feat: enhance textbook reader with anchor text support and improve knowledge point management
This commit is contained in:
@@ -201,102 +201,42 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 教师仪表盘体验优化 (2026-01-12)
|
## 7. 班级管理重构与角色分离 (2026-01-14)
|
||||||
|
|
||||||
**目标**: 提升教师仪表盘的信息密度与易用性,优化核心指标展示,调整布局以符合教师工作流。
|
**日期**: 2026-01-14
|
||||||
|
**范围**: 班级创建权限收归管理端,教师端仅保留查看与加入
|
||||||
|
|
||||||
### 7.1 核心指标卡片重构 (TeacherStats)
|
### 7.1 职责分离 (Role Separation)
|
||||||
- **原有问题**: 展示的总学生数、总课程数等静态指标对日常教学决策帮助有限。
|
|
||||||
- **优化方案**: 替换为高频动态指标,并增强视觉提示。
|
|
||||||
- **Needs Grading (待批改)**: 高亮显示待处理事项,使用 Amber 色彩引起注意。
|
|
||||||
- **Active Assignments (活跃作业)**: 显示当前发布的作业数量,反映教学负载。
|
|
||||||
- **Average Score (平均分)**: 展示近期作业平均分,快速了解学情。
|
|
||||||
- **Submission Rate (提交率)**: 展示整体作业完成度,反映学生参与度。
|
|
||||||
|
|
||||||
### 7.2 布局调整 (Layout Restructuring)
|
- **管理端 (Management)**:
|
||||||
- **原有问题**: "Needs Grading" 位于侧边栏,空间受限;"Homework" 列表占据主栏,信息密度低。
|
- 新增 `src/app/(dashboard)/management/grade/classes/page.tsx`
|
||||||
- **优化方案**:
|
- 供年级组长 (Grade Head) 与管理员创建、编辑、删除班级
|
||||||
- **Needs Grading 移至主栏**: 给予更多宽幅空间,展示详细的学生、作业信息及操作按钮。
|
- 引入 `GradeClassesView` 组件,支持按年级管理班级
|
||||||
- **Homework 移至侧边栏**: 改为紧凑列表视图,作为快速导航入口。
|
- **教师端 (Teacher)**:
|
||||||
- **Schedule 优化**: 引入时间轴 (Timeline) 视图,支持滚动提示与当前状态指示。
|
- 移除创建班级入口
|
||||||
|
- 新增「通过邀请码加入班级」功能 (`JoinClassDialog`)
|
||||||
|
- `MyClassesGrid` 样式优化,移除硬编码渐变,使用标准 `bg-card`
|
||||||
|
|
||||||
### 7.3 组件功能增强
|
### 7.2 数据访问与权限
|
||||||
- **RecentSubmissions (Needs Grading)**:
|
|
||||||
- 升级为 Table 视图,展示头像、作业名、提交时间。
|
|
||||||
- 增加 "Grade" 快捷按钮,一键进入批改页面。
|
|
||||||
- 增加 "Late" 状态标记。
|
|
||||||
- **TeacherSchedule**:
|
|
||||||
- 采用垂直时间轴设计。
|
|
||||||
- 增加滚动提示 (Scroll Hint) 与 "No more classes" 状态提示。
|
|
||||||
- **TeacherHomeworkCard**:
|
|
||||||
- 优化为紧凑型列表,显示发布状态 (Published/Draft) 与截止日期。
|
|
||||||
|
|
||||||
### 7.4 技术细节
|
- 新增 `getGradeManagedClasses`: 仅返回用户作为 Grade Head 或 Teaching Head 管理的年级下的班级
|
||||||
- 引入 `recharts` 替换手写 SVG 图表,统一图表风格。
|
- Server Actions (`createGradeClassAction` 等) 增加严格的 RBAC 校验,确保操作者对目标年级有管理权限
|
||||||
- 优化 Grid 布局响应式表现 (`lg:grid-cols-12`)。
|
|
||||||
- **New Components**:
|
|
||||||
- `TeacherGradeTrends`: 基于 Recharts 的趋势图组件。
|
|
||||||
- `Chart`: 基于 Shadcn/UI 规范的通用图表包装器 (`src/shared/components/ui/chart.tsx`)。
|
|
||||||
|
|
||||||
### 7.5 代码管理
|
## 8. 课表模块视觉升级与架构优化 (2026-01-15)
|
||||||
- **Branch**: `ui_opt`
|
|
||||||
- **Scope**: `src/modules/dashboard`, `src/shared/components/ui/chart.tsx`
|
|
||||||
- **Commit**: "feat(dashboard): optimize teacher dashboard ui and layout"
|
|
||||||
|
|
||||||
---
|
**日期**: 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)
|
### 8.2 架构精简 (Insights Removal)
|
||||||
- **分页 (Pagination)**: 引入客户端分页(每页 10 条),解决大班级列表渲染性能问题。
|
|
||||||
- **信息增强**:
|
|
||||||
- 增加学生头像 (Avatar)、性别、加入时间展示。
|
|
||||||
- 增加可视化状态徽章 (Status Badge):Active (Emerald) / Inactive (Muted)。
|
|
||||||
- **筛选能力**:
|
|
||||||
- 新增状态筛选器 (Active/Inactive),支持服务端过滤。
|
|
||||||
- **涉及组件**:
|
|
||||||
- `src/modules/classes/components/students-table.tsx`
|
|
||||||
- `src/modules/classes/components/students-filters.tsx`
|
|
||||||
|
|
||||||
### 8.2 班级详情页重构 (Class Detail Dashboard)
|
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
|
||||||
- **布局重构**: 采用响应式双栏布局 (Main Content + Sidebar),提升空间利用率。
|
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
|
||||||
- **核心指标 (Key Metrics)**: 顶部增加 4 卡片统计网格:
|
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。
|
||||||
- **Total Students**: 活跃/非活跃人数细分。
|
|
||||||
- **Schedule Items**: 每周课程数。
|
|
||||||
- **Active Assignments**: 活跃作业数与逾期数。
|
|
||||||
- **Class Average**: 基于已评分作业的平均分。
|
|
||||||
- **侧边栏小部件**:
|
|
||||||
- **Class Schedule**: 快速查看近期课程。
|
|
||||||
- **Homework History**: 快速查看历史作业状态。
|
|
||||||
- **涉及页面**:
|
|
||||||
- `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx`
|
|
||||||
|
|
||||||
### 8.3 数据访问层更新
|
|
||||||
- **getClassStudents**: 扩展查询字段(头像、性别、加入时间),支持 `status` 过滤参数。
|
|
||||||
|
|
||||||
### 8.4 权限与流程调整 (Role Separation)
|
|
||||||
- **教师端变更**:
|
|
||||||
- 移除了“创建班级”入口,教师不再直接创建班级。
|
|
||||||
- 新增“加入班级” (Join Class) 功能,通过 6 位邀请码加入已由管理员创建的班级。
|
|
||||||
- 涉及组件:`src/modules/classes/components/my-classes-grid.tsx`
|
|
||||||
|
|
||||||
## 9. 年级管理端班级模块 (2026-01-14)
|
|
||||||
|
|
||||||
**目标**: 实现年级维度的班级集中管理,支持年级组长与管理员统一创建与维护班级。
|
|
||||||
|
|
||||||
### 9.1 路由与入口
|
|
||||||
- **路由**: `src/app/(dashboard)/management/grade/classes/page.tsx`
|
|
||||||
- **权限**: 仅限拥有年级管理权限的角色(Grade Director / Teaching Head / Admin)。
|
|
||||||
|
|
||||||
### 9.2 功能特性
|
|
||||||
- **GradeClassesView**:
|
|
||||||
- 展示用户所管理年级的所有班级列表。
|
|
||||||
- 支持按年级筛选。
|
|
||||||
- **CRUD**: 提供创建、编辑、删除班级的完整能力。
|
|
||||||
- **RBAC**: 操作前校验用户对目标年级的管理权限。
|
|
||||||
|
|
||||||
### 9.3 核心变更
|
|
||||||
- **Data Access**: 新增 `getManagedGrades` 与 `getGradeManagedClasses`。
|
|
||||||
- **Actions**: 新增 `createGradeClassAction` 等带权限校验的 Server Actions。
|
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
# Work Log
|
# 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
|
## 2026-01-14
|
||||||
|
|
||||||
### 1. Class Management Refactoring (Role Separation)
|
### 1. Class Management Refactoring (Role Separation)
|
||||||
|
|||||||
1
drizzle/0008_thin_madrox.sql
Normal file
1
drizzle/0008_thin_madrox.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);
|
||||||
3071
drizzle/meta/0008_snapshot.json
Normal file
3071
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
|||||||
"when": 1768205524480,
|
"when": 1768205524480,
|
||||||
"tag": "0007_talented_bromley",
|
"tag": "0007_talented_bromley",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1768470966367,
|
||||||
|
"tag": "0008_thin_madrox",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
173
package-lock.json
generated
173
package-lock.json
generated
@@ -17,8 +17,10 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
@@ -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": {
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-id": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
|||||||
@@ -22,8 +22,10 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
import { ArrowLeft, BookOpen, Inbox } from "lucide-react"
|
import { BookOpen, Inbox } from "lucide-react"
|
||||||
|
|
||||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
|
||||||
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
||||||
|
|
||||||
@@ -34,29 +33,25 @@ export default async function StudentTextbookDetailPage({
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const [textbook, chapters] = await Promise.all([getTextbookById(id), getChaptersByTextbookId(id)])
|
const [textbook, chapters, knowledgePoints] = await Promise.all([
|
||||||
|
getTextbookById(id),
|
||||||
|
getChaptersByTextbookId(id),
|
||||||
|
getKnowledgePointsByTextbookId(id)
|
||||||
|
])
|
||||||
|
|
||||||
if (!textbook) notFound()
|
if (!textbook) notFound()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5">
|
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="flex items-center gap-4 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
|
<div className="flex items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
|
||||||
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground" asChild>
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Link href="/student/learning/textbooks">
|
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
Back
|
<span className="hidden sm:inline-block w-px h-4 bg-border" />
|
||||||
</Link>
|
<Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
|
||||||
</Button>
|
{textbook.grade && (
|
||||||
<div className="w-px h-8 bg-border mx-2" />
|
<Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
|
||||||
<div className="flex-1 min-w-0">
|
)}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h1 className="text-lg font-bold tracking-tight truncate mr-2">{textbook.title}</h1>
|
|
||||||
<Badge variant="secondary" className="font-normal text-xs">{textbook.subject}</Badge>
|
|
||||||
{textbook.grade && (
|
|
||||||
<span className="text-xs text-muted-foreground border px-1.5 py-0.5 rounded">
|
|
||||||
{textbook.grade}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +68,7 @@ export default async function StudentTextbookDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
||||||
<TextbookReader chapters={chapters} />
|
<TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
import Link from "next/link"
|
|
||||||
import { Suspense } from "react"
|
|
||||||
import { BarChart3 } from "lucide-react"
|
|
||||||
|
|
||||||
import { getClassHomeworkInsights, getTeacherClasses } from "@/modules/classes/data-access"
|
|
||||||
import { InsightsFilters } from "@/modules/classes/components/insights-filters"
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
|
||||||
|
|
||||||
const getParam = (params: SearchParams, key: string) => {
|
|
||||||
const v = params[key]
|
|
||||||
return Array.isArray(v) ? v[0] : v
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (v: number | null, digits = 1) => {
|
|
||||||
if (typeof v !== "number" || Number.isNaN(v)) return "-"
|
|
||||||
return v.toFixed(digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InsightsResultsFallback() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
|
||||||
<div key={idx} className="rounded-lg border bg-card">
|
|
||||||
<div className="p-6">
|
|
||||||
<Skeleton className="h-5 w-28" />
|
|
||||||
<Skeleton className="mt-3 h-8 w-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border bg-card">
|
|
||||||
<div className="p-4">
|
|
||||||
<Skeleton className="h-8 w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 p-4 pt-0">
|
|
||||||
{Array.from({ length: 8 }).map((_, idx) => (
|
|
||||||
<Skeleton key={idx} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function InsightsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
|
||||||
const params = await searchParams
|
|
||||||
const classId = getParam(params, "classId")
|
|
||||||
|
|
||||||
if (!classId || classId === "all") {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={BarChart3}
|
|
||||||
title="Select a class to view insights"
|
|
||||||
description="Pick a class to see latest homework and historical score statistics."
|
|
||||||
className="h-[360px] bg-card"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const insights = await getClassHomeworkInsights({ classId, limit: 50 })
|
|
||||||
if (!insights) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={BarChart3}
|
|
||||||
title="Class not found"
|
|
||||||
description="This class may not exist or is not accessible."
|
|
||||||
className="h-[360px] bg-card"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAssignments = insights.assignments.length > 0
|
|
||||||
|
|
||||||
if (!hasAssignments) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={BarChart3}
|
|
||||||
title="No homework data for this class"
|
|
||||||
description="No homework assignments were targeted to students in this class yet."
|
|
||||||
className="h-[360px] bg-card"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const latest = insights.latest
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Students</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{insights.assignments.length}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Latest: {latest ? formatDate(latest.createdAt) : "-"}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Overall scores</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{latest && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">Latest assignment</CardTitle>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">{latest.title}</span>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{latest.status}
|
|
||||||
</Badge>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{formatDate(latest.createdAt)}</span>
|
|
||||||
{latest.dueAt ? (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span>Due {formatDate(latest.dueAt)}</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4 md:grid-cols-5">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Targeted</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.targetCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Submitted</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.submittedCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Graded</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.gradedCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Average</div>
|
|
||||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Median</div>
|
|
||||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="rounded-md border bg-card">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Assignment</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Due</TableHead>
|
|
||||||
<TableHead className="text-right">Targeted</TableHead>
|
|
||||||
<TableHead className="text-right">Submitted</TableHead>
|
|
||||||
<TableHead className="text-right">Graded</TableHead>
|
|
||||||
<TableHead className="text-right">Avg</TableHead>
|
|
||||||
<TableHead className="text-right">Median</TableHead>
|
|
||||||
<TableHead className="text-right">Min</TableHead>
|
|
||||||
<TableHead className="text-right">Max</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{insights.assignments.map((a) => (
|
|
||||||
<TableRow key={a.assignmentId}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
|
|
||||||
{a.title}
|
|
||||||
</Link>
|
|
||||||
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{a.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
|
|
||||||
<TableCell className="text-right">{a.targetCount}</TableCell>
|
|
||||||
<TableCell className="text-right">{a.submittedCount}</TableCell>
|
|
||||||
<TableCell className="text-right">{a.gradedCount}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.min, 0)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.max, 0)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ClassInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
|
||||||
const classes = await getTeacherClasses()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Class Insights</h2>
|
|
||||||
<p className="text-muted-foreground">Latest homework and historical score statistics for a class.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
|
||||||
<InsightsFilters classes={classes} />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<Suspense fallback={<InsightsResultsFallback />}>
|
|
||||||
<InsightsResults searchParams={searchParams} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,39 +1,18 @@
|
|||||||
import Link from "next/link"
|
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { BookOpen, Calendar, ChevronRight, Clock, Users } from "lucide-react"
|
|
||||||
|
|
||||||
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
|
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
|
||||||
import { ScheduleView } from "@/modules/classes/components/schedule-view"
|
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
|
||||||
import { cn, formatDate } from "@/shared/lib/utils"
|
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
const getParam = (params: SearchParams, key: string) => {
|
|
||||||
const v = params[key]
|
|
||||||
return Array.isArray(v) ? v[0] : v
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (v: number | null, digits = 1) => {
|
|
||||||
if (typeof v !== "number" || Number.isNaN(v)) return "-"
|
|
||||||
return v.toFixed(digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getInitials = (name: string) => {
|
|
||||||
return name
|
|
||||||
.split(" ")
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ClassDetailPage({
|
export default async function ClassDetailPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -42,335 +21,96 @@ export default async function ClassDetailPage({
|
|||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const sp = await searchParams
|
|
||||||
const hw = getParam(sp, "hw")
|
|
||||||
const hwFilter = hw === "active" || hw === "overdue" ? hw : "all"
|
|
||||||
|
|
||||||
|
// Parallel data fetching
|
||||||
const [insights, students, schedule] = await Promise.all([
|
const [insights, students, schedule] = await Promise.all([
|
||||||
getClassHomeworkInsights({ classId: id, limit: 50 }),
|
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
|
||||||
getClassStudents({ classId: id }),
|
getClassStudents({ classId: id }),
|
||||||
getClassSchedule({ classId: id }),
|
getClassSchedule({ classId: id }),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!insights) return notFound()
|
if (!insights) return notFound()
|
||||||
|
|
||||||
const latest = insights.latest
|
// Fetch subject scores
|
||||||
const filteredAssignments = insights.assignments.filter((a) => {
|
const studentScores = await getClassStudentSubjectScoresV2(id)
|
||||||
if (hwFilter === "all") return true
|
|
||||||
if (hwFilter === "overdue") return a.isOverdue
|
// Data mapping for widgets
|
||||||
if (hwFilter === "active") return a.isActive
|
const assignmentSummaries = insights.assignments.map(a => ({
|
||||||
return true
|
id: a.assignmentId,
|
||||||
})
|
title: a.title,
|
||||||
const hasAssignments = filteredAssignments.length > 0
|
status: a.status,
|
||||||
const scheduleBuilderClasses = [
|
subject: a.subject,
|
||||||
{
|
isActive: a.isActive,
|
||||||
id: insights.class.id,
|
isOverdue: a.isOverdue,
|
||||||
name: insights.class.name,
|
dueAt: a.dueAt ? new Date(a.dueAt) : null,
|
||||||
grade: insights.class.grade,
|
submittedCount: a.submittedCount,
|
||||||
homeroom: insights.class.homeroom ?? null,
|
targetCount: a.targetCount,
|
||||||
room: insights.class.room ?? null,
|
avgScore: a.scoreStats.avg,
|
||||||
studentCount: insights.studentCounts.total,
|
medianScore: a.scoreStats.median
|
||||||
},
|
}))
|
||||||
]
|
|
||||||
|
const studentSummaries = students.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
email: s.email,
|
||||||
|
image: s.image,
|
||||||
|
status: s.status,
|
||||||
|
subjectScores: studentScores.get(s.id) ?? {}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Calculate advanced stats
|
||||||
|
const activeAssignments = insights.assignments.filter(a => a.isActive)
|
||||||
|
const papersToGrade = activeAssignments.reduce((acc, a) => acc + (a.submittedCount - a.gradedCount), 0)
|
||||||
|
const overdueCount = activeAssignments.filter(a => a.isOverdue).length
|
||||||
|
|
||||||
|
const totalSubmissionRate = activeAssignments.length > 0
|
||||||
|
? activeAssignments.reduce((acc, a) => acc + (a.targetCount > 0 ? a.submittedCount / a.targetCount : 0), 0) / activeAssignments.length
|
||||||
|
: 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-full space-y-8 p-8">
|
<div className="flex min-h-screen flex-col bg-muted/10">
|
||||||
{/* Header */}
|
<ClassHeader
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
classId={insights.class.id}
|
||||||
<div className="space-y-1.5">
|
name={insights.class.name}
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
grade={insights.class.grade}
|
||||||
<Link href="/teacher/classes/my" className="hover:text-foreground transition-colors">
|
homeroom={insights.class.homeroom}
|
||||||
My Classes
|
room={insights.class.room}
|
||||||
</Link>
|
schoolName={insights.class.schoolName}
|
||||||
<ChevronRight className="h-4 w-4" />
|
studentCount={insights.studentCounts.total}
|
||||||
<span className="text-foreground font-medium">{insights.class.name}</span>
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-6 p-6">
|
||||||
|
{/* Key Metrics */}
|
||||||
|
<ClassOverviewStats
|
||||||
|
averageScore={insights.overallScores.avg}
|
||||||
|
submissionRate={totalSubmissionRate * 100}
|
||||||
|
papersToGrade={papersToGrade}
|
||||||
|
overdueCount={overdueCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Content Area (Left 2/3) */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<ClassTrendsWidget
|
||||||
|
classId={insights.class.id}
|
||||||
|
assignments={assignmentSummaries}
|
||||||
|
/>
|
||||||
|
<ClassStudentsWidget
|
||||||
|
classId={insights.class.id}
|
||||||
|
students={studentSummaries}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight">{insights.class.name}</h2>
|
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
{/* Sidebar Area (Right 1/3) */}
|
||||||
<Badge variant="secondary" className="rounded-sm font-normal">
|
<div className="space-y-6">
|
||||||
{insights.class.grade}
|
{/* <ClassQuickActions classId={insights.class.id} /> */}
|
||||||
</Badge>
|
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
|
||||||
{insights.class.homeroom && (
|
<ClassAssignmentsWidget
|
||||||
<>
|
classId={insights.class.id}
|
||||||
<span className="w-1 h-1 rounded-full bg-border" />
|
assignments={assignmentSummaries}
|
||||||
<span>Homeroom: {insights.class.homeroom}</span>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{insights.class.room && (
|
|
||||||
<>
|
|
||||||
<span className="w-1 h-1 rounded-full bg-border" />
|
|
||||||
<span>Room: {insights.class.room}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
|
|
||||||
<Users className="mr-2 h-4 w-4" />
|
|
||||||
Students
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
|
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
|
||||||
Schedule
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>
|
|
||||||
Create Homework
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Total Students</CardTitle>
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{insights.studentCounts.active} active · {insights.studentCounts.inactive} inactive
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Schedule Items</CardTitle>
|
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{schedule.length}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Weekly sessions</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Active Assignments</CardTitle>
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{insights.assignments.filter((a) => a.isActive).length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{insights.assignments.filter((a) => a.isOverdue).length} overdue
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Class Average</CardTitle>
|
|
||||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}%</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Based on {insights.overallScores.count} graded submissions
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-7">
|
|
||||||
{/* Main Content Area */}
|
|
||||||
<div className="lg:col-span-4 space-y-6">
|
|
||||||
{/* Latest Homework */}
|
|
||||||
{latest && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle>Latest Homework</CardTitle>
|
|
||||||
<CardDescription>Most recent assignment activity</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Badge variant={latest.isActive ? "default" : "secondary"}>
|
|
||||||
{latest.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Link
|
|
||||||
href={`/teacher/homework/assignments/${latest.assignmentId}`}
|
|
||||||
className="font-semibold hover:underline"
|
|
||||||
>
|
|
||||||
{latest.title}
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>Due {latest.dueAt ? formatDate(latest.dueAt) : "No due date"}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{latest.submittedCount}/{latest.targetCount} Submitted</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>
|
|
||||||
Grade
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 border-t pt-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold">{latest.gradedCount}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Graded</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center border-l border-r">
|
|
||||||
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.avg, 1)}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Average</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.median, 1)}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Median</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Students Preview */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle>Students</CardTitle>
|
|
||||||
<CardDescription>Recently active students</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
|
|
||||||
View All
|
|
||||||
<ChevronRight className="ml-2 h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{students.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No students enrolled yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{students.slice(0, 5).map((s) => (
|
|
||||||
<div key={s.id} className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar className="h-9 w-9">
|
|
||||||
<AvatarImage src={s.image || undefined} />
|
|
||||||
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-sm">{s.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{s.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant={s.status === "active" ? "outline" : "secondary"} className="text-xs font-normal">
|
|
||||||
{s.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar Area */}
|
|
||||||
<div className="lg:col-span-3 space-y-6">
|
|
||||||
{/* Schedule Widget */}
|
|
||||||
<Card className="h-fit">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<CardTitle>Schedule</CardTitle>
|
|
||||||
<Button variant="ghost" size="icon" asChild>
|
|
||||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Homework History */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>History</CardTitle>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={hwFilter === "all" ? "secondary" : "ghost"}
|
|
||||||
asChild
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
>
|
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={hwFilter === "active" ? "secondary" : "ghost"}
|
|
||||||
asChild
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
>
|
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={hwFilter === "overdue" ? "secondary" : "ghost"}
|
|
||||||
asChild
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
>
|
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="divide-y">
|
|
||||||
{filteredAssignments.slice(0, 5).map((a) => (
|
|
||||||
<div key={a.assignmentId} className="p-4 hover:bg-muted/50 transition-colors">
|
|
||||||
<div className="flex items-start justify-between gap-4 mb-2">
|
|
||||||
<Link
|
|
||||||
href={`/teacher/homework/assignments/${a.assignmentId}`}
|
|
||||||
className="text-sm font-medium hover:underline line-clamp-1"
|
|
||||||
>
|
|
||||||
{a.title}
|
|
||||||
</Link>
|
|
||||||
<Badge variant={a.isActive ? "default" : "secondary"} className="shrink-0 text-[10px] h-5">
|
|
||||||
{a.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<span>{a.submittedCount} submitted</span>
|
|
||||||
<span>{formatNumber(a.scoreStats.avg, 0)}% avg</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{filteredAssignments.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
|
||||||
No assignments found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{filteredAssignments.length > 5 && (
|
|
||||||
<div className="p-2 border-t text-center">
|
|
||||||
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" asChild>
|
|
||||||
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>
|
|
||||||
View All Assignments
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,16 +15,7 @@ async function MyClassesPageImpl() {
|
|||||||
const classes = await getTeacherClasses()
|
const classes = await getTeacherClasses()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-4 p-8">
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">My Classes</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Overview of your classes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MyClassesGrid classes={classes} canCreateClass={false} />
|
<MyClassesGrid classes={classes} canCreateClass={false} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,16 +67,7 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
<div className="space-y-6">
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
View class schedule.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||||
<ScheduleFilters classes={classes} />
|
<ScheduleFilters classes={classes} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { User } from "lucide-react"
|
import { User } from "lucide-react"
|
||||||
|
|
||||||
import { getClassStudents, getTeacherClasses } from "@/modules/classes/data-access"
|
import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
|
||||||
import { StudentsFilters } from "@/modules/classes/components/students-filters"
|
import { StudentsFilters } from "@/modules/classes/components/students-filters"
|
||||||
import { StudentsTable } from "@/modules/classes/components/students-table"
|
import { StudentsTable } from "@/modules/classes/components/students-table"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
@@ -16,19 +16,34 @@ const getParam = (params: SearchParams, key: string) => {
|
|||||||
return Array.isArray(v) ? v[0] : v
|
return Array.isArray(v) ? v[0] : v
|
||||||
}
|
}
|
||||||
|
|
||||||
async function StudentsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }) {
|
||||||
const params = await searchParams
|
const params = await searchParams
|
||||||
|
|
||||||
const q = getParam(params, "q") || undefined
|
const q = getParam(params, "q") || undefined
|
||||||
const classId = getParam(params, "classId")
|
const classId = getParam(params, "classId")
|
||||||
const status = getParam(params, "status")
|
const status = getParam(params, "status")
|
||||||
|
|
||||||
|
// If classId is explicit in URL, use it (unless "all"). If not, use defaultClassId.
|
||||||
|
// If user explicitly selects "all", classId will be "all".
|
||||||
|
// However, the requirement is "Default to showing the first class".
|
||||||
|
// If classId param is missing, we use defaultClassId.
|
||||||
|
const targetClassId = classId ? (classId !== "all" ? classId : undefined) : defaultClassId
|
||||||
|
|
||||||
const filteredStudents = await getClassStudents({
|
const filteredStudents = await getClassStudents({
|
||||||
q,
|
q,
|
||||||
classId: classId && classId !== "all" ? classId : undefined,
|
classId: targetClassId,
|
||||||
status: status && status !== "all" ? status : undefined,
|
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"))
|
const hasFilters = Boolean(q || (classId && classId !== "all") || (status && status !== "all"))
|
||||||
|
|
||||||
if (filteredStudents.length === 0) {
|
if (filteredStudents.length === 0) {
|
||||||
@@ -67,25 +82,20 @@ function StudentsResultsFallback() {
|
|||||||
|
|
||||||
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const classes = await getTeacherClasses()
|
const classes = await getTeacherClasses()
|
||||||
|
const params = await searchParams
|
||||||
|
|
||||||
|
// Logic to determine default class (first one available)
|
||||||
|
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-4 p-8">
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Students</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage student list.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||||
<StudentsFilters classes={classes} />
|
<StudentsFilters classes={classes} defaultClassId={defaultClassId} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<Suspense fallback={<StudentsResultsFallback />}>
|
<Suspense fallback={<StudentsResultsFallback />}>
|
||||||
<StudentsResults searchParams={searchParams} />
|
<StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
|
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
|
||||||
import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout";
|
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader";
|
||||||
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
|
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -51,10 +51,11 @@ export default async function TextbookDetailPage({
|
|||||||
|
|
||||||
{/* Main Content Layout (Flex grow) */}
|
{/* Main Content Layout (Flex grow) */}
|
||||||
<div className="flex-1 overflow-hidden pt-6">
|
<div className="flex-1 overflow-hidden pt-6">
|
||||||
<TextbookContentLayout
|
<TextbookReader
|
||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
knowledgePoints={knowledgePoints}
|
knowledgePoints={knowledgePoints}
|
||||||
textbookId={id}
|
textbookId={id}
|
||||||
|
canEdit={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronRight, FileText } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
interface AssignmentSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
isActive: boolean
|
||||||
|
isOverdue: boolean
|
||||||
|
dueAt: Date | null
|
||||||
|
submittedCount: number
|
||||||
|
targetCount: number
|
||||||
|
avgScore: number | null
|
||||||
|
medianScore: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassAssignmentsWidgetProps {
|
||||||
|
classId: string
|
||||||
|
assignments: AssignmentSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
|
||||||
|
const activeAssignments = assignments.filter((a) => a.isActive)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base font-semibold">Recent Homework</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{activeAssignments.length} active assignments
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
View All
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{assignments.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-3">
|
||||||
|
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">No homework yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Create an assignment to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
Create Homework
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{assignments.slice(0, 5).map((assignment) => (
|
||||||
|
<div
|
||||||
|
key={assignment.id}
|
||||||
|
className="flex items-start justify-between space-x-4 rounded-md border p-3 transition-all hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Link
|
||||||
|
href={`/teacher/homework/assignments/${assignment.id}`}
|
||||||
|
className="block font-medium hover:underline line-clamp-1"
|
||||||
|
>
|
||||||
|
{assignment.title}
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className={assignment.isOverdue ? "text-destructive font-medium" : ""}>
|
||||||
|
Due {assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{assignment.submittedCount}/{assignment.targetCount} Submitted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={assignment.isActive ? "default" : "secondary"}
|
||||||
|
className="rounded-sm px-1.5 py-0.5 text-[10px] uppercase"
|
||||||
|
>
|
||||||
|
{assignment.status}
|
||||||
|
</Badge>
|
||||||
|
{typeof assignment.avgScore === "number" && (
|
||||||
|
<span className="text-xs font-medium tabular-nums">
|
||||||
|
Avg: {assignment.avgScore.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
src/modules/classes/components/class-detail/class-header.tsx
Normal file
119
src/modules/classes/components/class-detail/class-header.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import { EditClassDialog } from "./edit-class-dialog"
|
||||||
|
|
||||||
|
interface ClassHeaderProps {
|
||||||
|
classId: string
|
||||||
|
name: string
|
||||||
|
grade: string
|
||||||
|
homeroom?: string | null
|
||||||
|
room?: string | null
|
||||||
|
schoolName?: string | null
|
||||||
|
studentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassHeader({
|
||||||
|
classId,
|
||||||
|
name,
|
||||||
|
grade,
|
||||||
|
homeroom,
|
||||||
|
room,
|
||||||
|
schoolName,
|
||||||
|
studentCount,
|
||||||
|
}: ClassHeaderProps) {
|
||||||
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4 border-b bg-background px-6 py-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||||
|
{name}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{schoolName && (
|
||||||
|
<>
|
||||||
|
<span>{schoolName}</span>
|
||||||
|
<span className="text-muted-foreground/40">•</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="font-medium">
|
||||||
|
{grade}
|
||||||
|
</Badge>
|
||||||
|
{homeroom && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/40">•</span>
|
||||||
|
<span>Homeroom {homeroom}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{room && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/40">•</span>
|
||||||
|
<span>Room {room}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground/40">•</span>
|
||||||
|
<span>{studentCount} Students</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="hidden sm:flex">
|
||||||
|
<Share2 className="mr-2 h-4 w-4" />
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Share2 className="mr-2 h-4 w-4" />
|
||||||
|
Invite students
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Class settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditClassDialog
|
||||||
|
open={showEdit}
|
||||||
|
onOpenChange={setShowEdit}
|
||||||
|
classId={classId}
|
||||||
|
initialData={{
|
||||||
|
name,
|
||||||
|
grade,
|
||||||
|
homeroom,
|
||||||
|
room,
|
||||||
|
schoolName
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
interface ClassOverviewStatsProps {
|
||||||
|
averageScore: number | null
|
||||||
|
submissionRate: number
|
||||||
|
papersToGrade: number
|
||||||
|
overdueCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassOverviewStats({
|
||||||
|
averageScore,
|
||||||
|
submissionRate,
|
||||||
|
papersToGrade,
|
||||||
|
overdueCount,
|
||||||
|
}: ClassOverviewStatsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<StatsCard
|
||||||
|
title="Class Average"
|
||||||
|
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
|
||||||
|
subValue="Overall performance"
|
||||||
|
icon={BarChart3}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Submission Rate"
|
||||||
|
value={`${submissionRate.toFixed(0)}%`}
|
||||||
|
subValue="Average turn-in rate"
|
||||||
|
icon={CheckCircle2}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="To Grade"
|
||||||
|
value={papersToGrade.toString()}
|
||||||
|
subValue="Pending reviews"
|
||||||
|
icon={PenTool}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Missed Deadlines"
|
||||||
|
value={overdueCount.toString()}
|
||||||
|
subValue="Active assignments past due"
|
||||||
|
icon={AlertCircle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subValue,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
subValue: string
|
||||||
|
icon: React.ElementType
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between space-y-0 pb-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{subValue}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
interface ClassQuickActionsProps {
|
||||||
|
classId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-semibold">Quick Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
<Button asChild className="w-full justify-start" size="sm">
|
||||||
|
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
<FilePlus className="mr-2 h-4 w-4" />
|
||||||
|
Create Homework
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" className="w-full justify-start" size="sm">
|
||||||
|
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Manage Schedule
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
||||||
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
|
Message Class (Coming soon)
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Class Settings
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/shared/components/ui/hover-card"
|
||||||
|
import type { ClassScheduleItem } from "@/modules/classes/types"
|
||||||
|
|
||||||
|
interface ClassScheduleWidgetProps {
|
||||||
|
classId: string
|
||||||
|
schedule: ClassScheduleItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] // 1=Mon, 7=Sun
|
||||||
|
|
||||||
|
export function ClassScheduleGrid({ schedule, compact = false }: { schedule: ClassScheduleItem[], compact?: boolean }) {
|
||||||
|
// Group by weekday
|
||||||
|
const groupedSchedule = schedule.reduce((acc, item) => {
|
||||||
|
const day = item.weekday
|
||||||
|
if (!acc[day]) acc[day] = []
|
||||||
|
acc[day].push(item)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<number, ClassScheduleItem[]>)
|
||||||
|
|
||||||
|
// Sort items within each day by start time
|
||||||
|
Object.keys(groupedSchedule).forEach(key => {
|
||||||
|
groupedSchedule[Number(key)].sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (schedule.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-3 py-6 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-3">
|
||||||
|
<Calendar className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">No sessions scheduled.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
|
||||||
|
{WEEKDAYS.slice(0, 5).map((day, i) => (
|
||||||
|
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{WEEKDAY_INDICES.slice(0, 5).map((dayNum) => {
|
||||||
|
const items = groupedSchedule[dayNum] || []
|
||||||
|
return (
|
||||||
|
<div key={dayNum} className={`flex flex-col gap-1 py-1 border-r last:border-r-0 border-muted/30 ${compact ? 'max-h-[140px]' : 'min-h-[100px]'}`}>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="flex-1" />
|
||||||
|
) : (
|
||||||
|
items.map(item => (
|
||||||
|
<HoverCard key={item.id}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<div className="bg-primary/5 text-primary rounded-[2px] p-1 text-[10px] text-left relative hover:bg-primary/10 transition-colors cursor-default leading-tight shrink-0">
|
||||||
|
<div className="font-semibold truncate">{item.course}</div>
|
||||||
|
<div className="opacity-70 scale-90 origin-left mt-0.5 whitespace-nowrap">{item.startTime}-{item.endTime}</div>
|
||||||
|
</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="w-48 p-3" align="start" side="top">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="font-semibold text-sm border-b pb-1 mb-1">{item.course}</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||||
|
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{item.startTime} - {item.endTime}</span>
|
||||||
|
</div>
|
||||||
|
{item.location && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||||
|
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{item.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
|
||||||
|
return (
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-semibold">Weekly Schedule</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
Manage
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<ClassScheduleGrid schedule={schedule} />
|
||||||
|
<div className="mt-2 text-[10px] text-muted-foreground text-center">
|
||||||
|
* Showing Mon-Fri schedule
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronRight, Users } from "lucide-react"
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
interface StudentSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
image?: string | null
|
||||||
|
status: string
|
||||||
|
subjectScores?: Record<string, number | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassStudentsWidgetProps {
|
||||||
|
classId: string
|
||||||
|
students: StudentSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
|
||||||
|
const activeCount = students.filter(s => s.status === "active").length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base font-semibold">Students</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{activeCount} active students
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
View All
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{students.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-3">
|
||||||
|
<Users className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">No students enrolled yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{students.slice(0, 6).map((student) => (
|
||||||
|
<div key={student.id} className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={student.image || undefined} alt={student.name} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{student.name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-medium leading-none">{student.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground line-clamp-1">{student.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={student.status === "active" ? "outline" : "secondary"}
|
||||||
|
className="text-[10px] capitalize"
|
||||||
|
>
|
||||||
|
{student.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subject Scores */}
|
||||||
|
{student.subjectScores && Object.keys(student.subjectScores).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{Object.entries(student.subjectScores).map(([subject, score]) => (
|
||||||
|
<div key={subject} className="flex items-center gap-1.5 rounded bg-muted/50 px-2 py-1 text-[10px]">
|
||||||
|
<span className="font-medium text-muted-foreground">{subject}</span>
|
||||||
|
{score !== null ? (
|
||||||
|
<span className={score >= 60 ? "font-semibold text-primary" : "font-semibold text-destructive"}>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
|
||||||
|
interface AssignmentSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
subject?: string | null
|
||||||
|
isActive: boolean
|
||||||
|
isOverdue: boolean
|
||||||
|
dueAt: Date | null
|
||||||
|
submittedCount: number
|
||||||
|
targetCount: number
|
||||||
|
avgScore: number | null
|
||||||
|
medianScore: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassTrendsWidgetProps {
|
||||||
|
classId: string
|
||||||
|
assignments: AssignmentSummary[]
|
||||||
|
compact?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
submitted: {
|
||||||
|
label: "Submitted",
|
||||||
|
color: "hsl(var(--primary))",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
label: "Total Students",
|
||||||
|
color: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
avg: {
|
||||||
|
label: "Average Score",
|
||||||
|
color: "hsl(var(--chart-2))",
|
||||||
|
},
|
||||||
|
median: {
|
||||||
|
label: "Median Score",
|
||||||
|
color: "hsl(var(--chart-4))",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
export function transformAssignmentsToChartData(assignments: AssignmentSummary[], limit?: number) {
|
||||||
|
const data = [...assignments].reverse().map(a => ({
|
||||||
|
title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
|
||||||
|
fullTitle: a.title,
|
||||||
|
submitted: a.submittedCount,
|
||||||
|
target: a.targetCount,
|
||||||
|
avg: a.avgScore ? Math.round(a.avgScore) : null,
|
||||||
|
median: a.medianScore ? Math.round(a.medianScore) : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
return data.slice(-limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassSubmissionTrendChart({
|
||||||
|
data,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
data: any[]
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ChartContainer config={chartConfig} className={className}>
|
||||||
|
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="title"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={10}
|
||||||
|
hide
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={10}
|
||||||
|
domain={[0, 'auto']}
|
||||||
|
tickFormatter={(value) => `${value}`}
|
||||||
|
hide
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="target"
|
||||||
|
stroke="var(--color-target)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="submitted"
|
||||||
|
stroke="var(--color-submitted)"
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
|
||||||
|
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
|
||||||
|
const [selectedSubject, setSelectedSubject] = useState<string>("all")
|
||||||
|
|
||||||
|
// Extract unique subjects
|
||||||
|
const subjects = Array.from(new Set(assignments.map(a => a.subject).filter(Boolean))) as string[]
|
||||||
|
|
||||||
|
const activeAssignments = assignments.filter((a) => {
|
||||||
|
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
|
||||||
|
return a.isActive || a.status === "published" // Include published even if not "active" in terms of due date
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartData = transformAssignmentsToChartData(activeAssignments, 7)
|
||||||
|
|
||||||
|
if (chartData.length === 0 && selectedSubject === "all") return null
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
// Calculate simple stats for compact view
|
||||||
|
const lastAssignment = chartData[chartData.length - 1]
|
||||||
|
|
||||||
|
let metricValue = "0%"
|
||||||
|
let metricLabel = "Latest"
|
||||||
|
|
||||||
|
if (lastAssignment) {
|
||||||
|
if (chartTab === "submission") {
|
||||||
|
metricValue = lastAssignment.target > 0
|
||||||
|
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
|
||||||
|
: "0%"
|
||||||
|
} else {
|
||||||
|
metricValue = lastAssignment.avg ? `${lastAssignment.avg}` : "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full ${className || ""}`}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs font-semibold text-foreground/80 hover:bg-muted">
|
||||||
|
{chartTab === "submission" ? "Submission" : "Score"}
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
|
||||||
|
Submission Trends
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
|
||||||
|
Score Trends
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{subjects.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
{selectedSubject === "all" ? "All Subjects" : selectedSubject}
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
|
||||||
|
All Subjects
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{subjects.map(s => (
|
||||||
|
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
|
||||||
|
{s}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
{metricLabel}: <span className="text-foreground">{metricValue}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact Sparkline Chart */}
|
||||||
|
<div className="flex-1 w-full min-h-0">
|
||||||
|
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||||
|
{chartTab === "submission" ? (
|
||||||
|
<AreaChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillSubmitted" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-submitted)" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="var(--color-submitted)" stopOpacity={0.05}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis dataKey="title" hide />
|
||||||
|
<YAxis hide domain={[0, 'auto']} />
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent indicator="dot" hideLabel />}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="submitted"
|
||||||
|
stroke="var(--color-submitted)"
|
||||||
|
fill="url(#fillSubmitted)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="target"
|
||||||
|
stroke="var(--color-target)"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="2 2"
|
||||||
|
dot={false}
|
||||||
|
activeDot={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
) : (
|
||||||
|
<LineChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis dataKey="title" hide />
|
||||||
|
<YAxis hide domain={[0, 100]} />
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent indicator="dot" />}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="avg"
|
||||||
|
stroke="var(--color-avg)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="median"
|
||||||
|
stroke="var(--color-median)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base font-semibold">
|
||||||
|
{chartTab === "submission" ? "Submission Trends" : "Score Trends"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Tabs value={chartTab} onValueChange={(v) => setChartTab(v as "submission" | "score")} className="w-auto">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 h-8">
|
||||||
|
<TabsTrigger value="submission" className="text-xs">Submission</TabsTrigger>
|
||||||
|
<TabsTrigger value="score" className="text-xs">Score</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subjects.length > 0 && (
|
||||||
|
<Tabs value={selectedSubject} onValueChange={setSelectedSubject} className="w-full">
|
||||||
|
<TabsList className="h-8 w-auto flex-wrap justify-start bg-transparent p-0">
|
||||||
|
<TabsTrigger
|
||||||
|
value="all"
|
||||||
|
className="h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
|
||||||
|
>
|
||||||
|
All Subjects
|
||||||
|
</TabsTrigger>
|
||||||
|
{subjects.map(s => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={s}
|
||||||
|
value={s}
|
||||||
|
className="ml-2 h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ChartContainer config={chartConfig} className="h-[250px] w-full">
|
||||||
|
{chartTab === "submission" ? (
|
||||||
|
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="title"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={12}
|
||||||
|
domain={[0, 'auto']}
|
||||||
|
tickFormatter={(value) => `${value}`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="target"
|
||||||
|
stroke="var(--color-target)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="submitted"
|
||||||
|
stroke="var(--color-submitted)"
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
) : (
|
||||||
|
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="title"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={12}
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="avg"
|
||||||
|
stroke="var(--color-avg)"
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="median"
|
||||||
|
stroke="var(--color-median)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
</ChartContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No data for this subject
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { updateTeacherClassAction } from "../../actions"
|
||||||
|
|
||||||
|
interface EditClassDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
classId: string
|
||||||
|
initialData: {
|
||||||
|
name: string
|
||||||
|
grade: string
|
||||||
|
homeroom?: string | null
|
||||||
|
room?: string | null
|
||||||
|
schoolName?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditClassDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
classId,
|
||||||
|
initialData,
|
||||||
|
}: EditClassDialogProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
|
const handleEdit = async (formData: FormData) => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await updateTeacherClassAction(classId, null, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
onOpenChange(false)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to update class")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update class")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
if (isWorking) return
|
||||||
|
onOpenChange(val)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit class</DialogTitle>
|
||||||
|
<DialogDescription>Update basic class information.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleEdit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="schoolName" className="text-right">
|
||||||
|
School
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="schoolName"
|
||||||
|
name="schoolName"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.schoolName ?? ""}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="name" className="text-right">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="grade" className="text-right">
|
||||||
|
Grade
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="grade"
|
||||||
|
name="grade"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.grade}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="homeroom" className="text-right">
|
||||||
|
Homeroom
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="homeroom"
|
||||||
|
name="homeroom"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.homeroom ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="room" className="text-right">
|
||||||
|
Room
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="room"
|
||||||
|
name="room"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.room ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isWorking}>
|
||||||
|
{isWorking ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
|
||||||
import type { TeacherClass } from "../types"
|
|
||||||
|
|
||||||
export function InsightsFilters({ classes }: { classes: TeacherClass[] }) {
|
|
||||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
|
||||||
<SelectTrigger className="w-[240px]">
|
|
||||||
<SelectValue placeholder="Class" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Select a class</SelectItem>
|
|
||||||
{classes.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{classId !== "all" && (
|
|
||||||
<Button variant="ghost" onClick={() => setClassId(null)} className="h-8 px-2 lg:px-3">
|
|
||||||
Reset
|
|
||||||
<X className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -4,44 +4,21 @@ import Link from "next/link"
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
Calendar,
|
|
||||||
Copy,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Copy,
|
||||||
Trash2,
|
|
||||||
Users,
|
Users,
|
||||||
GraduationCap,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
ChartBar,
|
GraduationCap,
|
||||||
|
Search,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { parseAsString, useQueryState } from "nuqs"
|
|
||||||
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -53,15 +30,11 @@ import {
|
|||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||||
import type { TeacherClass } from "../types"
|
import type { TeacherClass, ClassScheduleItem } from "../types"
|
||||||
import {
|
import {
|
||||||
createTeacherClassAction,
|
|
||||||
deleteTeacherClassAction,
|
|
||||||
ensureClassInvitationCodeAction,
|
ensureClassInvitationCodeAction,
|
||||||
regenerateClassInvitationCodeAction,
|
regenerateClassInvitationCodeAction,
|
||||||
updateTeacherClassAction,
|
|
||||||
joinClassByInvitationCodeAction,
|
joinClassByInvitationCodeAction,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
|
|
||||||
@@ -82,26 +55,6 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
const [joinOpen, setJoinOpen] = 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<string>()
|
|
||||||
for (const c of classes) set.add(c.grade)
|
|
||||||
return Array.from(set).sort((a, b) => a.localeCompare(b))
|
|
||||||
}, [classes])
|
|
||||||
|
|
||||||
const filteredClasses = useMemo(() => {
|
|
||||||
const needle = q.trim().toLowerCase()
|
|
||||||
return classes.filter((c) => {
|
|
||||||
const gradeOk = grade === "all" ? true : c.grade === grade
|
|
||||||
const qOk = needle.length === 0 ? true : c.name.toLowerCase().includes(needle)
|
|
||||||
return gradeOk && qOk
|
|
||||||
})
|
|
||||||
}, [classes, grade, q])
|
|
||||||
|
|
||||||
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
|
|
||||||
|
|
||||||
const handleJoin = async (formData: FormData) => {
|
const handleJoin = async (formData: FormData) => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
@@ -123,117 +76,98 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Filter Bar */}
|
{/* Filter Bar */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="relative">
|
||||||
<div className="relative flex-1 md:max-w-[320px]">
|
<Dialog
|
||||||
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
open={joinOpen}
|
||||||
<Input
|
onOpenChange={(open) => {
|
||||||
placeholder="Search classes..."
|
if (isWorking) return
|
||||||
value={q}
|
setJoinOpen(open)
|
||||||
onChange={(e) => setQ(e.target.value || null)}
|
}}
|
||||||
className="pl-9 bg-background"
|
>
|
||||||
/>
|
<DialogTrigger asChild>
|
||||||
</div>
|
<div className="group relative">
|
||||||
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
|
{/* Decorative Ticket Stub Effect */}
|
||||||
<SelectTrigger className="w-[160px] bg-background">
|
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 to-secondary/20 rounded-lg blur opacity-30 group-hover:opacity-60 transition duration-500"></div>
|
||||||
<SelectValue placeholder="All Grades" />
|
<Button className="relative gap-2 h-10 px-5 shadow-sm border border-primary/10 hover:shadow-md transition-all bg-background text-foreground hover:bg-muted/50" disabled={isWorking} variant="outline">
|
||||||
</SelectTrigger>
|
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary group-hover:scale-110 transition-transform duration-300">
|
||||||
<SelectContent>
|
<Plus className="size-3.5" strokeWidth={3} />
|
||||||
<SelectItem value="all">All Grades</SelectItem>
|
</div>
|
||||||
{gradeOptions.map((g) => (
|
<span className="font-semibold tracking-tight">Join New Class</span>
|
||||||
<SelectItem key={g} value={g}>
|
|
||||||
{g}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{(q || grade !== "all") && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
setQ(null)
|
|
||||||
setGrade(null)
|
|
||||||
}}
|
|
||||||
title="Clear filters"
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={joinOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (isWorking) return
|
|
||||||
setJoinOpen(open)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="gap-2 shadow-sm" disabled={isWorking}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Join Class
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Join Class</DialogTitle>
|
|
||||||
<DialogDescription>Enter the invitation code to join a class.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form action={handleJoin}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="join-code" className="text-right">
|
|
||||||
Code
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="join-code"
|
|
||||||
name="code"
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="e.g. 123456"
|
|
||||||
required
|
|
||||||
maxLength={6}
|
|
||||||
pattern="\d{6}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isWorking}>
|
|
||||||
{isWorking ? "Joining..." : "Join Class"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</form>
|
</DialogTrigger>
|
||||||
</DialogContent>
|
<DialogContent className="sm:max-w-[480px] p-0 overflow-hidden gap-0 border-none shadow-2xl">
|
||||||
</Dialog>
|
{/* Header with Pattern */}
|
||||||
|
<div className="relative bg-primary/5 p-6 border-b border-border/50">
|
||||||
|
<div className="absolute inset-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '12px 12px' }}></div>
|
||||||
|
<DialogHeader className="relative z-10">
|
||||||
|
<DialogTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||||
|
<Plus className="size-5" />
|
||||||
|
</div>
|
||||||
|
Join a Class
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground mt-1.5">
|
||||||
|
Enter the 6-digit invitation code provided by your administrator.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={handleJoin} className="bg-card">
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="join-code" className="text-sm font-medium">
|
||||||
|
Invitation Code
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="join-code"
|
||||||
|
name="code"
|
||||||
|
className="h-12 text-center text-2xl font-mono tracking-[0.5em] font-bold uppercase placeholder:tracking-normal placeholder:font-sans placeholder:text-base placeholder:font-normal"
|
||||||
|
placeholder="e.g. 123456"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
pattern="\d{6}"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/30 pointer-events-none">
|
||||||
|
<Users className="size-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ask your administrator for the code if you don't have one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
|
||||||
|
{isWorking ? "Joining..." : "Join Class"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* List */}
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="flex flex-col gap-4">
|
||||||
{classes.length === 0 ? (
|
{classes.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No classes yet"
|
title="No classes yet"
|
||||||
description="Join a class to start managing students and schedules."
|
description="Join a class to start managing students and schedules."
|
||||||
icon={Users}
|
icon={Users}
|
||||||
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
|
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
|
||||||
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
|
className="h-[360px] bg-card border-dashed"
|
||||||
/>
|
|
||||||
) : filteredClasses.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="No classes match your filters"
|
|
||||||
description="Try clearing filters or adjusting keywords."
|
|
||||||
icon={Search}
|
|
||||||
action={{
|
|
||||||
label: "Clear filters",
|
|
||||||
onClick: () => {
|
|
||||||
setQ(null)
|
|
||||||
setGrade(null)
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
filteredClasses.map((c) => (
|
classes.map((c) => (
|
||||||
<ClassCard key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -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,
|
c,
|
||||||
isWorking,
|
isWorking,
|
||||||
onWorkingChange,
|
onWorkingChange,
|
||||||
@@ -251,8 +190,6 @@ function ClassCard({
|
|||||||
onWorkingChange: (v: boolean) => void
|
onWorkingChange: (v: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [showEdit, setShowEdit] = useState(false)
|
|
||||||
const [showDelete, setShowDelete] = useState(false)
|
|
||||||
|
|
||||||
const handleEnsureCode = async () => {
|
const handleEnsureCode = async () => {
|
||||||
onWorkingChange(true)
|
onWorkingChange(true)
|
||||||
@@ -299,277 +236,160 @@ function ClassCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = async (formData: FormData) => {
|
// Real data for chart
|
||||||
onWorkingChange(true)
|
const recentAssignments = c.recentAssignments ?? []
|
||||||
try {
|
|
||||||
const res = await updateTeacherClassAction(c.id, null, formData)
|
// Calculate performance change for indicator (still needed for the top indicator)
|
||||||
if (res.success) {
|
// We can't reuse chart data easily here without recalculating, but ClassTrendsWidget handles its own data now
|
||||||
toast.success(res.message)
|
const lastTwoAssignments = [...recentAssignments].reverse().slice(-2)
|
||||||
setShowEdit(false)
|
const performanceChange = lastTwoAssignments.length === 2 && lastTwoAssignments[0].submittedCount > 0
|
||||||
router.refresh()
|
? ((lastTwoAssignments[1].submittedCount - lastTwoAssignments[0].submittedCount) / lastTwoAssignments[0].submittedCount) * 100
|
||||||
} else {
|
: 0
|
||||||
toast.error(res.message || "Failed to update class")
|
const isPositive = performanceChange >= 0
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to update class")
|
|
||||||
} finally {
|
|
||||||
onWorkingChange(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
onWorkingChange(true)
|
|
||||||
try {
|
|
||||||
const res = await deleteTeacherClassAction(c.id)
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(res.message)
|
|
||||||
setShowDelete(false)
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || "Failed to delete class")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to delete class")
|
|
||||||
} finally {
|
|
||||||
onWorkingChange(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("group flex flex-col transition-all hover:shadow-md", getClassGradient(c.id))}>
|
<div className="group relative flex w-full overflow-hidden rounded-xl border bg-card shadow-sm transition-all hover:shadow-md">
|
||||||
<CardHeader className="relative pb-3">
|
{/* Realistic Paper Texture & Noise */}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="absolute inset-0 pointer-events-none opacity-[0.02]" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\'/%3E%3C/svg%3E")' }}></div>
|
||||||
<div className="space-y-1">
|
<div className="absolute inset-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '16px 16px' }}></div>
|
||||||
<CardTitle className="line-clamp-1 text-lg font-bold leading-none tracking-tight">
|
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
|
{/* Decorative Barcode Strip */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
|
||||||
|
{Array.from({ length: 20 }).map((_, i) => (
|
||||||
|
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left Section: Basic Info (Narrower) */}
|
||||||
|
<div className="flex w-full flex-col justify-between p-5 pl-7 sm:w-[320px] sm:flex-shrink-0 relative z-10 border-r border-dashed border-muted-foreground/20">
|
||||||
|
{/* Punch Hole Effect Top-Left */}
|
||||||
|
<div className="absolute -left-2 -top-2 h-6 w-6 rounded-full bg-background border border-border shadow-[inset_1px_1px_2px_rgba(0,0,0,0.1)] z-20"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/5 text-xl font-bold text-primary shadow-sm border border-primary/10">
|
||||||
|
{c.grade.replace(/[^0-9]/g, '')}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="text-lg font-bold hover:underline tracking-tight line-clamp-1">
|
||||||
{c.name}
|
{c.name}
|
||||||
</Link>
|
</Link>
|
||||||
</CardTitle>
|
<Badge variant="secondary" className="w-fit font-normal text-xs bg-muted/50 font-mono tracking-tight">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
{c.grade} • {c.id.slice(-4).toUpperCase()}
|
||||||
<Badge variant="secondary" className="h-5 px-1.5 font-medium">
|
|
||||||
{c.grade}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
{c.homeroom && (
|
|
||||||
<Badge variant="outline" className="h-5 border-dashed bg-transparent px-1.5 font-normal">
|
|
||||||
{c.homeroom}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 -mr-2" disabled={isWorking}>
|
<div className="flex items-center gap-2">
|
||||||
<MoreHorizontal className="size-4" />
|
<Users className="size-4 text-muted-foreground/70" />
|
||||||
<span className="sr-only">Actions</span>
|
<span className="font-medium text-foreground/80">{c.studentCount}</span> Students
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
|
||||||
<Pencil className="mr-2 size-4" />
|
|
||||||
Edit Class
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => setShowDelete(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 size-4" />
|
|
||||||
Delete Class
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex-1 pb-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs text-muted-foreground">Students</span>
|
|
||||||
<div className="flex items-center gap-1.5 font-medium">
|
|
||||||
<Users className="size-3.5 text-muted-foreground" />
|
|
||||||
{c.studentCount}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-col gap-1">
|
<MapPin className="size-4 text-muted-foreground/70" />
|
||||||
<span className="text-xs text-muted-foreground">Room</span>
|
<span className="font-medium text-foreground/80">{c.room || "No Room"}</span>
|
||||||
<div className="flex items-center gap-1.5 font-medium">
|
|
||||||
<MapPin className="size-3.5 text-muted-foreground" />
|
|
||||||
{c.room || "—"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{c.schoolName && (
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<GraduationCap className="size-4 text-muted-foreground/70" />
|
||||||
<div className="mt-4 flex items-center justify-between rounded-md border bg-background/50 p-2">
|
<span className="line-clamp-1">{c.schoolName}</span>
|
||||||
<div className="flex flex-col">
|
</div>
|
||||||
<span className="text-[10px] uppercase text-muted-foreground">Invite Code</span>
|
|
||||||
<span className="font-mono text-sm font-medium tracking-wider">{c.invitationCode || "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{c.invitationCode ? (
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopyCode} disabled={isWorking}>
|
|
||||||
<Copy className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Copy Code</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={handleRegenerateCode}
|
|
||||||
disabled={isWorking}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Regenerate</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleEnsureCode} disabled={isWorking}>
|
|
||||||
Generate
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="grid grid-cols-3 gap-2 border-t p-2">
|
{/* Invitation Code Section */}
|
||||||
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
|
<div className="mt-6 pt-4 border-t border-dashed border-border relative">
|
||||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
|
{/* Tiny Cut marks */}
|
||||||
<Users className="mr-1.5 size-3.5" />
|
<div className="absolute -left-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||||
Students
|
<div className="absolute -right-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
|
|
||||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
|
|
||||||
<Calendar className="mr-1.5 size-3.5" />
|
|
||||||
Schedule
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
|
|
||||||
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(c.id)}`}>
|
|
||||||
<ChartBar className="mr-1.5 size-3.5" />
|
|
||||||
Insights
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
|
|
||||||
{/* Dialogs */}
|
<div className="flex flex-col gap-1.5">
|
||||||
<Dialog
|
<div className="flex items-center justify-between">
|
||||||
open={showEdit}
|
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">Entry Pass</span>
|
||||||
onOpenChange={(open) => {
|
<div className="flex gap-0.5">
|
||||||
if (isWorking) return
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
setShowEdit(open)
|
<div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
|
||||||
}}
|
))}
|
||||||
>
|
</div>
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
</div>
|
||||||
<DialogHeader>
|
<div className="flex items-center justify-between gap-2 bg-muted/30 px-3 py-1.5 rounded-sm border border-dashed border-muted-foreground/30 relative overflow-hidden">
|
||||||
<DialogTitle>Edit class</DialogTitle>
|
<span className="font-mono text-lg font-bold tracking-widest text-foreground z-10">{c.invitationCode || "—"}</span>
|
||||||
<DialogDescription>Update basic class information.</DialogDescription>
|
|
||||||
</DialogHeader>
|
{/* Faint QR Code Placeholder Background */}
|
||||||
<form action={handleEdit}>
|
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
|
||||||
<div className="grid gap-4 py-4">
|
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
{Array.from({ length: 16 }).map((_, i) => (
|
||||||
<Label htmlFor={`edit-school-name-${c.id}`} className="text-right">
|
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
|
||||||
School
|
))}
|
||||||
</Label>
|
</div>
|
||||||
<Input
|
</div>
|
||||||
id={`edit-school-name-${c.id}`}
|
|
||||||
name="schoolName"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.schoolName ?? ""}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor={`edit-name-${c.id}`} className="text-right">
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`edit-name-${c.id}`}
|
|
||||||
name="name"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.name}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor={`edit-grade-${c.id}`} className="text-right">
|
|
||||||
Grade
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`edit-grade-${c.id}`}
|
|
||||||
name="grade"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.grade}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor={`edit-homeroom-${c.id}`} className="text-right">
|
|
||||||
Homeroom
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`edit-homeroom-${c.id}`}
|
|
||||||
name="homeroom"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.homeroom ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor={`edit-room-${c.id}`} className="text-right">
|
|
||||||
Room
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`edit-room-${c.id}`}
|
|
||||||
name="room"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.room ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isWorking}>
|
|
||||||
{isWorking ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
{c.invitationCode ? (
|
||||||
open={showDelete}
|
<div className="flex gap-1 z-10">
|
||||||
onOpenChange={(open) => {
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title="Copy">
|
||||||
if (isWorking) return
|
<Copy className="size-3.5" />
|
||||||
setShowDelete(open)
|
</Button>
|
||||||
}}
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title="Regenerate">
|
||||||
>
|
<RefreshCw className="size-3.5" />
|
||||||
<AlertDialogContent>
|
</Button>
|
||||||
<AlertDialogHeader>
|
</div>
|
||||||
<AlertDialogTitle>Delete class?</AlertDialogTitle>
|
) : (
|
||||||
<AlertDialogDescription>
|
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
|
||||||
This will permanently delete <span className="font-medium text-foreground">{c.name}</span> and remove all
|
Generate
|
||||||
enrollments.
|
</Button>
|
||||||
</AlertDialogDescription>
|
)}
|
||||||
</AlertDialogHeader>
|
</div>
|
||||||
<AlertDialogFooter>
|
</div>
|
||||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
</div>
|
||||||
<AlertDialogAction
|
</div>
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
onClick={handleDelete}
|
{/* Dashed Divider (Ticket perforation) */}
|
||||||
disabled={isWorking}
|
<div className="relative hidden w-4 flex-col items-center justify-center sm:flex -ml-2 z-20">
|
||||||
>
|
<div className="absolute -top-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_-1px_1px_rgba(0,0,0,0.05)]" />
|
||||||
{isWorking ? "Deleting..." : "Delete Class"}
|
<div className="h-full w-px border-l-2 border-dashed border-muted-foreground/20 relative">
|
||||||
</AlertDialogAction>
|
{/* Scissor Icon */}
|
||||||
</AlertDialogFooter>
|
<div className="absolute top-1/2 -left-[5px] -translate-y-1/2 text-muted-foreground/20 -rotate-90 text-[10px]">✂</div>
|
||||||
</AlertDialogContent>
|
</div>
|
||||||
</AlertDialog>
|
<div className="absolute -bottom-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_1px_1px_rgba(0,0,0,0.05)]" />
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section: Stats & Actions (Wider) */}
|
||||||
|
<div className="flex flex-1 flex-col bg-muted/5 p-6 relative z-10">
|
||||||
|
<div className="flex flex-1 gap-6">
|
||||||
|
{/* Left: Submission Trends */}
|
||||||
|
<div className="flex-1 flex flex-col gap-4 min-w-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground/80">Submission Trends</h4>
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
|
||||||
|
isPositive
|
||||||
|
? "text-emerald-600 bg-emerald-50 border-emerald-100"
|
||||||
|
: "text-red-600 bg-red-50 border-red-100"
|
||||||
|
)}>
|
||||||
|
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>vs last week</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real Chart */}
|
||||||
|
<div className="h-[140px] w-full">
|
||||||
|
<ClassTrendsWidget
|
||||||
|
classId={c.id}
|
||||||
|
assignments={recentAssignments}
|
||||||
|
compact
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Weekly Schedule */}
|
||||||
|
<div className="flex-1 flex flex-col gap-4 border-l border-dashed border-muted-foreground/20 pl-6 min-w-0">
|
||||||
|
<div className="h-[170px] w-full overflow-y-auto pr-1">
|
||||||
|
<ClassScheduleGrid schedule={c.schedule ?? []} compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Plus, X } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -29,7 +29,7 @@ import type { TeacherClass } from "../types"
|
|||||||
import { createClassScheduleItemAction } from "../actions"
|
import { createClassScheduleItemAction } from "../actions"
|
||||||
|
|
||||||
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -64,33 +64,29 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedClass = classes.find((c) => c.id === classId)
|
||||||
|
const title = selectedClass ? selectedClass.name : "All Classes"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="relative flex items-center justify-between py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
|
||||||
<SelectTrigger className="w-[240px]">
|
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
|
||||||
<SelectValue placeholder="Class" />
|
<SelectValue placeholder="All Classes" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Classes</SelectItem>
|
<SelectItem value="all" className="text-xs">All Classes</SelectItem>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
<SelectItem key={c.id} value={c.id}>
|
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||||
{c.name}
|
{c.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{classId !== "all" && (
|
<div className="absolute left-1/2 -translate-x-1/2 text-sm font-medium text-muted-foreground">
|
||||||
<Button
|
{title}
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setClassId(null)}
|
|
||||||
className="h-8 px-2 lg:px-3"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
<X className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -101,9 +97,13 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="gap-2" disabled={classes.length === 0}>
|
<Button
|
||||||
<Plus className="size-4" />
|
className="h-8 gap-1.5 text-xs px-3 shadow-none border-transparent bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
Add item
|
disabled={classes.length === 0}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add Event
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
|||||||
@@ -151,88 +151,145 @@ export function ScheduleView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPositionStyle = (startTime: string, endTime: string) => {
|
||||||
|
// Range 8:00 (480 min) -> 18:00 (1080 min)
|
||||||
|
// Total duration: 600 min
|
||||||
|
const startParts = startTime.split(':').map(Number)
|
||||||
|
const endParts = endTime.split(':').map(Number)
|
||||||
|
|
||||||
|
const startMinutes = startParts[0] * 60 + startParts[1]
|
||||||
|
const endMinutes = endParts[0] * 60 + endParts[1]
|
||||||
|
|
||||||
|
const minTime = 8 * 60
|
||||||
|
const maxTime = 18 * 60
|
||||||
|
const totalDuration = maxTime - minTime
|
||||||
|
|
||||||
|
// Calculate percentage positions
|
||||||
|
const top = Math.max(0, ((startMinutes - minTime) / totalDuration) * 100)
|
||||||
|
const height = Math.min(100 - top, ((endMinutes - startMinutes) / totalDuration) * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${top}%`,
|
||||||
|
height: `${height}%`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i) // 8, 9, ..., 18
|
||||||
|
|
||||||
|
// Predefined colors for different subjects to add visual variety
|
||||||
|
const getSubjectColor = (subject: string) => {
|
||||||
|
const s = subject.toLowerCase()
|
||||||
|
if (s.includes('math')) return 'bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20'
|
||||||
|
if (s.includes('physics') || s.includes('science')) return 'bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20'
|
||||||
|
if (s.includes('english') || s.includes('lit')) return 'bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20'
|
||||||
|
if (s.includes('history') || s.includes('geo')) return 'bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20'
|
||||||
|
if (s.includes('art') || s.includes('music')) return 'bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20'
|
||||||
|
if (s.includes('sport') || s.includes('pe')) return 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20'
|
||||||
|
return 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/20'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
<div className="h-[600px] flex flex-col">
|
||||||
{WEEKDAYS.map((d) => {
|
<div className="flex h-full">
|
||||||
const items = byDay.get(d.key) ?? []
|
{/* Time Axis */}
|
||||||
return (
|
<div className="w-14 flex-shrink-0 flex flex-col">
|
||||||
<Card key={d.key} className="shadow-none">
|
<div className="h-10" /> {/* Header spacer */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<div className="flex-1 relative">
|
||||||
<div className="flex items-center gap-2">
|
{HOURS.map((h, i) => (
|
||||||
<CardTitle className="text-base">{d.label}</CardTitle>
|
<div
|
||||||
<Badge variant="secondary" className={cn(items.length === 0 && "opacity-60")}>
|
key={h}
|
||||||
{items.length} items
|
className="absolute w-full text-right pr-3 text-[11px] text-muted-foreground/60 font-medium -translate-y-1/2 font-mono"
|
||||||
</Badge>
|
style={{ top: `${(i / 10) * 100}%` }}
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={classes.length === 0}
|
|
||||||
onClick={() => {
|
|
||||||
setCreateWeekday(d.key)
|
|
||||||
setCreateOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
{h}:00
|
||||||
</Button>
|
</div>
|
||||||
</CardHeader>
|
))}
|
||||||
<CardContent>
|
</div>
|
||||||
{items.length === 0 ? (
|
</div>
|
||||||
<div className="text-muted-foreground text-sm">No classes scheduled.</div>
|
|
||||||
) : (
|
{/* Days Columns */}
|
||||||
<div className="space-y-4">
|
<div className="flex-1 grid grid-cols-5">
|
||||||
{items.map((item) => (
|
{WEEKDAYS.slice(0, 5).map((d) => (
|
||||||
<div key={item.id} className="space-y-1 border-b pb-4 last:border-0 last:pb-0">
|
<div key={d.key} className="flex flex-col h-full min-w-0">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-center justify-center py-2 h-10 group">
|
||||||
<div className="min-w-0 flex-1">
|
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{d.label}</span>
|
||||||
<div className="font-medium leading-none">{item.course}</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-full mx-1">
|
||||||
|
{/* Subtle vertical guideline */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-px bg-border/30" />
|
||||||
|
|
||||||
|
{(byDay.get(d.key) ?? []).map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="group absolute w-full px-1 z-10"
|
||||||
|
style={getPositionStyle(item.startTime, item.endTime)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"rounded-md p-2 text-xs text-left relative transition-all cursor-default leading-tight h-full border overflow-hidden shadow-sm hover:shadow-md flex flex-col justify-center",
|
||||||
|
getSubjectColor(item.course)
|
||||||
|
)}>
|
||||||
|
<div className="flex justify-between items-start gap-1">
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||||
|
<div className="font-bold truncate text-[11px] leading-none tracking-tight">{item.course}</div>
|
||||||
|
<div className="opacity-80 scale-95 origin-left whitespace-nowrap tabular-nums text-[10px] font-medium leading-none font-mono">
|
||||||
|
{item.startTime} - {item.endTime}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-70 scale-95 origin-left truncate text-[9px] leading-none mt-0.5 font-medium">
|
||||||
|
{classNameById.get(item.classId)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline">{classNameById.get(item.classId) ?? "Class"}</Badge>
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-32">
|
||||||
<DropdownMenuItem onClick={() => setEditItem(item)}>
|
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
|
||||||
<Pencil className="mr-2 size-4" />
|
<Pencil className="mr-2 h-3 w-3" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
onClick={() => setDeleteItem(item)}
|
onClick={() => setDeleteItem(item)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash2 className="mr-2 h-3 w-3" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground flex items-center gap-3 text-sm">
|
|
||||||
<span className="inline-flex items-center gap-1 tabular-nums">
|
|
||||||
<Clock className="h-3.5 w-3.5" />
|
|
||||||
{item.startTime}–{item.endTime}
|
|
||||||
</span>
|
|
||||||
{item.location ? (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<MapPin className="h-3.5 w-3.5" />
|
|
||||||
{item.location}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add Button Overlay - Only visible on hover of the column */}
|
||||||
|
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
||||||
|
<div className="absolute top-2 right-2 pointer-events-auto">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
|
||||||
|
disabled={classes.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setCreateWeekday(d.key)
|
||||||
|
setCreateOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
))}
|
||||||
)
|
</div>
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
@@ -311,7 +368,7 @@ export function ScheduleView({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(editItem)}
|
open={!!editItem}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(v) => {
|
||||||
if (isWorking) return
|
if (isWorking) return
|
||||||
if (!v) setEditItem(null)
|
if (!v) setEditItem(null)
|
||||||
@@ -320,116 +377,118 @@ export function ScheduleView({
|
|||||||
<DialogContent className="sm:max-w-[560px]">
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit schedule item</DialogTitle>
|
<DialogTitle>Edit schedule item</DialogTitle>
|
||||||
<DialogDescription>Update this schedule entry.</DialogDescription>
|
<DialogDescription>Update class schedule entry.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{editItem ? (
|
<form action={handleUpdate}>
|
||||||
<form action={handleUpdate}>
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<Label className="text-right">Class</Label>
|
||||||
<Label className="text-right">Class</Label>
|
<div className="col-span-3">
|
||||||
<div className="col-span-3">
|
<Select value={editClassId} onValueChange={setEditClassId}>
|
||||||
<Select value={editClassId} onValueChange={setEditClassId}>
|
<SelectTrigger>
|
||||||
<SelectTrigger>
|
<SelectValue placeholder="Select a class" />
|
||||||
<SelectValue placeholder="Select a class" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
{classes.map((c) => (
|
||||||
{classes.map((c) => (
|
<SelectItem key={c.id} value={c.id}>
|
||||||
<SelectItem key={c.id} value={c.id}>
|
{c.name}
|
||||||
{c.name}
|
</SelectItem>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<input type="hidden" name="classId" value={editClassId} />
|
||||||
<input type="hidden" name="classId" value={editClassId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Weekday</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select weekday" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">Mon</SelectItem>
|
|
||||||
<SelectItem value="2">Tue</SelectItem>
|
|
||||||
<SelectItem value="3">Wed</SelectItem>
|
|
||||||
<SelectItem value="4">Thu</SelectItem>
|
|
||||||
<SelectItem value="5">Fri</SelectItem>
|
|
||||||
<SelectItem value="6">Sat</SelectItem>
|
|
||||||
<SelectItem value="7">Sun</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="weekday" value={editWeekday} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-startTime" className="text-right">
|
|
||||||
Start
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-startTime"
|
|
||||||
name="startTime"
|
|
||||||
type="time"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem.startTime}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-endTime" className="text-right">
|
|
||||||
End
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-endTime"
|
|
||||||
name="endTime"
|
|
||||||
type="time"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem.endTime}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-course" className="text-right">
|
|
||||||
Course
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-course"
|
|
||||||
name="course"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem.course}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-location" className="text-right">
|
|
||||||
Location
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-location"
|
|
||||||
name="location"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem.location ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isWorking}>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
{isWorking ? "Saving..." : "Save"}
|
<Label htmlFor="edit-weekday" className="text-right">
|
||||||
</Button>
|
Weekday
|
||||||
</DialogFooter>
|
</Label>
|
||||||
</form>
|
<div className="col-span-3">
|
||||||
) : null}
|
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select weekday" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">Mon</SelectItem>
|
||||||
|
<SelectItem value="2">Tue</SelectItem>
|
||||||
|
<SelectItem value="3">Wed</SelectItem>
|
||||||
|
<SelectItem value="4">Thu</SelectItem>
|
||||||
|
<SelectItem value="5">Fri</SelectItem>
|
||||||
|
<SelectItem value="6">Sat</SelectItem>
|
||||||
|
<SelectItem value="7">Sun</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="weekday" value={editWeekday} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-startTime" className="text-right">
|
||||||
|
Start
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-startTime"
|
||||||
|
name="startTime"
|
||||||
|
type="time"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.startTime}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-endTime" className="text-right">
|
||||||
|
End
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-endTime"
|
||||||
|
name="endTime"
|
||||||
|
type="time"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.endTime}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-course" className="text-right">
|
||||||
|
Course
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-course"
|
||||||
|
name="course"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.course}
|
||||||
|
placeholder="e.g. Math"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-location" className="text-right">
|
||||||
|
Location
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-location"
|
||||||
|
name="location"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.location ?? ""}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isWorking || !editClassId}>
|
||||||
|
{isWorking ? "Saving..." : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={Boolean(deleteItem)}
|
open={!!deleteItem}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(v) => {
|
||||||
if (isWorking) return
|
if (isWorking) return
|
||||||
if (!v) setDeleteItem(null)
|
if (!v) setDeleteItem(null)
|
||||||
@@ -437,22 +496,20 @@ export function ScheduleView({
|
|||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete schedule item?</AlertDialogTitle>
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{deleteItem ? (
|
This will permanently delete this schedule item.
|
||||||
<>
|
|
||||||
This will permanently delete <span className="font-medium text-foreground">{deleteItem.course}</span>{" "}
|
|
||||||
({deleteItem.startTime}–{deleteItem.endTime}).
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
onClick={(e) => {
|
||||||
onClick={handleDelete}
|
e.preventDefault()
|
||||||
|
handleDelete()
|
||||||
|
}}
|
||||||
disabled={isWorking}
|
disabled={isWorking}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{isWorking ? "Deleting..." : "Delete"}
|
{isWorking ? "Deleting..." : "Delete"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
@@ -461,5 +518,4 @@ export function ScheduleView({
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,18 +3,19 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Search, UserPlus, X } from "lucide-react"
|
import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Select,
|
DropdownMenu,
|
||||||
SelectContent,
|
DropdownMenuContent,
|
||||||
SelectItem,
|
DropdownMenuItem,
|
||||||
SelectTrigger,
|
DropdownMenuTrigger,
|
||||||
SelectValue,
|
DropdownMenuSeparator,
|
||||||
} from "@/shared/components/ui/select"
|
DropdownMenuLabel,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -24,26 +25,35 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
import type { TeacherClass } from "../types"
|
import type { TeacherClass } from "../types"
|
||||||
import { enrollStudentByEmailAction } from "../actions"
|
import { enrollStudentByEmailAction } from "../actions"
|
||||||
|
|
||||||
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherClass[], defaultClassId?: string }) {
|
||||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("").withOptions({ shallow: false, throttleMs: 500 }))
|
||||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault(defaultClassId || "all").withOptions({ shallow: false }))
|
||||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
|
const effectiveClassId = classId === "all" && defaultClassId ? defaultClassId : classId
|
||||||
const [enrollClassId, setEnrollClassId] = useState(defaultClassId)
|
|
||||||
|
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setEnrollClassId(defaultClassId)
|
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||||
}, [open, defaultClassId])
|
}, [open, effectiveClassId, classes])
|
||||||
|
|
||||||
const handleEnroll = async (formData: FormData) => {
|
const handleEnroll = async (formData: FormData) => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1 md:max-w-sm">
|
{/* Search - Minimal */}
|
||||||
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
|
<div className="relative group">
|
||||||
|
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search students..."
|
placeholder="Search students..."
|
||||||
className="pl-8"
|
className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value || null)}
|
onChange={(e) => setSearch(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
<div className="h-4 w-[1px] bg-border mx-1" />
|
||||||
<SelectTrigger className="w-[180px]">
|
|
||||||
<SelectValue placeholder="Class" />
|
{/* Class Filter - Compact */}
|
||||||
</SelectTrigger>
|
<DropdownMenu>
|
||||||
<SelectContent>
|
<DropdownMenuTrigger asChild>
|
||||||
<SelectItem value="all">All Classes</SelectItem>
|
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||||
|
<span className="truncate max-w-[120px]">{classLabel}</span>
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-[200px]">
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Class</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setClassId("all")}
|
||||||
|
className="text-xs flex items-center justify-between"
|
||||||
|
>
|
||||||
|
All Classes
|
||||||
|
{classId === "all" && <Check className="h-3 w-3" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
<SelectItem key={c.id} value={c.id}>
|
<DropdownMenuItem
|
||||||
{c.name}
|
key={c.id}
|
||||||
</SelectItem>
|
onClick={() => setClassId(c.id)}
|
||||||
|
className="text-xs flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="truncate">{c.name}</span>
|
||||||
|
{classId === c.id && <Check className="h-3 w-3" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</DropdownMenuContent>
|
||||||
</Select>
|
</DropdownMenu>
|
||||||
|
|
||||||
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
{/* Status Filter - Compact */}
|
||||||
<SelectTrigger className="w-[140px]">
|
<DropdownMenu>
|
||||||
<SelectValue placeholder="Status" />
|
<DropdownMenuTrigger asChild>
|
||||||
</SelectTrigger>
|
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||||
<SelectContent>
|
{statusLabel}
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
<SelectItem value="active">Active</SelectItem>
|
</Button>
|
||||||
<SelectItem value="inactive">Inactive</SelectItem>
|
</DropdownMenuTrigger>
|
||||||
</SelectContent>
|
<DropdownMenuContent align="start">
|
||||||
</Select>
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Status</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
|
||||||
{(search || classId !== "all" || status !== "all") && (
|
All Status
|
||||||
<Button
|
{status === "all" && <Check className="h-3 w-3" />}
|
||||||
variant="ghost"
|
</DropdownMenuItem>
|
||||||
onClick={() => {
|
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
|
||||||
setSearch(null)
|
Active
|
||||||
setClassId(null)
|
{status === "active" && <Check className="h-3 w-3" />}
|
||||||
setStatus(null)
|
</DropdownMenuItem>
|
||||||
}}
|
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
|
||||||
className="h-8 px-2 lg:px-3"
|
Inactive
|
||||||
>
|
{status === "inactive" && <Check className="h-3 w-3" />}
|
||||||
Reset
|
</DropdownMenuItem>
|
||||||
<X className="ml-2 h-4 w-4" />
|
</DropdownMenuContent>
|
||||||
</Button>
|
</DropdownMenu>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -125,8 +161,8 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="gap-2" disabled={classes.length === 0}>
|
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
|
||||||
<UserPlus className="size-4" />
|
<UserPlus className="size-3.5" />
|
||||||
Add student
|
Add student
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
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 { toast } from "sonner"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
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 { cn, formatDate } from "@/shared/lib/utils"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -27,31 +27,16 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/shared/components/ui/table"
|
|
||||||
import type { ClassStudent } from "../types"
|
import type { ClassStudent } from "../types"
|
||||||
import { setStudentEnrollmentStatusAction } from "../actions"
|
import { setStudentEnrollmentStatusAction } from "../actions"
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 10
|
|
||||||
|
|
||||||
export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [workingKey, setWorkingKey] = useState<string | null>(null)
|
const [workingKey, setWorkingKey] = useState<string | null>(null)
|
||||||
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(students.length / ITEMS_PER_PAGE)
|
|
||||||
const startIndex = (page - 1) * ITEMS_PER_PAGE
|
|
||||||
const paginatedStudents = students.slice(startIndex, startIndex + ITEMS_PER_PAGE)
|
|
||||||
|
|
||||||
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
|
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
|
||||||
const key = `${student.classId}:${student.id}:${status}`
|
const key = `${student.classId}:${student.id}`
|
||||||
setWorkingKey(key)
|
setWorkingKey(key)
|
||||||
try {
|
try {
|
||||||
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
|
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
|
||||||
@@ -59,10 +44,10 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to update student")
|
toast.error(res.message)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update student")
|
toast.error("Failed to update status")
|
||||||
} finally {
|
} finally {
|
||||||
setWorkingKey(null)
|
setWorkingKey(null)
|
||||||
}
|
}
|
||||||
@@ -79,133 +64,112 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="shadow-none">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<CardHeader className="border-b px-6 py-4">
|
{students.map((s) => (
|
||||||
<div className="flex items-center justify-between">
|
<Card key={`${s.classId}:${s.id}`} className="overflow-hidden">
|
||||||
<CardTitle className="text-base font-semibold">All Students</CardTitle>
|
<CardHeader className="flex flex-row items-center gap-4 space-y-0 p-4 pb-2">
|
||||||
<Badge variant="secondary" className="rounded-sm px-1.5 font-normal">
|
<div className="relative">
|
||||||
{students.length} total
|
<Avatar className="h-10 w-10 border">
|
||||||
</Badge>
|
<AvatarImage src={s.image || undefined} alt={s.name} />
|
||||||
</div>
|
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
||||||
</CardHeader>
|
</Avatar>
|
||||||
<CardContent className="p-0">
|
<span className={cn(
|
||||||
<Table>
|
"absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
|
||||||
<TableHeader>
|
s.status === "active" ? "bg-emerald-500" : "bg-muted-foreground"
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
)} />
|
||||||
<TableHead className="pl-6 text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
|
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
|
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Joined</TableHead>
|
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
|
||||||
<TableHead className="pr-6 text-right text-xs font-medium uppercase text-muted-foreground">
|
|
||||||
Actions
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{paginatedStudents.map((s) => (
|
|
||||||
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-16", s.status !== "active" && "opacity-70")}>
|
|
||||||
<TableCell className="pl-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar className="h-9 w-9 border">
|
|
||||||
<AvatarImage src={s.image || undefined} alt={s.name} />
|
|
||||||
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="font-medium leading-none">{s.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{s.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="font-normal">
|
|
||||||
{s.className}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
|
||||||
{formatDate(s.joinedAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={s.status === "active" ? "secondary" : "outline"}
|
|
||||||
className={cn(
|
|
||||||
"font-medium",
|
|
||||||
s.status === "active"
|
|
||||||
? "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 hover:bg-emerald-500/20"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s.status === "active" ? "Active" : "Inactive"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="pr-6 text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}>
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{s.status !== "active" ? (
|
|
||||||
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
|
|
||||||
<UserCheck className="mr-2 size-4" />
|
|
||||||
Set active
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
|
|
||||||
<UserX className="mr-2 size-4" />
|
|
||||||
Set inactive
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setRemoveTarget(s)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
disabled={s.status === "inactive" || workingKey !== null}
|
|
||||||
>
|
|
||||||
<UserX className="mr-2 size-4" />
|
|
||||||
Remove from class
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<CardFooter className="flex items-center justify-between border-t px-6 py-4">
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Showing <strong>{startIndex + 1}</strong>-
|
|
||||||
<strong>{Math.min(startIndex + ITEMS_PER_PAGE, students.length)}</strong> of{" "}
|
|
||||||
<strong>{students.length}</strong> students
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{page} / {totalPages}
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
variant="outline"
|
<div className="flex items-start justify-between">
|
||||||
size="sm"
|
<div className="flex flex-col overflow-hidden mr-2">
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
<span className="truncate font-semibold text-sm">{s.name}</span>
|
||||||
disabled={page === totalPages}
|
<span className="truncate text-xs text-muted-foreground">{s.email}</span>
|
||||||
className="h-8 w-8 p-0"
|
</div>
|
||||||
>
|
<div className="flex flex-col items-end gap-0.5 text-xs text-muted-foreground shrink-0">
|
||||||
<ChevronRight className="h-4 w-4" />
|
<span className="text-[10px] font-medium text-foreground/80">
|
||||||
|
{s.className}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px]">
|
||||||
|
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "2-digit"
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
{s.subjectScores && Object.keys(s.subjectScores).length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Object.entries(s.subjectScores).slice(0, 4).map(([subject, score]) => (
|
||||||
|
<div key={subject} className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs border border-muted/50">
|
||||||
|
<span className="font-medium text-muted-foreground/80">{subject}</span>
|
||||||
|
{score !== null ? (
|
||||||
|
<span className={cn(
|
||||||
|
"font-bold",
|
||||||
|
score >= 90 ? "text-emerald-600" :
|
||||||
|
score >= 80 ? "text-primary" :
|
||||||
|
score >= 60 ? "text-yellow-600" : "text-destructive"
|
||||||
|
)}>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(s.subjectScores).length > 4 && (
|
||||||
|
<div className="flex items-center justify-center rounded-md bg-muted/50 px-2 py-1 text-xs text-muted-foreground font-medium border border-muted/50">
|
||||||
|
+{Object.keys(s.subjectScores).length - 4}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-[32px] rounded-md bg-muted/20 border border-dashed border-muted">
|
||||||
|
<span className="text-xs text-muted-foreground/50 italic">No recent scores</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex items-center justify-between border-t bg-muted/50 p-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" asChild>
|
||||||
|
<a href={`mailto:${s.email}`}>Email</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<DropdownMenu>
|
||||||
</CardFooter>
|
<DropdownMenuTrigger asChild>
|
||||||
)}
|
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={workingKey !== null}>
|
||||||
</Card>
|
<MoreHorizontal className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{s.status !== "active" ? (
|
||||||
|
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
|
||||||
|
<UserCheck className="mr-2 size-4" />
|
||||||
|
Set active
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
|
||||||
|
<UserX className="mr-2 size-4" />
|
||||||
|
Set inactive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setRemoveTarget(s)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
disabled={s.status === "inactive" || workingKey !== null}
|
||||||
|
>
|
||||||
|
<UserX className="mr-2 size-4" />
|
||||||
|
Remove from class
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={Boolean(removeTarget)}
|
open={Boolean(removeTarget)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import { randomInt } from "node:crypto"
|
import { randomInt } from "node:crypto"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
homeworkAssignments,
|
homeworkAssignments,
|
||||||
homeworkSubmissions,
|
homeworkSubmissions,
|
||||||
schools,
|
schools,
|
||||||
|
subjects,
|
||||||
|
exams,
|
||||||
users,
|
users,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||||
@@ -169,7 +171,35 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
list.sort(compareClassLike)
|
list.sort(compareClassLike)
|
||||||
return list
|
|
||||||
|
// Fetch recent assignments for trends and schedule
|
||||||
|
const listWithTrends = await Promise.all(
|
||||||
|
list.map(async (c) => {
|
||||||
|
const [insights, schedule] = await Promise.all([
|
||||||
|
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
|
||||||
|
getClassSchedule({ classId: c.id, teacherId }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const recentAssignments = insights
|
||||||
|
? insights.assignments.map((a) => ({
|
||||||
|
id: a.assignmentId,
|
||||||
|
title: a.title,
|
||||||
|
status: a.status,
|
||||||
|
subject: a.subject,
|
||||||
|
isActive: a.isActive,
|
||||||
|
isOverdue: a.isOverdue,
|
||||||
|
dueAt: a.dueAt ? new Date(a.dueAt) : null,
|
||||||
|
submittedCount: a.submittedCount,
|
||||||
|
targetCount: a.targetCount,
|
||||||
|
avgScore: a.scoreStats.avg,
|
||||||
|
medianScore: a.scoreStats.median,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
return { ...c, recentAssignments, schedule }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return listWithTrends
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
||||||
@@ -752,11 +782,22 @@ export const getClassHomeworkInsights = cache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||||
const assignments = await db.query.homeworkAssignments.findMany({
|
const assignments = await db
|
||||||
where: and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)),
|
.select({
|
||||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
id: homeworkAssignments.id,
|
||||||
limit,
|
title: homeworkAssignments.title,
|
||||||
})
|
status: homeworkAssignments.status,
|
||||||
|
createdAt: homeworkAssignments.createdAt,
|
||||||
|
dueAt: homeworkAssignments.dueAt,
|
||||||
|
subjectId: exams.subjectId,
|
||||||
|
subjectName: subjects.name
|
||||||
|
})
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||||
|
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||||
|
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
|
||||||
|
.orderBy(desc(homeworkAssignments.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
const usedAssignmentIds = assignments.map((a) => a.id)
|
const usedAssignmentIds = assignments.map((a) => a.id)
|
||||||
if (usedAssignmentIds.length === 0) {
|
if (usedAssignmentIds.length === 0) {
|
||||||
@@ -845,6 +886,7 @@ export const getClassHomeworkInsights = cache(
|
|||||||
assignmentId: a.id,
|
assignmentId: a.id,
|
||||||
title: a.title,
|
title: a.title,
|
||||||
status: (a.status as string) ?? "draft",
|
status: (a.status as string) ?? "draft",
|
||||||
|
subject: a.subjectName,
|
||||||
createdAt: a.createdAt.toISOString(),
|
createdAt: a.createdAt.toISOString(),
|
||||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||||
isActive: dueMs === null || dueMs >= nowMs,
|
isActive: dueMs === null || dueMs >= nowMs,
|
||||||
@@ -1694,3 +1736,104 @@ export async function deleteClassScheduleItem(scheduleId: string): Promise<void>
|
|||||||
|
|
||||||
await db.delete(classSchedule).where(eq(classSchedule.id, id))
|
await db.delete(classSchedule).where(eq(classSchedule.id, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getStudentsSubjectScores = cache(
|
||||||
|
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
|
||||||
|
if (studentIds.length === 0) return new Map()
|
||||||
|
|
||||||
|
// 1. Find assignments targeted at these students
|
||||||
|
const assignmentTargets = await db
|
||||||
|
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||||
|
|
||||||
|
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
|
||||||
|
if (assignmentIds.length === 0) return new Map()
|
||||||
|
|
||||||
|
// 2. Get assignment details including subject from linked exam
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
id: homeworkAssignments.id,
|
||||||
|
createdAt: homeworkAssignments.createdAt,
|
||||||
|
subjectId: exams.subjectId,
|
||||||
|
subjectName: subjects.name
|
||||||
|
})
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||||
|
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||||
|
.where(and(
|
||||||
|
inArray(homeworkAssignments.id, assignmentIds),
|
||||||
|
eq(homeworkAssignments.status, "published")
|
||||||
|
))
|
||||||
|
.orderBy(desc(homeworkAssignments.createdAt))
|
||||||
|
|
||||||
|
// 3. Filter subjects (exclude PE, Music, Art)
|
||||||
|
const excludeSubjects = ["体育", "音乐", "美术"]
|
||||||
|
const subjectAssignments = new Map<string, string>() // subject -> assignmentId (latest)
|
||||||
|
|
||||||
|
for (const a of assignments) {
|
||||||
|
if (!a.subjectName) continue
|
||||||
|
if (excludeSubjects.includes(a.subjectName)) continue
|
||||||
|
if (!subjectAssignments.has(a.subjectName)) {
|
||||||
|
subjectAssignments.set(a.subjectName, a.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAssignmentIds = Array.from(subjectAssignments.values())
|
||||||
|
if (targetAssignmentIds.length === 0) return new Map()
|
||||||
|
|
||||||
|
// 4. Get submissions for these assignments
|
||||||
|
const submissions = await db
|
||||||
|
.select({
|
||||||
|
studentId: homeworkSubmissions.studentId,
|
||||||
|
assignmentId: homeworkSubmissions.assignmentId,
|
||||||
|
score: homeworkSubmissions.score,
|
||||||
|
createdAt: homeworkSubmissions.createdAt,
|
||||||
|
})
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
|
||||||
|
.orderBy(desc(homeworkSubmissions.createdAt))
|
||||||
|
|
||||||
|
// 5. Map back to subject scores per student
|
||||||
|
const studentScores = new Map<string, Record<string, number | null>>()
|
||||||
|
|
||||||
|
// Create reverse map for assignment -> subject
|
||||||
|
const assignmentSubjectMap = new Map<string, string>()
|
||||||
|
for (const [subject, id] of subjectAssignments.entries()) {
|
||||||
|
assignmentSubjectMap.set(id, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of submissions) {
|
||||||
|
const subject = assignmentSubjectMap.get(s.assignmentId)
|
||||||
|
if (!subject) continue
|
||||||
|
|
||||||
|
if (!studentScores.has(s.studentId)) {
|
||||||
|
studentScores.set(s.studentId, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores = studentScores.get(s.studentId)!
|
||||||
|
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
|
||||||
|
if (scores[subject] === undefined) {
|
||||||
|
scores[subject] = s.score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return studentScores
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getClassStudentSubjectScoresV2 = cache(
|
||||||
|
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
|
||||||
|
// 1. Get student IDs in the class
|
||||||
|
const enrollments = await db
|
||||||
|
.select({ studentId: classEnrollments.studentId })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.where(and(
|
||||||
|
eq(classEnrollments.classId, classId),
|
||||||
|
eq(classEnrollments.status, "active")
|
||||||
|
))
|
||||||
|
|
||||||
|
const studentIds = enrollments.map(e => e.studentId)
|
||||||
|
return getStudentsSubjectScores(studentIds)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ export type TeacherClass = {
|
|||||||
room?: string | null
|
room?: string | null
|
||||||
invitationCode?: string | null
|
invitationCode?: string | null
|
||||||
studentCount: number
|
studentCount: number
|
||||||
|
recentAssignments?: AssignmentSummary[]
|
||||||
|
schedule?: ClassScheduleItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignmentSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
subject?: string | null
|
||||||
|
isActive: boolean
|
||||||
|
isOverdue: boolean
|
||||||
|
dueAt: Date | null
|
||||||
|
submittedCount: number
|
||||||
|
targetCount: number
|
||||||
|
avgScore: number | null
|
||||||
|
medianScore: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TeacherOption = {
|
export type TeacherOption = {
|
||||||
@@ -71,6 +87,7 @@ export type ClassStudent = {
|
|||||||
className: string
|
className: string
|
||||||
status: "active" | "inactive"
|
status: "active" | "inactive"
|
||||||
joinedAt: Date
|
joinedAt: Date
|
||||||
|
subjectScores?: Record<string, number | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassScheduleItem = {
|
export type ClassScheduleItem = {
|
||||||
@@ -135,6 +152,7 @@ export type ClassHomeworkAssignmentStats = {
|
|||||||
assignmentId: string
|
assignmentId: string
|
||||||
title: string
|
title: string
|
||||||
status: string
|
status: string
|
||||||
|
subject?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
dueAt: string | null
|
dueAt: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
@@ -149,6 +167,8 @@ export type ClassHomeworkAssignmentStats = {
|
|||||||
export type ClassHomeworkInsights = {
|
export type ClassHomeworkInsights = {
|
||||||
class: {
|
class: {
|
||||||
id: string
|
id: string
|
||||||
|
schoolName?: string | null
|
||||||
|
schoolId?: string | null
|
||||||
name: string
|
name: string
|
||||||
grade: string
|
grade: string
|
||||||
homeroom?: string | null
|
homeroom?: string | null
|
||||||
|
|||||||
@@ -550,6 +550,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
|
id="structure-editor-dnd"
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={customCollisionDetection}
|
collisionDetection={customCollisionDetection}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
|||||||
{ title: "My Classes", href: "/teacher/classes/my" },
|
{ title: "My Classes", href: "/teacher/classes/my" },
|
||||||
{ title: "Students", href: "/teacher/classes/students" },
|
{ title: "Students", href: "/teacher/classes/students" },
|
||||||
{ title: "Schedule", href: "/teacher/classes/schedule" },
|
{ title: "Schedule", href: "/teacher/classes/schedule" },
|
||||||
{ title: "Insights", href: "/teacher/classes/insights" },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ interface CreateQuestionDialogProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
initialData?: Question | null
|
initialData?: Question | null
|
||||||
|
defaultKnowledgePointIds?: string[]
|
||||||
|
defaultContent?: string
|
||||||
|
defaultType?: "single_choice" | "multiple_choice" | "text" | "judgment" | "composite"
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitialTextFromContent(content: unknown) {
|
function getInitialTextFromContent(content: unknown) {
|
||||||
@@ -97,7 +100,14 @@ function getInitialOptionsFromContent(content: unknown) {
|
|||||||
return mapped.length > 0 ? mapped : undefined
|
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 router = useRouter()
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
const isEdit = !!initialData
|
const isEdit = !!initialData
|
||||||
@@ -105,9 +115,9 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
|||||||
const form = useForm<QuestionFormValues>({
|
const form = useForm<QuestionFormValues>({
|
||||||
resolver: zodResolver(QuestionFormSchema),
|
resolver: zodResolver(QuestionFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
type: initialData?.type || "single_choice",
|
type: initialData?.type || defaultType,
|
||||||
difficulty: initialData?.difficulty || 1,
|
difficulty: initialData?.difficulty || 1,
|
||||||
content: getInitialTextFromContent(initialData?.content),
|
content: getInitialTextFromContent(initialData?.content) || defaultContent,
|
||||||
options:
|
options:
|
||||||
getInitialOptionsFromContent(initialData?.content) ?? [
|
getInitialOptionsFromContent(initialData?.content) ?? [
|
||||||
{ label: "Option A", value: "A", isCorrect: true },
|
{ label: "Option A", value: "A", isCorrect: true },
|
||||||
@@ -130,16 +140,16 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
type: "single_choice",
|
type: defaultType,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
content: "",
|
content: defaultContent,
|
||||||
options: [
|
options: [
|
||||||
{ label: "Option A", value: "A", isCorrect: true },
|
{ label: "Option A", value: "A", isCorrect: true },
|
||||||
{ label: "Option B", value: "B", isCorrect: false },
|
{ label: "Option B", value: "B", isCorrect: false },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [initialData, form, open])
|
}, [initialData, form, open, defaultContent, defaultType])
|
||||||
|
|
||||||
const questionType = form.watch("type")
|
const questionType = form.watch("type")
|
||||||
|
|
||||||
@@ -184,7 +194,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
|||||||
type: data.type,
|
type: data.type,
|
||||||
difficulty: data.difficulty,
|
difficulty: data.difficulty,
|
||||||
content: buildContent(data),
|
content: buildContent(data),
|
||||||
knowledgePointIds: [],
|
knowledgePointIds: isEdit ? [] : defaultKnowledgePointIds,
|
||||||
}
|
}
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.set("json", JSON.stringify(payload))
|
fd.set("json", JSON.stringify(payload))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
deleteChapter,
|
deleteChapter,
|
||||||
createKnowledgePoint,
|
createKnowledgePoint,
|
||||||
deleteKnowledgePoint,
|
deleteKnowledgePoint,
|
||||||
|
updateKnowledgePoint,
|
||||||
updateTextbook,
|
updateTextbook,
|
||||||
deleteTextbook,
|
deleteTextbook,
|
||||||
reorderChapters
|
reorderChapters
|
||||||
@@ -185,11 +186,12 @@ export async function createKnowledgePointAction(
|
|||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const description = formData.get("description") 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" };
|
if (!name) return { success: false, message: "Name is required" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createKnowledgePoint({ name, description, chapterId });
|
await createKnowledgePoint({ name, description, anchorText, chapterId });
|
||||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||||
return { success: true, message: "Knowledge point created successfully" };
|
return { success: true, message: "Knowledge point created successfully" };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -209,3 +211,24 @@ export async function deleteKnowledgePointAction(
|
|||||||
return { success: false, message: "Failed to delete knowledge point" };
|
return { success: false, message: "Failed to delete knowledge point" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateKnowledgePointAction(
|
||||||
|
kpId: string,
|
||||||
|
textbookId: string,
|
||||||
|
prevState: ActionState | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState> {
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-3xl h-[80vh] flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{chapter.title}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Reading Mode
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<ScrollArea className="flex-1 pr-4 min-h-0">
|
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
|
||||||
{chapter.content ? (
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
|
|
||||||
{chapter.content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-40 items-center justify-center text-muted-foreground italic">
|
|
||||||
No content available for this chapter.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className={cn(level > 0 && "ml-2 border-l pl-2")}>
|
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<div className="flex items-center group py-1">
|
|
||||||
{hasChildren ? (
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 transition-transform duration-200 text-muted-foreground",
|
|
||||||
isOpen && "rotate-90"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="sr-only">Toggle</span>
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
) : (
|
|
||||||
<div className="w-6 shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={cn(
|
|
||||||
"flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 cursor-pointer transition-colors",
|
|
||||||
level === 0 ? "font-medium text-foreground" : "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
onClick={() => !hasChildren && onView(chapter)}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
<Folder className={cn("h-4 w-4", isOpen ? "text-primary" : "text-muted-foreground/70")} />
|
|
||||||
) : (
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{chapter.title}</span>
|
|
||||||
|
|
||||||
{showActions ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="ml-auto h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity focus:opacity-100"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => onView(chapter)}>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
View Content
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasChildren && (
|
|
||||||
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
|
||||||
<div className="pt-1">
|
|
||||||
{chapter.children!.map((child) => (
|
|
||||||
<ChapterItem
|
|
||||||
key={child.id}
|
|
||||||
chapter={child}
|
|
||||||
level={level + 1}
|
|
||||||
onView={onView}
|
|
||||||
showActions={showActions}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
)}
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChapterList({ chapters, showActions }: { chapters: Chapter[]; showActions?: boolean }) {
|
|
||||||
const [viewingChapter, setViewingChapter] = useState<Chapter | null>(null)
|
|
||||||
const [isViewerOpen, setIsViewerOpen] = useState(false)
|
|
||||||
|
|
||||||
const handleView = (chapter: Chapter) => {
|
|
||||||
setViewingChapter(chapter)
|
|
||||||
setIsViewerOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{chapters.map((chapter) => (
|
|
||||||
<ChapterItem key={chapter.id} chapter={chapter} onView={handleView} showActions={showActions} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChapterContentViewer
|
|
||||||
chapter={viewingChapter}
|
|
||||||
open={isViewerOpen}
|
|
||||||
onOpenChange={setIsViewerOpen}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -35,9 +35,10 @@ interface SortableChapterItemProps {
|
|||||||
textbookId: string
|
textbookId: string
|
||||||
onDelete: (chapter: Chapter) => void
|
onDelete: (chapter: Chapter) => void
|
||||||
onCreateSub: (parentId: string) => 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 [isOpen, setIsOpen] = useState(level === 0)
|
||||||
const hasChildren = chapter.children && chapter.children.length > 0
|
const hasChildren = chapter.children && chapter.children.length > 0
|
||||||
const isSelected = chapter.id === selectedId
|
const isSelected = chapter.id === selectedId
|
||||||
@@ -49,7 +50,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
|||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({ id: chapter.id })
|
} = useSortable({ id: chapter.id, disabled: !canEdit })
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
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",
|
isSelected ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted/50 text-muted-foreground hover:text-foreground",
|
||||||
isDragging && "opacity-50"
|
isDragging && "opacity-50"
|
||||||
)}>
|
)}>
|
||||||
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded">
|
{canEdit && (
|
||||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
|
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded">
|
||||||
</div>
|
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
@@ -103,7 +106,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
|||||||
<span className="truncate text-sm">{chapter.title}</span>
|
<span className="truncate text-sm">{chapter.title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity ml-2">
|
<div className={cn("flex items-center opacity-0 group-hover:opacity-100 transition-opacity ml-2", !canEdit && "hidden")}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -142,6 +145,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
|||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onCreateSub={onCreateSub}
|
onCreateSub={onCreateSub}
|
||||||
|
canEdit={canEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -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[],
|
items: Chapter[],
|
||||||
level: number,
|
level: number,
|
||||||
selectedId?: string,
|
selectedId?: string,
|
||||||
onSelect: (c: Chapter) => void,
|
onSelect: (c: Chapter) => void,
|
||||||
textbookId: string,
|
textbookId: string,
|
||||||
onDelete: (c: Chapter) => void,
|
onDelete: (c: Chapter) => void,
|
||||||
onCreateSub: (pid: string) => void
|
onCreateSub: (pid: string) => void,
|
||||||
|
canEdit?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
|
||||||
@@ -172,6 +177,7 @@ function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId,
|
|||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onCreateSub={onCreateSub}
|
onCreateSub={onCreateSub}
|
||||||
|
canEdit={canEdit}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
@@ -183,9 +189,10 @@ interface ChapterSidebarListProps {
|
|||||||
selectedChapterId?: string
|
selectedChapterId?: string
|
||||||
onSelectChapter: (chapter: Chapter) => void
|
onSelectChapter: (chapter: Chapter) => void
|
||||||
textbookId: string
|
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 [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||||
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
|
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
@@ -300,8 +307,9 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
|||||||
setShowCreateDialog(true)
|
setShowCreateDialog(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// If not editable, we can skip dnd logic
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
if (!canEdit) {
|
||||||
|
return (
|
||||||
<RecursiveSortableList
|
<RecursiveSortableList
|
||||||
items={chapters}
|
items={chapters}
|
||||||
level={0}
|
level={0}
|
||||||
@@ -310,6 +318,22 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
|||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
onDelete={handleDeleteRequest}
|
onDelete={handleDeleteRequest}
|
||||||
onCreateSub={handleCreateSubRequest}
|
onCreateSub={handleCreateSubRequest}
|
||||||
|
canEdit={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext id="chapter-sidebar-dnd" sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<RecursiveSortableList
|
||||||
|
items={chapters}
|
||||||
|
level={0}
|
||||||
|
selectedId={selectedChapterId}
|
||||||
|
onSelect={onSelectChapter}
|
||||||
|
textbookId={textbookId}
|
||||||
|
onDelete={handleDeleteRequest}
|
||||||
|
onCreateSub={handleCreateSubRequest}
|
||||||
|
canEdit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateChapterDialog
|
<CreateChapterDialog
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import ReactMarkdown from "react-markdown"
|
|
||||||
import remarkBreaks from "remark-breaks"
|
|
||||||
import remarkGfm from "remark-gfm"
|
|
||||||
import { Chapter, KnowledgePoint } from "../types"
|
|
||||||
import { ChapterSidebarList } from "./chapter-sidebar-list"
|
|
||||||
import { KnowledgePointPanel } from "./knowledge-point-panel"
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Edit2, Save, Folder } from "lucide-react"
|
|
||||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
|
||||||
import { updateChapterContentAction } from "../actions"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
|
||||||
|
|
||||||
interface TextbookContentLayoutProps {
|
|
||||||
chapters: Chapter[]
|
|
||||||
knowledgePoints: KnowledgePoint[]
|
|
||||||
textbookId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }: TextbookContentLayoutProps) {
|
|
||||||
const [selectedChapter, setSelectedChapter] = useState<Chapter | null>(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 (
|
|
||||||
<div className="grid grid-cols-12 h-[calc(100vh-8rem)]">
|
|
||||||
{/* Left Sidebar: TOC (3 cols) */}
|
|
||||||
<div className="col-span-3 border-r flex flex-col h-full bg-muted/10">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b">
|
|
||||||
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground">Contents</h3>
|
|
||||||
<CreateChapterDialog textbookId={textbookId} />
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="p-3">
|
|
||||||
<ChapterSidebarList
|
|
||||||
chapters={chapters}
|
|
||||||
selectedChapterId={selectedChapter?.id}
|
|
||||||
onSelectChapter={handleSelectChapter}
|
|
||||||
textbookId={textbookId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Middle: Content Viewer/Editor (6 cols) */}
|
|
||||||
<div className="col-span-6 flex flex-col h-full min-h-0 bg-background">
|
|
||||||
{selectedChapter ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between px-8 py-4 border-b sticky top-0 bg-background/95 backdrop-blur z-10">
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{selectedChapter.title}</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
{isSaving ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
|
||||||
<Edit2 className="mr-2 h-4 w-4" />
|
|
||||||
Edit Content
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="max-w-3xl mx-auto px-8 py-8 min-h-full">
|
|
||||||
{isEditing ? (
|
|
||||||
<RichTextEditor
|
|
||||||
value={editContent}
|
|
||||||
onChange={setEditContent}
|
|
||||||
className="min-h-[500px] border-none shadow-none"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="prose prose-zinc dark:prose-invert max-w-none prose-headings:font-bold prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl prose-p:leading-relaxed">
|
|
||||||
{selectedChapter.content ? (
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
|
|
||||||
{selectedChapter.content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground space-y-4">
|
|
||||||
<div className="p-4 rounded-full bg-muted">
|
|
||||||
<Edit2 className="h-8 w-8 opacity-50" />
|
|
||||||
</div>
|
|
||||||
<p className="italic">No content available yet.</p>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
|
||||||
Start Writing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-4">
|
|
||||||
<div className="p-6 rounded-full bg-muted/30">
|
|
||||||
<Folder className="h-12 w-12 opacity-20" />
|
|
||||||
</div>
|
|
||||||
<p>Select a chapter to view or edit content</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Sidebar: Knowledge Points (3 cols) */}
|
|
||||||
<div className="col-span-3 border-l flex flex-col h-full bg-muted/10">
|
|
||||||
<KnowledgePointPanel
|
|
||||||
knowledgePoints={knowledgePoints}
|
|
||||||
selectedChapterId={selectedChapter?.id || null}
|
|
||||||
textbookId={textbookId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,47 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState, useEffect, useRef } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
import remarkBreaks from "remark-breaks"
|
import remarkBreaks from "remark-breaks"
|
||||||
import remarkGfm from "remark-gfm"
|
import remarkGfm from "remark-gfm"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
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 { cn } from "@/shared/lib/utils"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { 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[]) {
|
function buildChapterIndex(chapters: Chapter[]) {
|
||||||
const index = new Map<string, Chapter>()
|
const index = new Map<string, Chapter>()
|
||||||
@@ -26,131 +57,543 @@ function buildChapterIndex(chapters: Chapter[]) {
|
|||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReaderChapterItem({
|
export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false, textbookId }: { chapters: Chapter[]; knowledgePoints?: KnowledgePoint[]; canEdit?: boolean; textbookId?: string }) {
|
||||||
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 (
|
|
||||||
<div className={cn(level > 0 && "ml-2 border-l pl-2")}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center group py-1 rounded-md transition-colors",
|
|
||||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
<ChevronRight className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-90")} />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="w-6 shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 text-sm text-left cursor-pointer",
|
|
||||||
level === 0 ? "font-medium" : "text-muted-foreground",
|
|
||||||
isSelected && "text-accent-foreground font-medium"
|
|
||||||
)}
|
|
||||||
onClick={() => onSelect(chapter.id)}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
<Folder className={cn("h-4 w-4", open ? "text-primary" : "text-muted-foreground/70")} />
|
|
||||||
) : (
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
<span className="truncate flex-1 min-w-0">{chapter.title}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasChildren && open ? (
|
|
||||||
<div className="pt-1">
|
|
||||||
{chapter.children!.map((child) => (
|
|
||||||
<ReaderChapterItem
|
|
||||||
key={child.id}
|
|
||||||
chapter={child}
|
|
||||||
level={level + 1}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onSelect={onSelect}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextbookReader({ chapters }: { chapters: Chapter[] }) {
|
|
||||||
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
|
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
|
||||||
|
const [activeTab, setActiveTab] = useState("chapters")
|
||||||
|
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(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<HTMLDivElement>(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<KnowledgePoint | null>(null)
|
||||||
|
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
|
||||||
|
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
|
||||||
|
|
||||||
|
// Question Creation State
|
||||||
|
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
|
||||||
|
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
|
||||||
|
|
||||||
const index = useMemo(() => buildChapterIndex(chapters), [chapters])
|
const index = useMemo(() => buildChapterIndex(chapters), [chapters])
|
||||||
const selected = chapterId ? index.get(chapterId) ?? null : null
|
const selected = chapterId ? index.get(chapterId) ?? null : null
|
||||||
const selectedId = selected?.id ?? 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 (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
|
||||||
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
|
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
|
||||||
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||||
<h3 className="font-semibold">Chapters</h3>
|
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
|
||||||
</div>
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<ScrollArea className="flex-1 min-h-0 px-2">
|
<TabsTrigger value="chapters" className="gap-2">
|
||||||
<div className="space-y-1">
|
<List className="h-4 w-4" />
|
||||||
{chapters.map((chapter) => (
|
章节目录
|
||||||
<ReaderChapterItem
|
</TabsTrigger>
|
||||||
key={chapter.id}
|
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
|
||||||
chapter={chapter}
|
<Tag className="h-4 w-4" />
|
||||||
selectedId={selectedId}
|
知识点
|
||||||
onSelect={handleSelect}
|
{currentChapterKPs.length > 0 && (
|
||||||
/>
|
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
|
||||||
))}
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
|
||||||
|
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
|
||||||
|
<ScrollArea className="flex-1 h-full px-2">
|
||||||
|
<div className="space-y-1 pb-4">
|
||||||
|
<ChapterSidebarList
|
||||||
|
chapters={chapters}
|
||||||
|
selectedChapterId={selectedId || undefined}
|
||||||
|
onSelectChapter={handleSelect}
|
||||||
|
textbookId={textbookId || ""}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||||
|
请选择一个章节查看知识点。
|
||||||
|
</div>
|
||||||
|
) : currentChapterKPs.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||||
|
该章节暂无知识点。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="flex-1 h-full px-2">
|
||||||
|
<div className="space-y-2 pb-4">
|
||||||
|
{currentChapterKPs.map((kp) => (
|
||||||
|
<div
|
||||||
|
key={kp.id}
|
||||||
|
className={cn(
|
||||||
|
"p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
|
||||||
|
highlightedKpId === kp.id && "border-primary bg-primary/5"
|
||||||
|
)}
|
||||||
|
onClick={() => setHighlightedKpId(kp.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setTargetKpForQuestion(kp)
|
||||||
|
setQuestionDialogOpen(true)
|
||||||
|
}}
|
||||||
|
title="创建相关题目"
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingKp(kp)
|
||||||
|
setEditKpDialogOpen(true)
|
||||||
|
}}
|
||||||
|
title="编辑知识点"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={(e) => handleDeleteKnowledgePoint(kp.id, e)}
|
||||||
|
title="删除知识点"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{kp.description && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{kp.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-8 flex flex-col min-h-0">
|
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>添加知识点</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
从选中的文本创建知识点。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleCreateKnowledgePoint}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">名称</Label>
|
||||||
|
<Input id="name" name="name" defaultValue={selectedText} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="description">描述(可选)</Label>
|
||||||
|
<Textarea id="description" name="description" placeholder="请输入描述..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isCreating}>
|
||||||
|
{isCreating ? "创建中..." : "创建"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑知识点</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
修改知识点的名称和描述。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleUpdateKnowledgePoint}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="edit-name">显示名称</Label>
|
||||||
|
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="edit-description">描述(可选)</Label>
|
||||||
|
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
|
||||||
|
高级:关联文本 (影响文中高亮)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2">
|
||||||
|
<Input
|
||||||
|
key={editingKp?.id} // Force re-render when kp changes
|
||||||
|
id="edit-anchorText"
|
||||||
|
name="anchorText"
|
||||||
|
defaultValue={editingKp?.anchorText || editingKp?.name}
|
||||||
|
className="text-sm font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
修改此字段会改变文中被高亮匹配的文字。通常保持与原文一致。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isUpdatingKp}>
|
||||||
|
{isUpdatingKp ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<CreateQuestionDialog
|
||||||
|
open={questionDialogOpen}
|
||||||
|
onOpenChange={setQuestionDialogOpen}
|
||||||
|
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
|
||||||
|
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
|
||||||
|
defaultType="text"
|
||||||
|
/>
|
||||||
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
|
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
|
||||||
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
|
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{isSaving ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={startEditing}>
|
||||||
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
|
编辑内容
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1 min-h-0 px-2">
|
<ScrollArea className="flex-1 min-h-0 px-2">
|
||||||
<div className="p-4 min-h-full">
|
{isEditing ? (
|
||||||
{selected.content ? (
|
<div className="h-full">
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
<RichTextEditor
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>{selected.content}</ReactMarkdown>
|
value={editContent}
|
||||||
|
onChange={setEditContent}
|
||||||
|
className="min-h-[500px] border-none shadow-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground italic py-8 text-center">No content available.</div>
|
<ContextMenu onOpenChange={handleContextMenuChange}>
|
||||||
)}
|
<ContextMenuTrigger asChild>
|
||||||
</div>
|
<div
|
||||||
|
className="p-4 min-h-full"
|
||||||
|
ref={contentRef}
|
||||||
|
onPointerDown={handleContentPointerDown}
|
||||||
|
>
|
||||||
|
{selected.content ? (
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||||
|
components={{
|
||||||
|
a: ({ href, children, ...props }) => {
|
||||||
|
if (href?.startsWith("#kp-")) {
|
||||||
|
const id = href.replace("#kp-", "")
|
||||||
|
const isHighlighted = highlightedKpId === id
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-kp-id={id}
|
||||||
|
className={cn(
|
||||||
|
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
|
||||||
|
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlightedKpId(id)
|
||||||
|
setActiveTab("knowledge")
|
||||||
|
}}
|
||||||
|
title="点击查看知识点详情"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <a href={href} {...props}>{children}</a>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processedContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground italic py-8 text-center">暂无内容</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
disabled={!selectedText}
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
添加知识点
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
|
||||||
Select a chapter to start reading.
|
请选择一个章节开始阅读。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,29 +366,28 @@ export const getKnowledgePointsByTextbookId = cache(async (textbookId: string):
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<KnowledgePoint> {
|
export async function createKnowledgePoint(data: { name: string; description?: string; anchorText?: string; chapterId?: string; parentId?: string }): Promise<void> {
|
||||||
const id = createId()
|
await db.insert(knowledgePoints).values({
|
||||||
|
id: createId(),
|
||||||
const row = {
|
name: data.name,
|
||||||
id,
|
description: data.description,
|
||||||
name: data.name.trim(),
|
anchorText: data.anchorText,
|
||||||
description: normalizeOptional(data.description ?? null),
|
|
||||||
chapterId: data.chapterId,
|
chapterId: data.chapterId,
|
||||||
level: 1,
|
parentId: data.parentId,
|
||||||
order: 0,
|
level: 0, // Default level
|
||||||
}
|
order: 0, // Default order
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(knowledgePoints).values(row)
|
export async function updateKnowledgePoint(data: { id: string; name: string; description?: string; anchorText?: string }): Promise<void> {
|
||||||
|
await db
|
||||||
return {
|
.update(knowledgePoints)
|
||||||
id: row.id,
|
.set({
|
||||||
name: row.name,
|
name: data.name,
|
||||||
description: row.description,
|
description: data.description,
|
||||||
parentId: null,
|
anchorText: data.anchorText,
|
||||||
chapterId: row.chapterId,
|
})
|
||||||
level: row.level,
|
.where(eq(knowledgePoints.id, data.id))
|
||||||
order: row.order,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteKnowledgePoint(id: string): Promise<void> {
|
export async function deleteKnowledgePoint(id: string): Promise<void> {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export type KnowledgePoint = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
anchorText?: string | null;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
chapterId?: string; // Logic link for this module context
|
chapterId?: string; // Logic link for this module context
|
||||||
level: number;
|
level: number;
|
||||||
@@ -69,5 +70,14 @@ export type UpdateChapterContentInput = {
|
|||||||
export type CreateKnowledgePointInput = {
|
export type CreateKnowledgePointInput = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
chapterId: string;
|
anchorText?: string;
|
||||||
|
parentId?: string;
|
||||||
|
chapterId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateKnowledgePointInput = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
anchorText?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
200
src/shared/components/ui/context-menu.tsx
Normal file
200
src/shared/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const ContextMenu = ContextMenuPrimitive.Root
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||||
|
|
||||||
|
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
ContextMenuCheckboxItem.displayName =
|
||||||
|
ContextMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
29
src/shared/components/ui/hover-card.tsx
Normal file
29
src/shared/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const HoverCard = HoverCardPrimitive.Root
|
||||||
|
|
||||||
|
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||||
|
|
||||||
|
const HoverCardContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
@@ -118,6 +118,7 @@ export const knowledgePoints = mysqlTable("knowledge_points", {
|
|||||||
id: id("id").primaryKey(),
|
id: id("id").primaryKey(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
anchorText: varchar("anchor_text", { length: 255 }),
|
||||||
|
|
||||||
// Tree Structure: Parent KP
|
// Tree Structure: Parent KP
|
||||||
parentId: varchar("parent_id", { length: 128 }), // Self-reference defined in relations
|
parentId: varchar("parent_id", { length: 128 }), // Self-reference defined in relations
|
||||||
|
|||||||
Reference in New Issue
Block a user