Compare commits
3 Commits
e7c902e8e1
...
13e91e628d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13e91e628d | ||
|
|
f8e39f518d | ||
|
|
f7ff018490 |
@@ -1,12 +1,12 @@
|
||||
# Database Schema Changelog
|
||||
|
||||
## v1.1.0 - Exam Structure & Performance Optimization
|
||||
## v1.1.0 - Exam Structure Support
|
||||
**Date:** 2025-12-29
|
||||
**Migration ID:** `0001_flawless_texas_twister`
|
||||
**Author:** Principal Database Architect
|
||||
|
||||
### 1. Summary
|
||||
This release introduces support for hierarchical exam structures (Sectioning/Grouping) and optimizes database constraint naming for better compatibility with MySQL environments.
|
||||
This release introduces support for hierarchical exam structures (Sectioning/Grouping).
|
||||
|
||||
### 2. Changes
|
||||
|
||||
@@ -23,17 +23,55 @@ This release introduces support for hierarchical exam structures (Sectioning/Gro
|
||||
>
|
||||
```
|
||||
|
||||
#### 2.2 Table: `questions_to_knowledge_points`
|
||||
* **Action**: `RENAME FOREIGN KEY`
|
||||
### 3. Migration Strategy
|
||||
* **Up**: Run standard Drizzle migration.
|
||||
* **Down**: Revert `structure` column. Note that FK names can be kept short as they are implementation details.
|
||||
|
||||
### 4. Impact Analysis
|
||||
* **Performance**: Negligible. JSON parsing is done client-side or at application layer.
|
||||
* **Data Integrity**: High. Existing data is unaffected. New `structure` field defaults to `NULL`.
|
||||
|
||||
## v1.2.0 - Homework Module Tables & FK Name Hardening
|
||||
**Date:** 2025-12-31
|
||||
**Migration ID:** `0002_equal_wolfpack`
|
||||
**Author:** Principal Database Architect
|
||||
|
||||
### 1. Summary
|
||||
This release introduces homework-related tables and hardens foreign key names to avoid exceeding MySQL identifier length limits (MySQL 64-char constraint names).
|
||||
|
||||
### 2. Changes
|
||||
|
||||
#### 2.1 Tables: Homework Domain
|
||||
* **Action**: `CREATE TABLE`
|
||||
* **Tables**:
|
||||
* `homework_assignments`
|
||||
* `homework_assignment_questions`
|
||||
* `homework_assignment_targets`
|
||||
* `homework_submissions`
|
||||
* `homework_answers`
|
||||
* **Reason**: Support assignment lifecycle, targeting, submissions, and per-question grading.
|
||||
|
||||
#### 2.2 Foreign Keys: Homework Domain (Name Hardening)
|
||||
* **Action**: `ADD FOREIGN KEY` (with short constraint names)
|
||||
* **Details**:
|
||||
* `homework_assignments`: `hw_asg_exam_fk`, `hw_asg_creator_fk`
|
||||
* `homework_assignment_questions`: `hw_aq_a_fk`, `hw_aq_q_fk`
|
||||
* `homework_assignment_targets`: `hw_at_a_fk`, `hw_at_s_fk`
|
||||
* `homework_submissions`: `hw_sub_a_fk`, `hw_sub_student_fk`
|
||||
* `homework_answers`: `hw_ans_sub_fk`, `hw_ans_q_fk`
|
||||
* **Reason**: Default generated FK names can exceed 64 characters in MySQL and fail during migration.
|
||||
|
||||
#### 2.3 Table: `questions_to_knowledge_points`
|
||||
* **Action**: `RENAME FOREIGN KEY` (implemented as drop + add)
|
||||
* **Details**:
|
||||
* Old: `questions_to_knowledge_points_question_id_questions_id_fk` -> New: `q_kp_qid_fk`
|
||||
* Old: `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` -> New: `q_kp_kpid_fk`
|
||||
* **Reason**: Previous names exceeded MySQL's 64-character identifier limit, causing potential migration failures in production environments.
|
||||
|
||||
### 3. Migration Strategy
|
||||
* **Up**: Run standard Drizzle migration. The script includes `ALTER TABLE ... DROP FOREIGN KEY` followed by `ADD CONSTRAINT`.
|
||||
* **Down**: Revert `structure` column. Note that FK names can be kept short as they are implementation details.
|
||||
* **Up**: Run standard Drizzle migration. The migration is resilient whether the legacy FK names exist or have already been renamed.
|
||||
* **Down**: Not provided. Removing homework tables and FKs is destructive and should be handled explicitly per environment.
|
||||
|
||||
### 4. Impact Analysis
|
||||
* **Performance**: Negligible. JSON parsing is done client-side or at application layer.
|
||||
* **Data Integrity**: High. Existing data is unaffected. New `structure` field defaults to `NULL`.
|
||||
* **Performance**: Minimal. New indexes are scoped to common homework access patterns.
|
||||
* **Data Integrity**: High. Foreign keys enforce referential integrity for homework workflow.
|
||||
|
||||
@@ -1,87 +1,132 @@
|
||||
# Question Bank Module Implementation
|
||||
# 题库模块实现
|
||||
|
||||
## 1. Overview
|
||||
The Question Bank module (`src/modules/questions`) is a core component for teachers to manage their examination resources. It implements a comprehensive CRUD interface with advanced filtering and batch operations.
|
||||
## 1. 概述
|
||||
题库模块(`src/modules/questions`)是教师管理考试资源的核心组件,提供完整的 CRUD 能力,并支持搜索/筛选等常用管理能力。
|
||||
|
||||
**Status**: IMPLEMENTED
|
||||
**Date**: 2025-12-23
|
||||
**Author**: Senior Frontend Engineer
|
||||
**状态**:已实现
|
||||
**日期**:2025-12-23
|
||||
**作者**:前端高级工程师
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture & Tech Stack
|
||||
## 2. 架构与技术栈
|
||||
|
||||
### 2.1 Vertical Slice Architecture
|
||||
Following the project's architectural guidelines, all question-related logic is encapsulated within `src/modules/questions`:
|
||||
- `components/`: UI components (Data Table, Dialogs, Filters)
|
||||
- `actions.ts`: Server Actions for data mutation
|
||||
- `data-access.ts`: Database query logic
|
||||
- `schema.ts`: Zod schemas for validation
|
||||
- `types.ts`: TypeScript interfaces
|
||||
### 2.1 垂直切片(Vertical Slice)架构
|
||||
遵循项目的架构规范,所有与题库相关的逻辑都收敛在 `src/modules/questions` 下:
|
||||
- `components/`:UI 组件(表格、弹窗、筛选器)
|
||||
- `actions.ts`:Server Actions(数据变更)
|
||||
- `data-access.ts`:数据库查询逻辑
|
||||
- `schema.ts`:Zod 校验 Schema
|
||||
- `types.ts`:TypeScript 类型定义
|
||||
|
||||
### 2.2 Key Technologies
|
||||
- **Data Grid**: `@tanstack/react-table` for high-performance rendering.
|
||||
- **State Management**: `nuqs` for URL-based state (filters, search).
|
||||
- **Forms**: `react-hook-form` + `zod` + `shadcn/ui` form components.
|
||||
- **Validation**: Strict server-side and client-side validation using Zod schemas.
|
||||
### 2.2 关键技术
|
||||
- **数据表格**:`@tanstack/react-table`(高性能表格渲染)
|
||||
- **状态管理**:`nuqs`(基于 URL Query 的筛选/搜索状态)
|
||||
- **表单**:`react-hook-form` + `zod` + `shadcn/ui` 表单组件
|
||||
- **校验**:Zod 提供严格的服务端/客户端校验
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Design
|
||||
## 3. 组件设计
|
||||
|
||||
### 3.1 QuestionDataTable (`question-data-table.tsx`)
|
||||
- **Features**: Pagination, Sorting, Row Selection.
|
||||
- **Performance**: Uses `React.memo` compatible patterns where possible (though `useReactTable` itself is not memoized).
|
||||
- **Responsiveness**: Mobile-first design with horizontal scroll for complex columns.
|
||||
### 3.1 QuestionDataTable(`question-data-table.tsx`)
|
||||
- **能力**:分页、排序、行选择
|
||||
- **性能**:尽可能采用与 `React.memo` 兼容的写法(`useReactTable` 本身不做 memo)
|
||||
- **响应式**:移动端优先;复杂列支持横向滚动
|
||||
|
||||
### 3.2 QuestionColumns (`question-columns.tsx`)
|
||||
Custom cell renderers for rich data display:
|
||||
- **Type Badge**: Color-coded badges for different question types (Single Choice, Multiple Choice, etc.).
|
||||
- **Difficulty**: Visual indicator with color (Green -> Red) and numerical value.
|
||||
- **Actions**: Dropdown menu for Edit, Delete, View Details, and Copy ID.
|
||||
### 3.2 QuestionColumns(`question-columns.tsx`)
|
||||
用于增强单元格展示的自定义渲染:
|
||||
- **题型 Badge**:不同题型的颜色/样式区分(单选、多选等)
|
||||
- **难度展示**:难度标签 + 数值
|
||||
- **行操作**:下拉菜单(编辑、删除、查看详情、复制 ID)
|
||||
|
||||
### 3.3 Create/Edit Dialog (`create-question-dialog.tsx`)
|
||||
A unified dialog component for both creating and editing questions.
|
||||
- **Dynamic Fields**: Shows/hides "Options" field based on question type.
|
||||
- **Interactive Options**: Allows adding/removing/reordering options for choice questions.
|
||||
- **Optimistic UI**: Shows loading states during submission.
|
||||
### 3.3 创建/编辑弹窗(`create-question-dialog.tsx`)
|
||||
创建与编辑共用同一个弹窗组件:
|
||||
- **动态字段**:根据题型显示/隐藏“选项”区域
|
||||
- **选项编辑**:支持添加/删除选项(选择题)
|
||||
- **交互反馈**:提交中 Loading 状态
|
||||
|
||||
### 3.4 Filters (`question-filters.tsx`)
|
||||
- **URL Sync**: All filter states (Search, Type, Difficulty) are synced to URL parameters.
|
||||
- **Debounce**: Search input uses debounce to prevent excessive requests.
|
||||
- **Server Filtering**: Filtering logic is executed on the server side (currently simulated in `page.tsx`, ready for DB integration).
|
||||
### 3.4 筛选器(`question-filters.tsx`)
|
||||
- **URL 同步**:搜索、题型、难度等筛选条件与 URL 参数保持同步
|
||||
- **无 Debounce(当前)**:搜索输入每次变更都会更新 URL
|
||||
- **服务端筛选**:在服务端组件中通过 `getQuestions` 执行筛选查询
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Details
|
||||
## 4. 实现细节
|
||||
|
||||
### 4.1 Data Flow
|
||||
1. **Read**: `page.tsx` (Server Component) fetches data based on `searchParams`.
|
||||
2. **Write**: Client components invoke Server Actions (simulated) -> Revalidate Path -> UI Updates.
|
||||
3. **Filter**: User interaction -> Update URL -> Server Component Re-render -> New Data.
|
||||
### 4.1 数据流
|
||||
1. **读取**:`page.tsx`(Server Component)根据 `searchParams` 拉取数据
|
||||
2. **写入**:客户端组件调用 Server Actions -> `revalidatePath` -> UI 更新
|
||||
3. **筛选**:用户操作 -> 更新 URL -> 服务端组件重新渲染 -> 返回新数据
|
||||
|
||||
### 4.2 类型安全
|
||||
共享的 `Question` 类型用于保证前后端一致:
|
||||
|
||||
### 4.2 Type Safety
|
||||
A shared `Question` interface ensures consistency across the stack:
|
||||
```typescript
|
||||
export interface Question {
|
||||
id: string;
|
||||
content: any; // Rich text structure
|
||||
type: QuestionType;
|
||||
difficulty: number;
|
||||
// ... relations
|
||||
id: string
|
||||
content: unknown
|
||||
type: QuestionType
|
||||
difficulty: number
|
||||
// ... 关联字段
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 UI/UX Standards
|
||||
- **Empty States**: Custom `EmptyState` component when no data matches.
|
||||
- **Loading States**: Skeleton screens for table loading.
|
||||
- **Feedback**: `Sonner` toasts for success/error notifications.
|
||||
- **Confirmation**: `AlertDialog` for destructive actions (Delete).
|
||||
### 4.3 UI/UX 规范
|
||||
- **空状态**:无数据时展示 `EmptyState`
|
||||
- **加载态**:表格加载使用 Skeleton
|
||||
- **反馈**:`Sonner` toast 展示成功/失败提示
|
||||
- **确认弹窗**:删除等破坏性操作使用 `AlertDialog`
|
||||
|
||||
---
|
||||
|
||||
## 5. Next Steps
|
||||
- [ ] Integrate with real Database (replace Mock Data).
|
||||
- [ ] Implement Rich Text Editor (Slate.js / Tiptap) for question content.
|
||||
- [ ] Add "Batch Import" functionality.
|
||||
- [ ] Implement "Tags" management for Knowledge Points.
|
||||
## 5. 后续计划
|
||||
- [x] 接入真实数据库(替换 Mock Data)
|
||||
- [ ] 为题目内容引入富文本编辑器(Slate.js / Tiptap)
|
||||
- [ ] 增加“批量导入”能力
|
||||
- [ ] 增加知识点“标签”管理能力
|
||||
|
||||
---
|
||||
|
||||
## 6. 实现更新(2025-12-30)
|
||||
|
||||
### 6.1 教师路由与加载态
|
||||
- 实现 `/teacher/questions` 页面(Suspense + 空状态)
|
||||
- 新增路由级加载 UI:`/teacher/questions/loading.tsx`
|
||||
|
||||
### 6.2 Content JSON 约定
|
||||
为与考试组卷/预览组件保持一致,`questions.content` 采用最小 JSON 结构:
|
||||
|
||||
```typescript
|
||||
type ChoiceOption = {
|
||||
id: string
|
||||
text: string
|
||||
isCorrect?: boolean
|
||||
}
|
||||
|
||||
type QuestionContent = {
|
||||
text: string
|
||||
options?: ChoiceOption[]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 数据访问层(`getQuestions`)
|
||||
- 新增服务端筛选:`q`(content LIKE)、`type`、`difficulty`、`knowledgePointId`
|
||||
- 默认仅返回根题(`parentId IS NULL`),除非显式按 `ids` 查询
|
||||
- 返回 `{ data, meta }`(包含分页统计),并为 UI 映射关联数据
|
||||
|
||||
### 6.4 Server Actions(CRUD)
|
||||
- `createNestedQuestion` 支持 FormData(字段 `json`)与递归 `subQuestions`
|
||||
- `updateQuestionAction` 更新题目与知识点关联
|
||||
- `deleteQuestionAction` 递归删除子题
|
||||
- 所有变更都会对 `/teacher/questions` 做缓存再验证
|
||||
|
||||
### 6.5 UI 集成
|
||||
- `CreateQuestionDialog` 提交 `QuestionContent` JSON,并支持选择题正确答案勾选
|
||||
- `QuestionActions` 在编辑/删除后刷新列表
|
||||
- 表格内容预览优先展示 `content.text`
|
||||
|
||||
### 6.6 校验
|
||||
- `npm run lint`:0 errors(仓库其他位置仍存在 warnings)
|
||||
- `npm run typecheck`:通过
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
# 考试模块实现设计文档
|
||||
|
||||
## 1. 概述
|
||||
考试模块提供了一个完整的评估管理生命周期,使教师能够创建考试、组卷(支持嵌套分组)、发布评估以及对学生的提交进行评分。
|
||||
考试模块用于教师侧的“试卷制作与管理”,覆盖创建考试、组卷(支持嵌套分组)、发布/归档等流程。
|
||||
|
||||
**说明(合并调整)**:与“作业(Homework)”模块合并后,考试模块不再提供“阅卷/评分(grading)”与提交流转;教师批改统一在 Homework 的 submissions 中完成。
|
||||
|
||||
## 2. 数据架构
|
||||
|
||||
### 2.1 核心实体
|
||||
- **Exams**: 根实体,包含元数据(标题、时间安排)和结构信息。
|
||||
- **ExamQuestions**: 关系链接,用于查询题目的使用情况(扁平化表示)。
|
||||
- **ExamSubmissions**: 学生的考试尝试记录。
|
||||
- **SubmissionAnswers**: 链接到特定题目的单个答案。
|
||||
- **ExamSubmissions**: (历史/保留)学生的考试尝试记录;当前 UI/路由不再使用。
|
||||
- **SubmissionAnswers**: (历史/保留)链接到特定题目的单个答案;当前 UI/路由不再使用。
|
||||
|
||||
### 2.2 `structure` 字段
|
||||
为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`。这作为考试布局的“单一事实来源”(Source of Truth)。
|
||||
为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`。它作为“布局/呈现层”的单一事实来源(Source of Truth),用于渲染分组与排序;而 `exam_questions` 仍然承担题目关联、外键完整性与索引查询职责。
|
||||
|
||||
**JSON Schema:**
|
||||
```typescript
|
||||
@@ -26,6 +28,9 @@ type ExamNode = {
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 `description` 元数据字段(当前实现)
|
||||
当前版本将部分元数据(如 `subject/grade/difficulty/totalScore/durationMin/tags/scheduledAt`)以 JSON 字符串形式存入 `exams.description`,并在数据访问层解析后提供给列表页展示与筛选。
|
||||
|
||||
## 3. 组件架构
|
||||
|
||||
### 3.1 组卷(构建器)
|
||||
@@ -43,14 +48,16 @@ type ExamNode = {
|
||||
- 可搜索/筛选的可用题目列表。
|
||||
- “添加”操作将节点追加到结构树中。
|
||||
|
||||
### 3.2 阅卷界面
|
||||
位于 `/teacher/exams/grading/[submissionId]`。
|
||||
### 3.2 阅卷界面(已下线)
|
||||
原阅卷路由 `/teacher/exams/grading` 与 `/teacher/exams/grading/[submissionId]` 已移除业务能力并重定向到 Homework:
|
||||
- `/teacher/exams/grading*` → `/teacher/homework/submissions`
|
||||
|
||||
- **`GradingView` (客户端组件)**
|
||||
- **左侧面板**: 只读视图,显示学生的答案与题目内容。
|
||||
- **右侧面板**: 评分和反馈的输入字段。
|
||||
- **状态**: 在提交前管理本地更改。
|
||||
- **Actions**: `gradeSubmissionAction` 更新 `submissionAnswers` 并将总分聚合到 `examSubmissions`。
|
||||
### 3.3 列表页(All Exams)
|
||||
位于 `/teacher/exams/all`。
|
||||
|
||||
- **Page (RSC)**: 负责解析 query(`q/status/difficulty`)并调用数据访问层获取 exams。
|
||||
- **`ExamFilters` (客户端组件)**: 使用 URL query 驱动筛选条件。
|
||||
- **`ExamDataTable` (客户端组件)**: 基于 TanStack Table 渲染列表,并在 actions 列中渲染 `ExamActions`。
|
||||
|
||||
## 4. 关键工作流
|
||||
|
||||
@@ -64,13 +71,31 @@ type ExamNode = {
|
||||
- **保存**: 同时提交 `questionsJson`(扁平化,用于索引)和 `structureJson`(树状,用于布局)到 `updateExamAction`。
|
||||
3. **发布**: 状态变更为 `published`。
|
||||
|
||||
### 4.2 阅卷流程
|
||||
1. **列表**: 教师查看 `submission-data-table`。
|
||||
2. **评分**: 打开特定提交。
|
||||
3. **审查**: 遍历题目。
|
||||
- 系统显示学生答案。
|
||||
- 教师输入分数(上限为满分)和反馈。
|
||||
4. **提交**: 服务器更新单个答案记录并重新计算提交总分。
|
||||
### 4.2 阅卷/批改流程(迁移到 Homework)
|
||||
教师批改统一在 Homework 模块完成:
|
||||
- 提交列表:`/teacher/homework/submissions`
|
||||
- 批改页:`/teacher/homework/submissions/[submissionId]`
|
||||
|
||||
### 4.3 考试管理(All Exams Actions)
|
||||
位于 `/teacher/exams/all` 的表格行级菜单。
|
||||
|
||||
1. **Publish / Move to Draft / Archive**
|
||||
- 客户端组件 `ExamActions` 触发 `updateExamAction`,传入 `examId` 与目标 `status`。
|
||||
- 服务器更新 `exams.status`,并对 `/teacher/exams/all` 执行缓存再验证。
|
||||
|
||||
2. **Duplicate**
|
||||
- 客户端组件 `ExamActions` 触发 `duplicateExamAction`,传入 `examId`。
|
||||
- 服务器复制 `exams` 记录并复制关联的 `exam_questions`。
|
||||
- 新考试以 `draft` 状态创建,复制结构(`exams.structure`),并清空排期信息(`startTime/endTime`,以及 description 中的 `scheduledAt`)。
|
||||
- 成功后跳转到新考试的构建页 `/teacher/exams/[id]/build`。
|
||||
|
||||
3. **Delete**
|
||||
- 客户端组件 `ExamActions` 触发 `deleteExamAction`,传入 `examId`。
|
||||
- 服务器删除 `exams` 记录;相关表(如 `exam_questions`、`exam_submissions`、`submission_answers`)通过外键级联删除。
|
||||
- 成功后刷新列表。
|
||||
|
||||
4. **Edit / Build**
|
||||
- 当前统一跳转到 `/teacher/exams/[id]/build`。
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
@@ -87,4 +112,50 @@ type ExamNode = {
|
||||
- 面向未来(现代 React Hooks 模式)。
|
||||
|
||||
### 5.3 Server Actions
|
||||
所有变更操作(保存草稿、发布、评分)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。
|
||||
所有变更操作(保存草稿、发布、复制、删除)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。
|
||||
|
||||
已落地的 Server Actions:
|
||||
- `createExamAction`
|
||||
- `updateExamAction`
|
||||
- `duplicateExamAction`
|
||||
- `deleteExamAction`
|
||||
|
||||
## 6. 接口与数据影响
|
||||
|
||||
### 6.1 `updateExamAction`
|
||||
- **入参(FormData)**: `examId`(必填),`status`(可选:draft/published/archived),`questionsJson`(可选),`structureJson`(可选)
|
||||
- **行为**:
|
||||
- 若传入 `questionsJson`:先清空 `exam_questions` 再批量写入,`order` 由数组顺序决定;未传入则不触碰 `exam_questions`
|
||||
- 若传入 `structureJson`:写入 `exams.structure`;未传入则不更新该字段
|
||||
- 若传入 `status`:写入 `exams.status`
|
||||
- **缓存**: `revalidatePath("/teacher/exams/all")`
|
||||
|
||||
### 6.2 `duplicateExamAction`
|
||||
- **入参(FormData)**: `examId`(必填)
|
||||
- **行为**:
|
||||
- 复制一条 `exams`(新 id、新 title:追加 “(Copy)”、`status` 强制为 `draft`)
|
||||
- `startTime/endTime` 置空;同时尝试从 `description` JSON 中移除 `scheduledAt`
|
||||
- 复制 `exam_questions`(保留 questionId/score/order)
|
||||
- 复制 `exams.structure`
|
||||
- **缓存**: `revalidatePath("/teacher/exams/all")`
|
||||
|
||||
### 6.3 `deleteExamAction`
|
||||
- **入参(FormData)**: `examId`(必填)
|
||||
- **行为**:
|
||||
- 删除 `exams` 记录
|
||||
- 依赖外键级联清理关联数据:`exam_questions`、`exam_submissions`、`submission_answers`
|
||||
- **缓存**:
|
||||
- `revalidatePath("/teacher/exams/all")`
|
||||
|
||||
### 6.4 数据访问层(Data Access)
|
||||
位于 `src/modules/exams/data-access.ts`,对外提供与页面/组件解耦的查询函数。
|
||||
|
||||
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
|
||||
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
|
||||
|
||||
## 7. 变更记录(合并 Homework)
|
||||
|
||||
**日期**:2025-12-31
|
||||
|
||||
- 移除 Exams grading 入口与实现:删除阅卷 UI、server action、data-access 查询。
|
||||
- Exams grading 路由改为重定向到 Homework submissions。
|
||||
|
||||
153
docs/design/006_homework_module_implementation.md
Normal file
153
docs/design/006_homework_module_implementation.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 作业模块实现设计文档(Homework Module)
|
||||
|
||||
**日期**: 2025-12-31
|
||||
**模块**: Homework (`src/modules/homework`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
作业模块提供“由试卷派发作业”的完整生命周期:
|
||||
|
||||
- 教师从已存在的 Exam 派发 Homework Assignment(冻结当时的结构与题目引用)
|
||||
- 指定作业目标学生(Targets)
|
||||
- 学生开始一次作答(Submission),保存答案(Answers),并最终提交
|
||||
- 教师在提交列表中查看并批改(按题给分/反馈,汇总总分)
|
||||
|
||||
核心目标是:在不破坏 Exam 本体数据的前提下,为作业提供可追溯、可批改、可统计的独立域模型。
|
||||
|
||||
**说明(合并调整)**:教师端“阅卷/批改”统一通过 Homework submissions 完成,`/teacher/exams/grading*` 相关路由已重定向到 `/teacher/homework/submissions`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据架构
|
||||
|
||||
### 2.1 核心实体
|
||||
|
||||
- `homework_assignments`: 作业实例(从 exam 派生)
|
||||
- `homework_assignment_questions`: 作业与题目关系(score/order)
|
||||
- `homework_assignment_targets`: 作业目标学生列表
|
||||
- `homework_submissions`: 学生作业尝试(attempt_no/status/时间/是否迟交)
|
||||
- `homework_answers`: 每题答案(answer_content/score/feedback)
|
||||
|
||||
数据库变更记录见:[schema-changelog.md](file:///c:/Users/xiner/Desktop/CICD/docs/db/schema-changelog.md#L34-L77)
|
||||
|
||||
### 2.2 设计要点:冻结 Exam → Homework Assignment
|
||||
|
||||
- `homework_assignments.source_exam_id` 保存来源 Exam
|
||||
- `homework_assignments.structure` 在 publish 时复制 `exams.structure`(冻结当时的呈现结构)
|
||||
- 题目关联使用 `homework_assignment_questions`(仍引用 `questions` 表,作业侧记录分值与顺序)
|
||||
|
||||
---
|
||||
|
||||
## 3. 路由与页面
|
||||
|
||||
### 3.1 教师端
|
||||
|
||||
- `/teacher/homework/assignments`: 作业列表
|
||||
实现:[assignments/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
|
||||
- `/teacher/homework/assignments/create`: 从 Exam 派发作业
|
||||
实现:[create/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
|
||||
- `/teacher/homework/assignments/[id]`: 作业详情
|
||||
实现:[[id]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
|
||||
- `/teacher/homework/assignments/[id]/submissions`: 作业提交列表(按作业筛选)
|
||||
实现:[[id]/submissions/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
|
||||
- `/teacher/homework/submissions`: 全部提交列表
|
||||
实现:[submissions/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
|
||||
- `/teacher/homework/submissions/[submissionId]`: 批改页
|
||||
实现:[[submissionId]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
|
||||
|
||||
关联重定向:
|
||||
|
||||
- `/teacher/exams/grading` → `/teacher/homework/submissions`
|
||||
实现:[grading/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)
|
||||
- `/teacher/exams/grading/[submissionId]` → `/teacher/homework/submissions`
|
||||
实现:[grading/[submissionId]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
|
||||
|
||||
### 3.2 学生端
|
||||
|
||||
- `/student/learning/assignments`: 作业列表
|
||||
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/student/learning/assignments/page.tsx)
|
||||
- `/student/learning/assignments/[assignmentId]`: 作答页(开始/保存/提交)
|
||||
实现:[[assignmentId]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/student/learning/assignments/%5BassignmentId%5D/page.tsx)
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据访问层(Data Access)
|
||||
|
||||
数据访问位于:[data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
|
||||
|
||||
### 4.1 教师侧查询
|
||||
|
||||
- `getHomeworkAssignments`:作业列表(可按 creatorId/ids)
|
||||
- `getHomeworkAssignmentById`:作业详情(含目标人数、提交数统计)
|
||||
- `getHomeworkSubmissions`:提交列表(可按 assignmentId)
|
||||
- `getHomeworkSubmissionDetails`:提交详情(题目内容 + 学生答案 + 分值/顺序)
|
||||
|
||||
### 4.2 学生侧查询
|
||||
|
||||
- `getStudentHomeworkAssignments(studentId)`:只返回“已派发给该学生、已发布、且到达 availableAt”的作业
|
||||
- `getStudentHomeworkTakeData(assignmentId, studentId)`:进入作答页所需数据(assignment + 当前/最近 submission + 题目列表 + 已保存答案)
|
||||
|
||||
### 4.3 开发模式用户选择(Demo)
|
||||
|
||||
为了在未接入真实 Auth 的情况下可演示学生端页面,提供:
|
||||
|
||||
- `getDemoStudentUser()`:优先选取最早创建的 student;若无 student,则退化到任意用户
|
||||
|
||||
---
|
||||
|
||||
## 5. Server Actions
|
||||
|
||||
实现位于:[actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/actions.ts)
|
||||
|
||||
### 5.1 教师侧
|
||||
|
||||
- `createHomeworkAssignmentAction`:从 exam 创建 assignment;可写入 targets;可选择 publish(默认 true)
|
||||
- `gradeHomeworkSubmissionAction`:按题写入 score/feedback,并汇总写入 submission.score 与 status=graded
|
||||
|
||||
### 5.2 学生侧
|
||||
|
||||
- `startHomeworkSubmissionAction`:创建一次 submission(attemptNo + startedAt),并校验:
|
||||
- assignment 已发布
|
||||
- student 在 targets 中
|
||||
- availableAt 已到
|
||||
- 未超过 maxAttempts
|
||||
- `saveHomeworkAnswerAction`:保存/更新某题答案(upsert 到 homework_answers)
|
||||
- `submitHomeworkAction`:提交作业(校验 dueAt/lateDueAt/allowLate,写入 submittedAt/isLate/status=submitted)
|
||||
|
||||
---
|
||||
|
||||
## 6. UI 组件
|
||||
|
||||
### 6.1 教师批改视图
|
||||
|
||||
- [HomeworkGradingView](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-grading-view.tsx)
|
||||
- 左侧:学生答案只读展示
|
||||
- 右侧:按题录入分数与反馈,并提交批改
|
||||
|
||||
### 6.2 学生作答视图
|
||||
|
||||
- [HomeworkTakeView](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-take-view.tsx)
|
||||
- Start:开始一次作答
|
||||
- Save:按题保存
|
||||
- Submit:提交(提交前会先保存当前题目答案)
|
||||
- 题型支持:`text` / `judgment` / `single_choice` / `multiple_choice`
|
||||
|
||||
题目 content 约定与题库一致:`{ text, options?: [{ id, text, isCorrect? }] }`(作答页仅消费 `id/text`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 类型定义
|
||||
|
||||
类型位于:[types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/types.ts)
|
||||
|
||||
- 教师侧:`HomeworkAssignmentListItem` / `HomeworkSubmissionDetails` 等
|
||||
- 学生侧:`StudentHomeworkAssignmentListItem` / `StudentHomeworkTakeData` 等
|
||||
|
||||
---
|
||||
|
||||
## 8. 校验
|
||||
|
||||
- `npm run typecheck`: 通过
|
||||
- `npm run lint`: 0 errors(仓库其他位置存在 warnings,与本模块新增功能无直接关联)
|
||||
90
docs/scripts/baseline-migrations.js
Normal file
90
docs/scripts/baseline-migrations.js
Normal file
@@ -0,0 +1,90 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const mysql = require("mysql2/promise");
|
||||
|
||||
const JOURNAL = {
|
||||
"0000_aberrant_cobalt_man": 1766460456274,
|
||||
"0001_flawless_texas_twister": 1767004087964,
|
||||
"0002_equal_wolfpack": 1767145757594,
|
||||
};
|
||||
|
||||
function sha256Hex(input) {
|
||||
return crypto.createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
const conn = await mysql.createConnection(url);
|
||||
|
||||
await conn.query(
|
||||
"CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id serial primary key, hash text not null, created_at bigint)"
|
||||
);
|
||||
|
||||
const [existing] = await conn.query(
|
||||
"SELECT id, hash, created_at FROM `__drizzle_migrations` ORDER BY created_at DESC LIMIT 1"
|
||||
);
|
||||
if (Array.isArray(existing) && existing.length > 0) {
|
||||
console.log("✅ __drizzle_migrations already has entries. Skip baselining.");
|
||||
await conn.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const [[accountsRow]] = await conn.query(
|
||||
"SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='accounts'"
|
||||
);
|
||||
const accountsExists = Number(accountsRow?.cnt ?? 0) > 0;
|
||||
if (!accountsExists) {
|
||||
console.log("ℹ️ No existing tables detected (accounts missing). Skip baselining.");
|
||||
await conn.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const [[structureRow]] = await conn.query(
|
||||
"SELECT COUNT(*) AS cnt FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='exams' AND column_name='structure'"
|
||||
);
|
||||
const examsStructureExists = Number(structureRow?.cnt ?? 0) > 0;
|
||||
|
||||
const [[homeworkRow]] = await conn.query(
|
||||
"SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='homework_assignments'"
|
||||
);
|
||||
const homeworkExists = Number(homeworkRow?.cnt ?? 0) > 0;
|
||||
|
||||
const baselineTags = [];
|
||||
baselineTags.push("0000_aberrant_cobalt_man");
|
||||
if (examsStructureExists) baselineTags.push("0001_flawless_texas_twister");
|
||||
if (homeworkExists) baselineTags.push("0002_equal_wolfpack");
|
||||
|
||||
const drizzleDir = path.resolve(__dirname, "..", "..", "drizzle");
|
||||
for (const tag of baselineTags) {
|
||||
const sqlPath = path.join(drizzleDir, `${tag}.sql`);
|
||||
if (!fs.existsSync(sqlPath)) {
|
||||
throw new Error(`Missing migration file: ${sqlPath}`);
|
||||
}
|
||||
const sqlText = fs.readFileSync(sqlPath).toString();
|
||||
const hash = sha256Hex(sqlText);
|
||||
const createdAt = JOURNAL[tag];
|
||||
if (typeof createdAt !== "number") {
|
||||
throw new Error(`Missing journal timestamp for: ${tag}`);
|
||||
}
|
||||
await conn.query(
|
||||
"INSERT INTO `__drizzle_migrations` (`hash`, `created_at`) VALUES (?, ?)",
|
||||
[hash, createdAt]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✅ Baselined __drizzle_migrations: ${baselineTags.join(", ")}`);
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Baseline failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -16,8 +16,18 @@ async function reset() {
|
||||
`)
|
||||
|
||||
// Drop each table
|
||||
for (const row of (tables[0] as unknown as any[])) {
|
||||
const tableName = row.TABLE_NAME || row.table_name
|
||||
const rows = (tables as unknown as [unknown])[0]
|
||||
if (!Array.isArray(rows)) return
|
||||
|
||||
for (const row of rows) {
|
||||
const record = row as Record<string, unknown>
|
||||
const tableName =
|
||||
typeof record.TABLE_NAME === "string"
|
||||
? record.TABLE_NAME
|
||||
: typeof record.table_name === "string"
|
||||
? record.table_name
|
||||
: null
|
||||
if (!tableName) continue
|
||||
console.log(`Dropping table: ${tableName}`)
|
||||
await db.execute(sql.raw(`DROP TABLE IF EXISTS \`${tableName}\`;`))
|
||||
}
|
||||
|
||||
@@ -80,7 +80,12 @@ async function seed() {
|
||||
const qId = createId()
|
||||
questionIds.push(qId)
|
||||
|
||||
const type = faker.helpers.arrayElement(["single_choice", "multiple_choice", "text", "judgment"])
|
||||
const type = faker.helpers.arrayElement([
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"text",
|
||||
"judgment",
|
||||
] as const)
|
||||
|
||||
await db.insert(questions).values({
|
||||
id: qId,
|
||||
@@ -93,7 +98,7 @@ async function seed() {
|
||||
{ id: "D", text: faker.lorem.sentence(), isCorrect: false },
|
||||
] : undefined
|
||||
},
|
||||
type: type as any,
|
||||
type,
|
||||
difficulty: faker.helpers.arrayElement(DIFFICULTY),
|
||||
authorId: teacherId,
|
||||
})
|
||||
@@ -105,7 +110,7 @@ async function seed() {
|
||||
const examId = createId()
|
||||
const subject = faker.helpers.arrayElement(SUBJECTS)
|
||||
const grade = faker.helpers.arrayElement(GRADES)
|
||||
const status = faker.helpers.arrayElement(["draft", "published", "archived"])
|
||||
const status = faker.helpers.arrayElement(["draft", "published", "archived"] as const)
|
||||
|
||||
const scheduledAt = faker.date.soon({ days: 30 })
|
||||
|
||||
@@ -126,7 +131,7 @@ async function seed() {
|
||||
description: JSON.stringify(meta),
|
||||
creatorId: teacherId,
|
||||
startTime: scheduledAt,
|
||||
status: status as any,
|
||||
status,
|
||||
})
|
||||
|
||||
// Link some questions to this exam (random 5 questions)
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
ALTER TABLE `exams` ADD `structure` json;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_question_id_questions_id_fk`;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_qid_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_kpid_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE `exams` ADD `structure` json;
|
||||
|
||||
274
drizzle/0002_equal_wolfpack.sql
Normal file
274
drizzle/0002_equal_wolfpack.sql
Normal file
@@ -0,0 +1,274 @@
|
||||
CREATE TABLE IF NOT EXISTS `homework_answers` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`submission_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`answer_content` json,
|
||||
`score` int,
|
||||
`feedback` text,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `homework_answers_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `homework_assignment_questions` (
|
||||
`assignment_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`score` int DEFAULT 0,
|
||||
`order` int DEFAULT 0,
|
||||
CONSTRAINT `homework_assignment_questions_assignment_id_question_id_pk` PRIMARY KEY(`assignment_id`,`question_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `homework_assignment_targets` (
|
||||
`assignment_id` varchar(128) NOT NULL,
|
||||
`student_id` varchar(128) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `homework_assignment_targets_assignment_id_student_id_pk` PRIMARY KEY(`assignment_id`,`student_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `homework_assignments` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`source_exam_id` varchar(128) NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`structure` json,
|
||||
`status` varchar(50) DEFAULT 'draft',
|
||||
`creator_id` varchar(128) NOT NULL,
|
||||
`available_at` timestamp,
|
||||
`due_at` timestamp,
|
||||
`allow_late` boolean NOT NULL DEFAULT false,
|
||||
`late_due_at` timestamp,
|
||||
`max_attempts` int NOT NULL DEFAULT 1,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `homework_assignments_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `homework_submissions` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`assignment_id` varchar(128) NOT NULL,
|
||||
`student_id` varchar(128) NOT NULL,
|
||||
`attempt_no` int NOT NULL DEFAULT 1,
|
||||
`score` int,
|
||||
`status` varchar(50) DEFAULT 'started',
|
||||
`started_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`submitted_at` timestamp,
|
||||
`is_late` boolean NOT NULL DEFAULT false,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `homework_submissions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
SET @__qkp_drop_qid := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.referential_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name = 'questions_to_knowledge_points_question_id_questions_id_fk'
|
||||
),
|
||||
'ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_question_id_questions_id_fk`;',
|
||||
'SELECT 1;'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt FROM @__qkp_drop_qid;--> statement-breakpoint
|
||||
EXECUTE __stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt;--> statement-breakpoint
|
||||
SET @__qkp_drop_kpid := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.referential_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name = 'questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk'
|
||||
),
|
||||
'ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`;',
|
||||
'SELECT 1;'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt2 FROM @__qkp_drop_kpid;--> statement-breakpoint
|
||||
EXECUTE __stmt2;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt2;--> statement-breakpoint
|
||||
ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_sub_fk` FOREIGN KEY (`submission_id`) REFERENCES `homework_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_exam_fk` FOREIGN KEY (`source_exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_creator_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_student_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
SET @__idx_hw_answer_submission := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_answers'
|
||||
AND index_name = 'hw_answer_submission_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_answer_submission_idx` ON `homework_answers` (`submission_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt3 FROM @__idx_hw_answer_submission;--> statement-breakpoint
|
||||
EXECUTE __stmt3;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt3;--> statement-breakpoint
|
||||
SET @__idx_hw_answer_submission_question := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_answers'
|
||||
AND index_name = 'hw_answer_submission_question_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_answer_submission_question_idx` ON `homework_answers` (`submission_id`,`question_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt4 FROM @__idx_hw_answer_submission_question;--> statement-breakpoint
|
||||
EXECUTE __stmt4;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt4;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_questions_assignment := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignment_questions'
|
||||
AND index_name = 'hw_assignment_questions_assignment_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_questions_assignment_idx` ON `homework_assignment_questions` (`assignment_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt5 FROM @__idx_hw_assignment_questions_assignment;--> statement-breakpoint
|
||||
EXECUTE __stmt5;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt5;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_targets_assignment := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignment_targets'
|
||||
AND index_name = 'hw_assignment_targets_assignment_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_targets_assignment_idx` ON `homework_assignment_targets` (`assignment_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt6 FROM @__idx_hw_assignment_targets_assignment;--> statement-breakpoint
|
||||
EXECUTE __stmt6;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt6;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_targets_student := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignment_targets'
|
||||
AND index_name = 'hw_assignment_targets_student_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_targets_student_idx` ON `homework_assignment_targets` (`student_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt7 FROM @__idx_hw_assignment_targets_student;--> statement-breakpoint
|
||||
EXECUTE __stmt7;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt7;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_creator := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignments'
|
||||
AND index_name = 'hw_assignment_creator_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_creator_idx` ON `homework_assignments` (`creator_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt8 FROM @__idx_hw_assignment_creator;--> statement-breakpoint
|
||||
EXECUTE __stmt8;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt8;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_source_exam := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignments'
|
||||
AND index_name = 'hw_assignment_source_exam_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_source_exam_idx` ON `homework_assignments` (`source_exam_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt9 FROM @__idx_hw_assignment_source_exam;--> statement-breakpoint
|
||||
EXECUTE __stmt9;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt9;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_status := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignments'
|
||||
AND index_name = 'hw_assignment_status_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_status_idx` ON `homework_assignments` (`status`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt10 FROM @__idx_hw_assignment_status;--> statement-breakpoint
|
||||
EXECUTE __stmt10;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt10;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_student := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_submissions'
|
||||
AND index_name = 'hw_assignment_student_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_student_idx` ON `homework_submissions` (`assignment_id`,`student_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt11 FROM @__idx_hw_assignment_student;--> statement-breakpoint
|
||||
EXECUTE __stmt11;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt11;--> statement-breakpoint
|
||||
SET @__qkp_add_qid := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.referential_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name = 'q_kp_qid_fk'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_qid_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt12 FROM @__qkp_add_qid;--> statement-breakpoint
|
||||
EXECUTE __stmt12;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt12;--> statement-breakpoint
|
||||
SET @__qkp_add_kpid := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.referential_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name = 'q_kp_kpid_fk'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_kpid_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt13 FROM @__qkp_add_kpid;--> statement-breakpoint
|
||||
EXECUTE __stmt13;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt13;
|
||||
1884
drizzle/meta/0002_snapshot.json
Normal file
1884
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1767004087964,
|
||||
"tag": "0001_flawless_texas_twister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1767145757594,
|
||||
"tag": "0002_equal_wolfpack",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import nextTs from "eslint-config-next/typescript";
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
rules: {
|
||||
"react-hooks/incompatible-library": "off",
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
@@ -12,6 +17,7 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"docs/scripts/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ async function seed() {
|
||||
creatorId: "user_teacher_math",
|
||||
status: "published",
|
||||
startTime: new Date(),
|
||||
structure: examStructure as any // Bypass strict typing for seed
|
||||
structure: examStructure as unknown
|
||||
});
|
||||
|
||||
// Link questions physically (Source of Truth)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export default async function StudentAssignmentTakePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ assignmentId: string }>
|
||||
}) {
|
||||
const { assignmentId } = await params
|
||||
const student = await getDemoStudentUser()
|
||||
if (!student) return notFound()
|
||||
|
||||
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
||||
if (!data) return notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Max Attempts: {data.assignment.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeworkTakeView assignmentId={data.assignment.id} initialData={data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
105
src/app/(dashboard)/student/learning/assignments/page.tsx
Normal file
105
src/app/(dashboard)/student/learning/assignments/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
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"
|
||||
import { getDemoStudentUser, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
if (status === "graded") return "default"
|
||||
if (status === "submitted") return "secondary"
|
||||
if (status === "in_progress") return "secondary"
|
||||
return "outline"
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
if (status === "graded") return "Graded"
|
||||
if (status === "submitted") return "Submitted"
|
||||
if (status === "in_progress") return "In progress"
|
||||
return "Not started"
|
||||
}
|
||||
|
||||
export default async function StudentAssignmentsPage() {
|
||||
const student = await getDemoStudentUser()
|
||||
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
|
||||
<p className="text-muted-foreground">Your homework assignments.</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState title="No user found" description="Create a student user to see assignments." icon={Inbox} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const assignments = await getStudentHomeworkAssignments(student.id)
|
||||
const hasAssignments = assignments.length > 0
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
|
||||
<p className="text-muted-foreground">Your homework assignments.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/student/dashboard">Back</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasAssignments ? (
|
||||
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
|
||||
) : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Due</TableHead>
|
||||
<TableHead>Attempts</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
|
||||
{a.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||
{getStatusLabel(a.progressStatus)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{a.attemptsUsed}/{a.maxAttempts}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,38 +16,89 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
||||
// In a real app, this might be paginated or filtered by exam subject/grade
|
||||
const { data: questionsData } = await getQuestions({ pageSize: 100 })
|
||||
|
||||
const questionOptions: Question[] = questionsData.map((q) => ({
|
||||
id: q.id,
|
||||
content: q.content as any,
|
||||
type: q.type as any,
|
||||
difficulty: q.difficulty ?? 1,
|
||||
createdAt: new Date(q.createdAt),
|
||||
updatedAt: new Date(q.updatedAt),
|
||||
author: q.author ? {
|
||||
id: q.author.id,
|
||||
name: q.author.name || "Unknown",
|
||||
image: q.author.image || null
|
||||
} : null,
|
||||
knowledgePoints: (q.questionsToKnowledgePoints || []).map((kp) => ({
|
||||
id: kp.knowledgePoint.id,
|
||||
name: kp.knowledgePoint.name
|
||||
}))
|
||||
}))
|
||||
|
||||
const initialSelected = (exam.questions || []).map(q => ({
|
||||
id: q.id,
|
||||
score: q.score || 0
|
||||
}))
|
||||
|
||||
// Prepare initialStructure on server side to avoid hydration mismatch with random IDs
|
||||
let initialStructure: ExamNode[] = exam.structure as ExamNode[] || []
|
||||
const selectedQuestionIds = initialSelected.map((s) => s.id)
|
||||
const { data: selectedQuestionsData } = selectedQuestionIds.length
|
||||
? await getQuestions({ ids: selectedQuestionIds, pageSize: Math.max(10, selectedQuestionIds.length) })
|
||||
: { data: [] as typeof questionsData }
|
||||
|
||||
type RawQuestion = (typeof questionsData)[number]
|
||||
|
||||
const toQuestionOption = (q: RawQuestion): Question => ({
|
||||
id: q.id,
|
||||
content: q.content as Question["content"],
|
||||
type: q.type as Question["type"],
|
||||
difficulty: q.difficulty ?? 1,
|
||||
createdAt: new Date(q.createdAt),
|
||||
updatedAt: new Date(q.updatedAt),
|
||||
author: q.author
|
||||
? {
|
||||
id: q.author.id,
|
||||
name: q.author.name || "Unknown",
|
||||
image: q.author.image || null,
|
||||
}
|
||||
: null,
|
||||
knowledgePoints: q.knowledgePoints ?? [],
|
||||
})
|
||||
|
||||
const questionOptionsById = new Map<string, Question>()
|
||||
for (const q of questionsData) questionOptionsById.set(q.id, toQuestionOption(q))
|
||||
for (const q of selectedQuestionsData) questionOptionsById.set(q.id, toQuestionOption(q))
|
||||
const questionOptions = Array.from(questionOptionsById.values())
|
||||
|
||||
const normalizeStructure = (nodes: unknown): ExamNode[] => {
|
||||
const seen = new Set<string>()
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null
|
||||
|
||||
const normalize = (raw: unknown[]): ExamNode[] => {
|
||||
return raw
|
||||
.map((n) => {
|
||||
if (!isRecord(n)) return null
|
||||
const type = n.type
|
||||
if (type !== "group" && type !== "question") return null
|
||||
|
||||
let id = typeof n.id === "string" && n.id.length > 0 ? n.id : createId()
|
||||
while (seen.has(id)) id = createId()
|
||||
seen.add(id)
|
||||
|
||||
if (type === "group") {
|
||||
return {
|
||||
id,
|
||||
type: "group",
|
||||
title: typeof n.title === "string" ? n.title : undefined,
|
||||
children: normalize(Array.isArray(n.children) ? n.children : []),
|
||||
} satisfies ExamNode
|
||||
}
|
||||
|
||||
if (typeof n.questionId !== "string" || n.questionId.length === 0) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
type: "question",
|
||||
questionId: n.questionId,
|
||||
score: typeof n.score === "number" ? n.score : undefined,
|
||||
} satisfies ExamNode
|
||||
})
|
||||
.filter(Boolean) as ExamNode[]
|
||||
}
|
||||
|
||||
if (!Array.isArray(nodes)) return []
|
||||
return normalize(nodes)
|
||||
}
|
||||
|
||||
let initialStructure: ExamNode[] = normalizeStructure(exam.structure)
|
||||
|
||||
if (initialStructure.length === 0 && initialSelected.length > 0) {
|
||||
initialStructure = initialSelected.map(s => ({
|
||||
id: createId(), // Generate stable ID on server
|
||||
type: 'question',
|
||||
initialStructure = initialSelected.map((s) => ({
|
||||
id: createId(),
|
||||
type: "question",
|
||||
questionId: s.id,
|
||||
score: s.score
|
||||
score: s.score,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,134 @@
|
||||
import { Suspense } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
|
||||
import { examColumns } from "@/modules/exams/components/exam-columns"
|
||||
import { ExamFilters } from "@/modules/exams/components/exam-filters"
|
||||
import { getExams } from "@/modules/exams/data-access"
|
||||
import { FileText, PlusCircle } from "lucide-react"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
async function ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams
|
||||
|
||||
const q = getParam(params, "q")
|
||||
const status = getParam(params, "status")
|
||||
const difficulty = getParam(params, "difficulty")
|
||||
|
||||
const exams = await getExams({
|
||||
q,
|
||||
status,
|
||||
difficulty,
|
||||
})
|
||||
|
||||
const hasFilters = Boolean(q || (status && status !== "all") || (difficulty && difficulty !== "all"))
|
||||
|
||||
const counts = exams.reduce(
|
||||
(acc, e) => {
|
||||
acc.total += 1
|
||||
if (e.status === "draft") acc.draft += 1
|
||||
if (e.status === "published") acc.published += 1
|
||||
if (e.status === "archived") acc.archived += 1
|
||||
return acc
|
||||
},
|
||||
{ total: 0, draft: 0, published: 0, archived: 0 }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Showing</span>
|
||||
<span className="text-sm font-medium">{counts.total}</span>
|
||||
<span className="text-sm text-muted-foreground">exams</span>
|
||||
<Badge variant="outline" className="ml-0 md:ml-2">
|
||||
Draft {counts.draft}
|
||||
</Badge>
|
||||
<Badge variant="outline">Published {counts.published}</Badge>
|
||||
<Badge variant="outline">Archived {counts.archived}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/teacher/exams/create" className="inline-flex items-center gap-2">
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Create Exam
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exams.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title={hasFilters ? "No exams match your filters" : "No exams yet"}
|
||||
description={
|
||||
hasFilters
|
||||
? "Try clearing filters or adjusting keywords."
|
||||
: "Create your first exam to start assigning and grading."
|
||||
}
|
||||
action={
|
||||
hasFilters
|
||||
? {
|
||||
label: "Clear filters",
|
||||
href: "/teacher/exams/all",
|
||||
}
|
||||
: {
|
||||
label: "Create Exam",
|
||||
href: "/teacher/exams/create",
|
||||
}
|
||||
}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : (
|
||||
<ExamDataTable columns={examColumns} data={exams} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExamsResultsFallback() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="h-4 w-[160px]" />
|
||||
<Skeleton className="h-5 w-[92px]" />
|
||||
<Skeleton className="h-5 w-[112px]" />
|
||||
<Skeleton className="h-5 w-[106px]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-[120px]" />
|
||||
<Skeleton className="h-9 w-[132px]" />
|
||||
</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: 6 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function AllExamsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
|
||||
const exams = await getExams({
|
||||
q: params.q as string,
|
||||
status: params.status as string,
|
||||
difficulty: params.difficulty as string,
|
||||
})
|
||||
|
||||
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">
|
||||
@@ -26,21 +136,16 @@ export default async function AllExamsPage({
|
||||
<h2 className="text-2xl font-bold tracking-tight">All Exams</h2>
|
||||
<p className="text-muted-foreground">View and manage all your exams.</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild>
|
||||
<Link href="/teacher/exams/create">Create Exam</Link>
|
||||
</Button>
|
||||
</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" />}>
|
||||
<ExamFilters />
|
||||
</Suspense>
|
||||
|
||||
<div className="rounded-md border bg-card">
|
||||
<ExamDataTable columns={examColumns} data={exams} />
|
||||
</div>
|
||||
<Suspense fallback={<ExamsResultsFallback />}>
|
||||
<ExamsResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,40 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { GradingView } from "@/modules/exams/components/grading-view"
|
||||
import { getSubmissionDetails } from "@/modules/exams/data-access"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function SubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) {
|
||||
const { submissionId } = await params
|
||||
const submission = await getSubmissionDetails(submissionId)
|
||||
|
||||
if (!submission) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{submission.examTitle}</h2>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
|
||||
<span>Student: <span className="font-medium text-foreground">{submission.studentName}</span></span>
|
||||
<span>•</span>
|
||||
<span>Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">Status: {submission.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GradingView
|
||||
submissionId={submission.id}
|
||||
studentName={submission.studentName}
|
||||
examTitle={submission.examTitle}
|
||||
submittedAt={submission.submittedAt}
|
||||
status={submission.status || "started"}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
await params
|
||||
redirect("/teacher/homework/submissions")
|
||||
}
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
import { SubmissionDataTable } from "@/modules/exams/components/submission-data-table"
|
||||
import { submissionColumns } from "@/modules/exams/components/submission-columns"
|
||||
import { getExamSubmissions } from "@/modules/exams/data-access"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function ExamGradingPage() {
|
||||
const submissions = await getExamSubmissions()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grading</h2>
|
||||
<p className="text-muted-foreground">Grade student exam submissions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-card">
|
||||
<SubmissionDataTable columns={submissionColumns} data={submissions} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
redirect("/teacher/homework/submissions")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getHomeworkAssignmentById } from "@/modules/homework/data-access"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const assignment = await getHomeworkAssignmentById(id)
|
||||
|
||||
if (!assignment) return notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{assignment.title}</h2>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{assignment.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">{assignment.description || "—"}</p>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
<span>Source Exam: {assignment.sourceExamTitle}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Created: {formatDate(assignment.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/homework/assignments">Back</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>View Submissions</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Targets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{assignment.targetCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Submissions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{assignment.submissionCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Due</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Late: {assignment.allowLate ? (assignment.lateDueAt ? formatDate(assignment.lateDueAt) : "Allowed") : "Not allowed"}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
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"
|
||||
import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access"
|
||||
|
||||
export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const assignment = await getHomeworkAssignmentById(id)
|
||||
if (!assignment) return notFound()
|
||||
|
||||
const submissions = await getHomeworkSubmissions({ assignmentId: id })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
|
||||
<p className="text-muted-foreground">{assignment.title}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/teacher/homework/assignments/${id}`}>Back</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Submitted</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{submissions.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">{s.studentName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{s.status}
|
||||
</Badge>
|
||||
{s.isLate ? <span className="ml-2 text-xs text-destructive">Late</span> : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
|
||||
<TableCell>{typeof s.score === "number" ? s.score : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/teacher/homework/submissions/${s.id}`} className="text-sm underline-offset-4 hover:underline">
|
||||
Grade
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form"
|
||||
import { getExams } from "@/modules/exams/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { FileQuestion } from "lucide-react"
|
||||
|
||||
export default async function CreateHomeworkAssignmentPage() {
|
||||
const exams = await getExams({})
|
||||
const options = exams.map((e) => ({ id: e.id, title: e.title }))
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Create Assignment</h2>
|
||||
<p className="text-muted-foreground">Dispatch homework from an existing exam.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No exams available"
|
||||
description="Create an exam first, then dispatch it as homework."
|
||||
icon={FileQuestion}
|
||||
action={{ label: "Create Exam", href: "/teacher/exams/create" }}
|
||||
/>
|
||||
) : (
|
||||
<HomeworkAssignmentForm exams={options} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
import Link from "next/link"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { PenTool } from "lucide-react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
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"
|
||||
import { getHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { PenTool, PlusCircle } from "lucide-react"
|
||||
|
||||
export default async function AssignmentsPage() {
|
||||
const assignments = await getHomeworkAssignments()
|
||||
const hasAssignments = assignments.length > 0
|
||||
|
||||
export default function AssignmentsPage() {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
@@ -11,16 +27,58 @@ export default function AssignmentsPage() {
|
||||
Manage homework assignments.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/teacher/homework/assignments/create">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Create Assignment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No assignments"
|
||||
description="You haven't created any assignments yet."
|
||||
icon={PenTool}
|
||||
action={{
|
||||
label: "Create Assignment",
|
||||
href: "#"
|
||||
}}
|
||||
/>
|
||||
|
||||
{!hasAssignments ? (
|
||||
<EmptyState
|
||||
title="No assignments"
|
||||
description="You haven't created any assignments yet."
|
||||
icon={PenTool}
|
||||
action={{
|
||||
label: "Create Assignment",
|
||||
href: "/teacher/homework/assignments/create",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Due</TableHead>
|
||||
<TableHead>Source Exam</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/teacher/homework/assignments/${a.id}`} className="hover:underline">
|
||||
{a.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{a.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{a.sourceExamTitle}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access"
|
||||
import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) {
|
||||
const { submissionId } = await params
|
||||
const submission = await getHomeworkSubmissionDetails(submissionId)
|
||||
|
||||
if (!submission) return notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{submission.assignmentTitle}</h2>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
|
||||
<span>
|
||||
Student: <span className="font-medium text-foreground">{submission.studentName}</span>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">Status: {submission.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeworkGradingView
|
||||
submissionId={submission.id}
|
||||
studentName={submission.studentName}
|
||||
assignmentTitle={submission.assignmentTitle}
|
||||
submittedAt={submission.submittedAt}
|
||||
status={submission.status}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import Link from "next/link"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { getHomeworkSubmissions } from "@/modules/homework/data-access"
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
export default function SubmissionsPage() {
|
||||
export default async function SubmissionsPage() {
|
||||
const submissions = await getHomeworkSubmissions()
|
||||
const hasSubmissions = submissions.length > 0
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
@@ -12,11 +27,48 @@ export default function SubmissionsPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No submissions"
|
||||
description="There are no homework submissions to review."
|
||||
icon={Inbox}
|
||||
/>
|
||||
|
||||
{!hasSubmissions ? (
|
||||
<EmptyState
|
||||
title="No submissions"
|
||||
description="There are no homework submissions to review."
|
||||
icon={Inbox}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Submitted</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{submissions.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/teacher/homework/submissions/${s.id}`} className="hover:underline">
|
||||
{s.assignmentTitle}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{s.studentName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{s.status}
|
||||
</Badge>
|
||||
{s.isLate ? <span className="ml-2 text-xs text-destructive">Late</span> : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
|
||||
<TableCell>{typeof s.score === "number" ? s.score : "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
30
src/app/(dashboard)/teacher/questions/loading.tsx
Normal file
30
src/app/(dashboard)/teacher/questions/loading.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
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 className="space-y-2">
|
||||
<Skeleton className="h-7 w-[200px]" />
|
||||
<Skeleton className="h-4 w-[420px]" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-[140px]" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<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: 6 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,51 +1,90 @@
|
||||
import { Suspense } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { QuestionDataTable } from "@/modules/questions/components/question-data-table"
|
||||
import { columns } from "@/modules/questions/components/question-columns"
|
||||
import { QuestionFilters } from "@/modules/questions/components/question-filters"
|
||||
import { CreateQuestionButton } from "@/modules/questions/components/create-question-button"
|
||||
import { MOCK_QUESTIONS } from "@/modules/questions/mock-data"
|
||||
import { Question } from "@/modules/questions/types"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { getQuestions } from "@/modules/questions/data-access"
|
||||
import type { QuestionType } from "@/modules/questions/types"
|
||||
|
||||
// Simulate backend delay and filtering
|
||||
async function getQuestions(searchParams: { [key: string]: string | string[] | undefined }) {
|
||||
// In a real app, you would call your DB or API here
|
||||
// await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate network latency
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
let filtered = [...MOCK_QUESTIONS]
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
const q = searchParams.q as string
|
||||
const type = searchParams.type as string
|
||||
const difficulty = searchParams.difficulty as string
|
||||
async function QuestionBankResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams
|
||||
|
||||
if (q) {
|
||||
filtered = filtered.filter((item) =>
|
||||
(typeof item.content === 'string' && item.content.toLowerCase().includes(q.toLowerCase())) ||
|
||||
(typeof item.content === 'object' && JSON.stringify(item.content).toLowerCase().includes(q.toLowerCase()))
|
||||
const q = getParam(params, "q")
|
||||
const type = getParam(params, "type")
|
||||
const difficulty = getParam(params, "difficulty")
|
||||
|
||||
const questionType: QuestionType | undefined =
|
||||
type === "single_choice" ||
|
||||
type === "multiple_choice" ||
|
||||
type === "text" ||
|
||||
type === "judgment" ||
|
||||
type === "composite"
|
||||
? type
|
||||
: undefined
|
||||
|
||||
const { data: questions } = await getQuestions({
|
||||
q: q || undefined,
|
||||
type: questionType,
|
||||
difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined,
|
||||
pageSize: 200,
|
||||
})
|
||||
|
||||
const hasFilters = Boolean(q || (type && type !== "all") || (difficulty && difficulty !== "all"))
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={hasFilters ? "No questions match your filters" : "No questions yet"}
|
||||
description={
|
||||
hasFilters
|
||||
? "Try clearing filters or adjusting keywords."
|
||||
: "Create your first question to start building exams and assignments."
|
||||
}
|
||||
action={hasFilters ? { label: "Clear filters", href: "/teacher/questions" } : undefined}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type && type !== "all") {
|
||||
filtered = filtered.filter((item) => item.type === type)
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border bg-card">
|
||||
<QuestionDataTable columns={columns} data={questions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (difficulty && difficulty !== "all") {
|
||||
filtered = filtered.filter((item) => item.difficulty === parseInt(difficulty))
|
||||
}
|
||||
|
||||
return filtered
|
||||
function QuestionBankResultsFallback() {
|
||||
return (
|
||||
<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: 6 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function QuestionBankPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const questions = await getQuestions(params)
|
||||
|
||||
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">
|
||||
@@ -62,12 +101,12 @@ export default async function QuestionBankPage({
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<QuestionFilters />
|
||||
<QuestionFilters />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<QuestionBankResultsFallback />}>
|
||||
<QuestionBankResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
|
||||
<div className="rounded-md border bg-card">
|
||||
<QuestionDataTable columns={columns} data={questions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Label } from "@/shared/components/ui/label"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Loader2, Github } from "lucide-react"
|
||||
|
||||
interface LoginFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Label } from "@/shared/components/ui/label"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Loader2, Github } from "lucide-react"
|
||||
|
||||
interface RegisterFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function RegisterForm({ className, ...props }: RegisterFormProps) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
|
||||
@@ -40,9 +40,9 @@ export function TeacherSchedule() {
|
||||
const hasSchedule = MOCK_SCHEDULE.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Today's Schedule</CardTitle>
|
||||
<CardTitle>Today's Schedule</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasSchedule ? (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ActionState } from "@/shared/types/action-state"
|
||||
import { z } from "zod"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, submissionAnswers, examSubmissions } from "@/shared/db/schema"
|
||||
import { exams, examQuestions } from "@/shared/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
const ExamCreateSchema = z.object({
|
||||
@@ -101,8 +101,8 @@ const ExamUpdateSchema = z.object({
|
||||
score: z.coerce.number().int().min(0),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
structure: z.any().optional(), // Accept structure JSON
|
||||
.optional(),
|
||||
structure: z.unknown().optional(),
|
||||
status: z.enum(["draft", "published", "archived"]).optional(),
|
||||
})
|
||||
|
||||
@@ -110,13 +110,15 @@ export async function updateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
const rawStructure = formData.get("structureJson") as string | null
|
||||
const rawQuestions = formData.get("questionsJson")
|
||||
const rawStructure = formData.get("structureJson")
|
||||
const hasQuestions = typeof rawQuestions === "string"
|
||||
const hasStructure = typeof rawStructure === "string"
|
||||
|
||||
const parsed = ExamUpdateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
structure: rawStructure ? JSON.parse(rawStructure) : undefined,
|
||||
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
|
||||
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
|
||||
status: formData.get("status") ?? undefined,
|
||||
})
|
||||
|
||||
@@ -131,22 +133,24 @@ export async function updateExamAction(
|
||||
const { examId, questions, structure, status } = parsed.data
|
||||
|
||||
try {
|
||||
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
|
||||
if (questions.length > 0) {
|
||||
await db.insert(examQuestions).values(
|
||||
questions.map((q, idx) => ({
|
||||
examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
order: idx,
|
||||
}))
|
||||
)
|
||||
if (questions) {
|
||||
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
|
||||
if (questions.length > 0) {
|
||||
await db.insert(examQuestions).values(
|
||||
questions.map((q, idx) => ({
|
||||
examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
order: idx,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update object
|
||||
const updateData: any = {}
|
||||
const updateData: Partial<typeof exams.$inferInsert> = {}
|
||||
if (status) updateData.status = status
|
||||
if (structure) updateData.structure = structure
|
||||
if (structure !== undefined) updateData.structure = structure
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.update(exams).set(updateData).where(eq(exams.id, examId))
|
||||
@@ -169,73 +173,139 @@ export async function updateExamAction(
|
||||
}
|
||||
}
|
||||
|
||||
const GradingSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
answers: z.array(z.object({
|
||||
id: z.string(), // answer id
|
||||
score: z.coerce.number().min(0),
|
||||
feedback: z.string().optional()
|
||||
}))
|
||||
const ExamDeleteSchema = z.object({
|
||||
examId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function gradeSubmissionAction(
|
||||
export async function deleteExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawAnswers = formData.get("answersJson") as string | null
|
||||
const parsed = GradingSchema.safeParse({
|
||||
submissionId: formData.get("submissionId"),
|
||||
answers: rawAnswers ? JSON.parse(rawAnswers) : []
|
||||
const parsed = ExamDeleteSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid grading data",
|
||||
errors: parsed.error.flatten().fieldErrors
|
||||
message: "Invalid delete data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { submissionId, answers } = parsed.data
|
||||
const { examId } = parsed.data
|
||||
|
||||
try {
|
||||
let totalScore = 0
|
||||
|
||||
// Update each answer
|
||||
for (const ans of answers) {
|
||||
await db.update(submissionAnswers)
|
||||
.set({
|
||||
score: ans.score,
|
||||
feedback: ans.feedback,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(submissionAnswers.id, ans.id))
|
||||
|
||||
totalScore += ans.score
|
||||
}
|
||||
|
||||
// Update submission total score and status
|
||||
await db.update(examSubmissions)
|
||||
.set({
|
||||
score: totalScore,
|
||||
status: "graded",
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(examSubmissions.id, submissionId))
|
||||
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
} catch (error) {
|
||||
console.error("Grading failed:", error)
|
||||
console.error("Failed to delete exam:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error during grading"
|
||||
message: "Database error: Failed to delete exam",
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/teacher/exams/grading`)
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Grading saved successfully"
|
||||
message: "Exam deleted",
|
||||
data: examId,
|
||||
}
|
||||
}
|
||||
|
||||
const ExamDuplicateSchema = z.object({
|
||||
examId: z.string().min(1),
|
||||
})
|
||||
|
||||
const omitScheduledAtFromDescription = (description: string | null) => {
|
||||
if (!description) return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(description)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return description
|
||||
const meta = parsed as Record<string, unknown>
|
||||
if ("scheduledAt" in meta) delete meta.scheduledAt
|
||||
return JSON.stringify(meta)
|
||||
} catch {
|
||||
return description
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const parsed = ExamDuplicateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid duplicate data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
const source = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!source) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Exam not found",
|
||||
}
|
||||
}
|
||||
|
||||
const newExamId = createId()
|
||||
const user = await getCurrentUser()
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(exams).values({
|
||||
id: newExamId,
|
||||
title: `${source.title} (Copy)`,
|
||||
description: omitScheduledAtFromDescription(source.description),
|
||||
creatorId: user?.id ?? "user_teacher_123",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
status: "draft",
|
||||
structure: source.structure,
|
||||
})
|
||||
|
||||
if (source.questions.length > 0) {
|
||||
await tx.insert(examQuestions).values(
|
||||
source.questions.map((q) => ({
|
||||
examId: newExamId,
|
||||
questionId: q.questionId,
|
||||
score: q.score ?? 0,
|
||||
order: q.order ?? 0,
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate exam:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to duplicate exam",
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Exam duplicated",
|
||||
data: newExamId,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
140
src/modules/exams/components/assembly/exam-paper-preview.tsx
Normal file
140
src/modules/exams/components/assembly/exam-paper-preview.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Eye, Printer } from "lucide-react"
|
||||
import type { ExamNode } from "./selected-question-list"
|
||||
|
||||
type ChoiceOption = {
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type QuestionContent = {
|
||||
text?: string
|
||||
options?: ChoiceOption[]
|
||||
}
|
||||
|
||||
type ExamPaperPreviewProps = {
|
||||
title: string
|
||||
subject: string
|
||||
grade: string
|
||||
durationMin: number
|
||||
totalScore: number
|
||||
nodes: ExamNode[]
|
||||
}
|
||||
|
||||
export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) {
|
||||
// Helper to flatten questions for continuous numbering
|
||||
let questionCounter = 0
|
||||
|
||||
const renderNode = (node: ExamNode, depth: number = 0) => {
|
||||
if (node.type === 'group') {
|
||||
return (
|
||||
<div key={node.id} className="space-y-4 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-bold ${depth === 0 ? 'text-lg' : 'text-md'} text-foreground/90`}>
|
||||
{node.title || "Section"}
|
||||
</h3>
|
||||
{/* Optional: Show section score if needed */}
|
||||
</div>
|
||||
<div className="pl-0">
|
||||
{node.children?.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'question' && node.question) {
|
||||
questionCounter++
|
||||
const q = node.question
|
||||
const content = q.content as QuestionContent
|
||||
|
||||
return (
|
||||
<div key={node.id} className="mb-6 break-inside-avoid">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-semibold text-foreground min-w-[24px]">{questionCounter}.</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{content.text ?? ""}
|
||||
<span className="text-muted-foreground text-sm ml-2">({node.score}分)</span>
|
||||
</div>
|
||||
|
||||
{/* Options for Choice Questions */}
|
||||
{(q.type === 'single_choice' || q.type === 'multiple_choice') && content.options && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-2 gap-x-4 mt-2 pl-2">
|
||||
{content.options.map((opt) => (
|
||||
<div key={opt.id} className="flex gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<span className="font-medium">{opt.id}.</span>
|
||||
<span>{opt.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Space for written answers */}
|
||||
{q.type === 'text' && (
|
||||
<div className="mt-4 h-24 border-b border-dashed border-muted-foreground/30 w-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" size="sm" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview Exam
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="p-6 pb-2 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Exam Preview</DialogTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden">
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-8 bg-white/50 dark:bg-zinc-950/50">
|
||||
<div className="max-w-3xl mx-auto bg-card shadow-sm border p-12 min-h-[1000px] print:shadow-none print:border-none">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
|
||||
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
|
||||
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
|
||||
<span>Subject: {subject}</span>
|
||||
<span>Grade: {grade}</span>
|
||||
<span>Time: {durationMin} mins</span>
|
||||
<span>Total: {totalScore} pts</span>
|
||||
</div>
|
||||
<div className="flex justify-center gap-12 text-sm pt-4">
|
||||
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
|
||||
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
|
||||
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-2">
|
||||
{nodes.length === 0 ? (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
Empty Exam Paper
|
||||
</div>
|
||||
) : (
|
||||
nodes.map(node => renderNode(node))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DndContext,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
getFirstCollision,
|
||||
CollisionDetection,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
@@ -34,7 +33,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/co
|
||||
import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ExamNode } from "./selected-question-list"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -47,6 +45,15 @@ type StructureEditorProps = {
|
||||
onAddGroup: () => void
|
||||
}
|
||||
|
||||
function cloneExamNodes(nodes: ExamNode[]): ExamNode[] {
|
||||
return nodes.map((node) => {
|
||||
if (node.type === "group") {
|
||||
return { ...node, children: cloneExamNodes(node.children || []) }
|
||||
}
|
||||
return { ...node }
|
||||
})
|
||||
}
|
||||
|
||||
// --- Components ---
|
||||
|
||||
function SortableItem({
|
||||
@@ -201,10 +208,20 @@ function StructureRenderer({ nodes, ...props }: {
|
||||
onScoreChange: (id: string, score: number) => void
|
||||
onGroupTitleChange: (id: string, title: string) => void
|
||||
}) {
|
||||
// Deduplicate nodes to prevent React key errors
|
||||
const uniqueNodes = useMemo(() => {
|
||||
const seen = new Set()
|
||||
return nodes.filter(n => {
|
||||
if (seen.has(n.id)) return false
|
||||
seen.add(n.id)
|
||||
return true
|
||||
})
|
||||
}, [nodes])
|
||||
|
||||
return (
|
||||
<SortableContext items={nodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
||||
{nodes.map(node => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
||||
{uniqueNodes.map(node => (
|
||||
<div key={node.id}>
|
||||
{node.type === 'group' ? (
|
||||
<SortableGroup
|
||||
id={node.id}
|
||||
@@ -232,7 +249,7 @@ function StructureRenderer({ nodes, ...props }: {
|
||||
onScoreChange={(val) => props.onScoreChange(node.id, val)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
)
|
||||
@@ -362,7 +379,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
if (activeContainerId !== overNode.id) {
|
||||
// ... implementation continues ...
|
||||
|
||||
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
|
||||
const newItems = cloneExamNodes(items)
|
||||
|
||||
// Remove active from old location
|
||||
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
|
||||
@@ -386,7 +403,10 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
for (const node of list) {
|
||||
if (node.id === overId) {
|
||||
if (!node.children) node.children = []
|
||||
node.children.push(movedItem)
|
||||
// Extra safety: Check if movedItem.id is already in children
|
||||
if (!node.children.some(c => c.id === movedItem.id)) {
|
||||
node.children.push(movedItem)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (node.children) {
|
||||
@@ -404,8 +424,12 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
|
||||
// Scenario 2: Moving between different lists (e.g. from Root to Group A, or Group A to Group B)
|
||||
if (activeContainerId !== overContainerId) {
|
||||
// FIX: If we are already inside the group we are hovering (i.e. activeContainerId IS overId),
|
||||
// do not try to move "next to" the group (which would move us out).
|
||||
if (activeContainerId === overId) return
|
||||
|
||||
// Standard Sortable Move
|
||||
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
|
||||
const newItems = cloneExamNodes(items)
|
||||
|
||||
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
|
||||
const idx = list.findIndex(i => i.id === activeId)
|
||||
@@ -484,7 +508,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
|
||||
if (activeContainerId === overContainerId) {
|
||||
// Same container reorder
|
||||
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
|
||||
const newItems = cloneExamNodes(items)
|
||||
|
||||
const getMutableList = (groupId?: string): ExamNode[] => {
|
||||
if (groupId === 'root') return newItems
|
||||
@@ -560,7 +584,9 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
) : (
|
||||
<div className="rounded-md border bg-background p-3 shadow-lg opacity-80 w-[300px] flex items-center gap-3">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
<p className="text-sm line-clamp-1">{(activeItem.question?.content as any)?.text || "Question"}</p>
|
||||
<p className="text-sm line-clamp-1">
|
||||
{(activeItem.question?.content as { text?: string } | undefined)?.text || "Question"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions"
|
||||
import { Exam } from "../types"
|
||||
|
||||
interface ExamActionsProps {
|
||||
@@ -42,31 +43,70 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
const router = useRouter()
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const copyId = () => {
|
||||
navigator.clipboard.writeText(exam.id)
|
||||
toast.success("Exam ID copied to clipboard")
|
||||
}
|
||||
|
||||
const publishExam = async () => {
|
||||
toast.success("Exam published")
|
||||
const setStatus = async (status: Exam["status"]) => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("examId", exam.id)
|
||||
formData.set("status", status)
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(status === "published" ? "Exam published" : status === "archived" ? "Exam archived" : "Exam moved to draft")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to update exam")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update exam")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const unpublishExam = async () => {
|
||||
toast.success("Exam moved to draft")
|
||||
}
|
||||
|
||||
const archiveExam = async () => {
|
||||
toast.success("Exam archived")
|
||||
const duplicateExam = async () => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("examId", exam.id)
|
||||
const result = await duplicateExamAction(null, formData)
|
||||
if (result.success && result.data) {
|
||||
toast.success("Exam duplicated")
|
||||
router.push(`/teacher/exams/${result.data}/build`)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to duplicate exam")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to duplicate exam")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
toast.success("Exam deleted successfully")
|
||||
setShowDeleteDialog(false)
|
||||
} catch (e) {
|
||||
const formData = new FormData()
|
||||
formData.set("examId", exam.id)
|
||||
const result = await deleteExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Exam deleted successfully")
|
||||
setShowDeleteDialog(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to delete exam")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete exam")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,25 +128,39 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={publishExam}>
|
||||
<DropdownMenuItem onClick={duplicateExam} disabled={isWorking}>
|
||||
<Copy className="mr-2 h-4 w-4" /> Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setStatus("published")}
|
||||
disabled={isWorking || exam.status === "published"}
|
||||
>
|
||||
<UploadCloud className="mr-2 h-4 w-4" /> Publish
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={unpublishExam}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setStatus("draft")}
|
||||
disabled={isWorking || exam.status === "draft"}
|
||||
>
|
||||
<Undo2 className="mr-2 h-4 w-4" /> Move to Draft
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={archiveExam}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setStatus("archived")}
|
||||
disabled={isWorking || exam.status === "archived"}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" /> Archive
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -159,6 +213,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={isWorking}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useDeferredValue, useMemo, useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import { updateExamAction } from "@/modules/exams/actions"
|
||||
import { StructureEditor } from "./assembly/structure-editor"
|
||||
import { QuestionBankList } from "./assembly/question-bank-list"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
type ExamAssemblyProps = {
|
||||
@@ -48,17 +47,20 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||
const deferredSearch = useDeferredValue(search)
|
||||
|
||||
// Initialize structure state
|
||||
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
||||
// Hydrate structure with full question objects
|
||||
const questionById = new Map<string, Question>()
|
||||
for (const q of props.questionOptions) questionById.set(q.id, q)
|
||||
|
||||
const hydrate = (nodes: ExamNode[]): ExamNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.type === 'question') {
|
||||
const q = props.questionOptions.find(opt => opt.id === node.questionId)
|
||||
return nodes.map((node) => {
|
||||
if (node.type === "question") {
|
||||
const q = node.questionId ? questionById.get(node.questionId) : undefined
|
||||
return { ...node, question: q }
|
||||
}
|
||||
if (node.type === 'group') {
|
||||
if (node.type === "group") {
|
||||
return { ...node, children: hydrate(node.children || []) }
|
||||
}
|
||||
return node
|
||||
@@ -77,8 +79,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
const filteredQuestions = useMemo(() => {
|
||||
let list: Question[] = [...props.questionOptions]
|
||||
|
||||
if (search) {
|
||||
const lower = search.toLowerCase()
|
||||
if (deferredSearch) {
|
||||
const lower = deferredSearch.toLowerCase()
|
||||
list = list.filter(q => {
|
||||
const content = q.content as { text?: string }
|
||||
return content.text?.toLowerCase().includes(lower)
|
||||
@@ -93,7 +95,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
list = list.filter((q) => q.difficulty === d)
|
||||
}
|
||||
return list
|
||||
}, [search, typeFilter, difficultyFilter, props.questionOptions])
|
||||
}, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions])
|
||||
|
||||
// Recursively calculate total score
|
||||
const assignedTotal = useMemo(() => {
|
||||
@@ -109,17 +111,40 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
|
||||
const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
|
||||
|
||||
const handleAdd = (question: Question) => {
|
||||
setStructure(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: createId(),
|
||||
type: 'question',
|
||||
questionId: question.id,
|
||||
score: 10,
|
||||
question
|
||||
const addedQuestionIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
const walk = (nodes: ExamNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.type === "question" && n.questionId) ids.add(n.questionId)
|
||||
if (n.type === "group" && n.children) walk(n.children)
|
||||
}
|
||||
])
|
||||
}
|
||||
walk(structure)
|
||||
return ids
|
||||
}, [structure])
|
||||
|
||||
const handleAdd = (question: Question) => {
|
||||
setStructure((prev) => {
|
||||
const has = (nodes: ExamNode[]): boolean => {
|
||||
return nodes.some((n) => {
|
||||
if (n.type === "question") return n.questionId === question.id
|
||||
if (n.type === "group" && n.children) return has(n.children)
|
||||
return false
|
||||
})
|
||||
}
|
||||
if (has(prev)) return prev
|
||||
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: createId(),
|
||||
type: "question",
|
||||
questionId: question.id,
|
||||
score: 10,
|
||||
question,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddGroup = () => {
|
||||
@@ -171,10 +196,14 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
// Helper to extract flat list for DB examQuestions table
|
||||
const getFlatQuestions = () => {
|
||||
const list: Array<{ id: string; score: number }> = []
|
||||
const seen = new Set<string>()
|
||||
const traverse = (nodes: ExamNode[]) => {
|
||||
nodes.forEach(n => {
|
||||
if (n.type === 'question' && n.questionId) {
|
||||
list.push({ id: n.questionId, score: n.score || 0 })
|
||||
if (!seen.has(n.questionId)) {
|
||||
seen.add(n.questionId)
|
||||
list.push({ id: n.questionId, score: n.score || 0 })
|
||||
}
|
||||
}
|
||||
if (n.type === 'group') {
|
||||
traverse(n.children || [])
|
||||
@@ -187,9 +216,12 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
|
||||
// Helper to strip runtime question objects for DB structure storage
|
||||
const getCleanStructure = () => {
|
||||
const clean = (nodes: ExamNode[]): any[] => {
|
||||
type CleanExamNode = Omit<ExamNode, "question"> & { children?: CleanExamNode[] }
|
||||
|
||||
const clean = (nodes: ExamNode[]): CleanExamNode[] => {
|
||||
return nodes.map(n => {
|
||||
const { question, ...rest } = n
|
||||
void question
|
||||
if (n.type === 'group') {
|
||||
return { ...rest, children: clean(n.children || []) }
|
||||
}
|
||||
@@ -233,7 +265,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
|
||||
<CardHeader className="bg-muted/30 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Exam Structure</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle>Exam Structure</CardTitle>
|
||||
<ExamPaperPreview
|
||||
title={props.title}
|
||||
subject={props.subject}
|
||||
grade={props.grade}
|
||||
durationMin={props.durationMin}
|
||||
totalScore={props.totalScore}
|
||||
nodes={structure}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
|
||||
@@ -324,17 +366,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
<QuestionBankList
|
||||
questions={filteredQuestions}
|
||||
onAdd={handleAdd}
|
||||
isAdded={(id) => {
|
||||
// Check if question is added anywhere in the structure
|
||||
const isAddedRecursive = (nodes: ExamNode[]): boolean => {
|
||||
return nodes.some(n => {
|
||||
if (n.type === 'question' && n.questionId === id) return true
|
||||
if (n.type === 'group' && n.children) return isAddedRecursive(n.children)
|
||||
return false
|
||||
})
|
||||
}
|
||||
return isAddedRecursive(structure)
|
||||
}}
|
||||
isAdded={(id) => addedQuestionIds.has(id)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import { Exam } from "../types"
|
||||
import { ExamActions } from "./exam-actions"
|
||||
@@ -36,8 +36,8 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
{row.original.tags && row.original.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.tags.slice(0, 2).map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-xs">
|
||||
{row.original.tags.slice(0, 2).map((t, idx) => (
|
||||
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -65,9 +65,14 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const variant = status === "published" ? "secondary" : status === "archived" ? "destructive" : "outline"
|
||||
const variant: BadgeProps["variant"] =
|
||||
status === "published"
|
||||
? "secondary"
|
||||
: status === "archived"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
return (
|
||||
<Badge variant={variant as any} className="capitalize">
|
||||
<Badge variant={variant} className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
@@ -134,4 +139,3 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
cell: ({ row }) => <ExamActions exam={row.original} />,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Eye, CheckSquare } from "lucide-react"
|
||||
import { ExamSubmission } from "../types"
|
||||
import Link from "next/link"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export const submissionColumns: ColumnDef<ExamSubmission>[] = [
|
||||
{
|
||||
accessorKey: "studentName",
|
||||
header: "Student",
|
||||
},
|
||||
{
|
||||
accessorKey: "examTitle",
|
||||
header: "Exam",
|
||||
},
|
||||
{
|
||||
accessorKey: "submittedAt",
|
||||
header: "Submitted",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatDate(row.original.submittedAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const variant = status === "graded" ? "secondary" : "outline"
|
||||
return <Badge variant={variant as any} className="capitalize">{status}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "score",
|
||||
header: "Score",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">{row.original.score ?? "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/teacher/exams/grading/${row.original.id}`}>
|
||||
<Eye className="h-4 w-4 mr-1" /> View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/teacher/exams/grading/${row.original.id}`}>
|
||||
<CheckSquare className="h-4 w-4 mr-1" /> Grade
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export function SubmissionDataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="group">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No submissions.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers, users } from "@/shared/db/schema"
|
||||
import { exams } from "@/shared/db/schema"
|
||||
import { eq, desc, like, and, or } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
|
||||
import type { ExamStatus } from "./types"
|
||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
|
||||
export type GetExamsParams = {
|
||||
q?: string
|
||||
@@ -13,6 +13,40 @@ export type GetExamsParams = {
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
const parseExamMeta = (description: string | null): Record<string, unknown> => {
|
||||
if (!description) return {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(description)
|
||||
return isRecord(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const getString = (obj: Record<string, unknown>, key: string): string | undefined => {
|
||||
const v = obj[key]
|
||||
return typeof v === "string" ? v : undefined
|
||||
}
|
||||
|
||||
const getNumber = (obj: Record<string, unknown>, key: string): number | undefined => {
|
||||
const v = obj[key]
|
||||
return typeof v === "number" ? v : undefined
|
||||
}
|
||||
|
||||
const getStringArray = (obj: Record<string, unknown>, key: string): string[] | undefined => {
|
||||
const v = obj[key]
|
||||
if (!Array.isArray(v)) return undefined
|
||||
const items = v.filter((x): x is string => typeof x === "string")
|
||||
return items.length === v.length ? items : undefined
|
||||
}
|
||||
|
||||
const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
|
||||
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
export const getExams = cache(async (params: GetExamsParams) => {
|
||||
const conditions = []
|
||||
@@ -23,7 +57,7 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
||||
}
|
||||
|
||||
if (params.status && params.status !== "all") {
|
||||
conditions.push(eq(exams.status, params.status as any))
|
||||
conditions.push(eq(exams.status, params.status))
|
||||
}
|
||||
|
||||
// Note: Difficulty is stored in JSON description field in current schema,
|
||||
@@ -37,25 +71,23 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
||||
})
|
||||
|
||||
// Transform and Filter (especially for JSON fields)
|
||||
let result = data.map((exam) => {
|
||||
let meta: any = {}
|
||||
try {
|
||||
meta = JSON.parse(exam.description || "{}")
|
||||
} catch { }
|
||||
let result: Exam[] = data.map((exam) => {
|
||||
const meta = parseExamMeta(exam.description || null)
|
||||
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
subject: meta.subject || "General",
|
||||
grade: meta.grade || "General",
|
||||
difficulty: meta.difficulty || 1,
|
||||
totalScore: meta.totalScore || 100,
|
||||
durationMin: meta.durationMin || 60,
|
||||
questionCount: meta.questionCount || 0,
|
||||
subject: getString(meta, "subject") || "General",
|
||||
grade: getString(meta, "grade") || "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
totalScore: getNumber(meta, "totalScore") || 100,
|
||||
durationMin: getNumber(meta, "durationMin") || 60,
|
||||
questionCount: getNumber(meta, "questionCount") || 0,
|
||||
scheduledAt: exam.startTime?.toISOString(),
|
||||
createdAt: exam.createdAt.toISOString(),
|
||||
tags: meta.tags || [],
|
||||
updatedAt: exam.updatedAt?.toISOString(),
|
||||
tags: getStringArray(meta, "tags") || [],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -82,101 +114,26 @@ export const getExamById = cache(async (id: string) => {
|
||||
|
||||
if (!exam) return null
|
||||
|
||||
let meta: any = {}
|
||||
try {
|
||||
meta = JSON.parse(exam.description || "{}")
|
||||
} catch { }
|
||||
const meta = parseExamMeta(exam.description || null)
|
||||
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
subject: meta.subject || "General",
|
||||
grade: meta.grade || "General",
|
||||
difficulty: meta.difficulty || 1,
|
||||
totalScore: meta.totalScore || 100,
|
||||
durationMin: meta.durationMin || 60,
|
||||
subject: getString(meta, "subject") || "General",
|
||||
grade: getString(meta, "grade") || "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
totalScore: getNumber(meta, "totalScore") || 100,
|
||||
durationMin: getNumber(meta, "durationMin") || 60,
|
||||
scheduledAt: exam.startTime?.toISOString(),
|
||||
createdAt: exam.createdAt.toISOString(),
|
||||
tags: meta.tags || [],
|
||||
structure: exam.structure as any, // Return structure
|
||||
questions: exam.questions.map(eq => ({
|
||||
id: eq.questionId,
|
||||
score: eq.score,
|
||||
order: eq.order,
|
||||
// ... include question details if needed
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
export const getExamSubmissions = cache(async () => {
|
||||
const data = await db.query.examSubmissions.findMany({
|
||||
orderBy: [desc(examSubmissions.submittedAt)],
|
||||
with: {
|
||||
exam: true,
|
||||
student: true
|
||||
}
|
||||
})
|
||||
|
||||
return data.map(sub => ({
|
||||
id: sub.id,
|
||||
examId: sub.examId,
|
||||
examTitle: sub.exam.title,
|
||||
studentName: sub.student.name || "Unknown",
|
||||
submittedAt: sub.submittedAt ? sub.submittedAt.toISOString() : new Date().toISOString(),
|
||||
score: sub.score || undefined,
|
||||
status: sub.status as "pending" | "graded",
|
||||
}))
|
||||
})
|
||||
|
||||
export const getSubmissionDetails = cache(async (submissionId: string) => {
|
||||
const submission = await db.query.examSubmissions.findFirst({
|
||||
where: eq(examSubmissions.id, submissionId),
|
||||
with: {
|
||||
student: true,
|
||||
exam: true,
|
||||
}
|
||||
})
|
||||
|
||||
if (!submission) return null
|
||||
|
||||
// Fetch answers
|
||||
const answers = await db.query.submissionAnswers.findMany({
|
||||
where: eq(submissionAnswers.submissionId, submissionId),
|
||||
with: {
|
||||
question: true
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch exam questions structure (to know max score and order)
|
||||
const examQ = await db.query.examQuestions.findMany({
|
||||
where: eq(examQuestions.examId, submission.examId),
|
||||
orderBy: [desc(examQuestions.order)],
|
||||
})
|
||||
|
||||
// Map answers with question details
|
||||
const answersWithDetails = answers.map(ans => {
|
||||
const eqRel = examQ.find(q => q.questionId === ans.questionId)
|
||||
return {
|
||||
id: ans.id,
|
||||
questionId: ans.questionId,
|
||||
questionContent: ans.question.content,
|
||||
questionType: ans.question.type,
|
||||
maxScore: eqRel?.score || 0,
|
||||
studentAnswer: ans.answerContent,
|
||||
score: ans.score,
|
||||
feedback: ans.feedback,
|
||||
order: eqRel?.order || 0
|
||||
}
|
||||
}).sort((a, b) => a.order - b.order)
|
||||
|
||||
return {
|
||||
id: submission.id,
|
||||
studentName: submission.student.name || "Unknown",
|
||||
examTitle: submission.exam.title,
|
||||
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
|
||||
status: submission.status,
|
||||
totalScore: submission.score,
|
||||
answers: answersWithDetails
|
||||
updatedAt: exam.updatedAt?.toISOString(),
|
||||
tags: getStringArray(meta, "tags") || [],
|
||||
structure: exam.structure as unknown,
|
||||
questions: exam.questions.map((eqRel) => ({
|
||||
id: eqRel.questionId,
|
||||
score: eqRel.score ?? 0,
|
||||
order: eqRel.order ?? 0,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
366
src/modules/homework/actions.ts
Normal file
366
src/modules/homework/actions.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { headers } from "next/headers"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
exams,
|
||||
homeworkAnswers,
|
||||
homeworkAssignmentQuestions,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
|
||||
|
||||
type CurrentUser = { id: string; role: "admin" | "teacher" | "student" }
|
||||
|
||||
async function getCurrentUser() {
|
||||
const ref = (await headers()).get("referer") || ""
|
||||
const roleHint: CurrentUser["role"] = ref.includes("/admin/")
|
||||
? "admin"
|
||||
: ref.includes("/student/")
|
||||
? "student"
|
||||
: ref.includes("/teacher/")
|
||||
? "teacher"
|
||||
: "teacher"
|
||||
|
||||
const byRole = await db.query.users.findFirst({
|
||||
where: eq(users.role, roleHint),
|
||||
orderBy: (u, { asc }) => [asc(u.createdAt)],
|
||||
})
|
||||
|
||||
if (byRole) return { id: byRole.id, role: roleHint }
|
||||
|
||||
const anyUser = await db.query.users.findFirst({
|
||||
orderBy: (u, { asc }) => [asc(u.createdAt)],
|
||||
})
|
||||
|
||||
if (anyUser) return { id: anyUser.id, role: roleHint }
|
||||
|
||||
return { id: "user_teacher_123", role: roleHint }
|
||||
}
|
||||
|
||||
async function ensureTeacher() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== "teacher" && user.role !== "admin")) throw new Error("Unauthorized")
|
||||
return user
|
||||
}
|
||||
|
||||
async function ensureStudent() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || user.role !== "student") throw new Error("Unauthorized")
|
||||
return user
|
||||
}
|
||||
|
||||
const parseStudentIds = (raw: string): string[] => {
|
||||
return raw
|
||||
.split(/[,\n\r\t ]+/g)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
export async function createHomeworkAssignmentAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureTeacher()
|
||||
|
||||
const targetStudentIdsJson = formData.get("targetStudentIdsJson")
|
||||
const targetStudentIdsText = formData.get("targetStudentIdsText")
|
||||
|
||||
const parsed = CreateHomeworkAssignmentSchema.safeParse({
|
||||
sourceExamId: formData.get("sourceExamId"),
|
||||
title: formData.get("title") || undefined,
|
||||
description: formData.get("description") || undefined,
|
||||
availableAt: formData.get("availableAt") || undefined,
|
||||
dueAt: formData.get("dueAt") || undefined,
|
||||
allowLate: formData.get("allowLate") || undefined,
|
||||
lateDueAt: formData.get("lateDueAt") || undefined,
|
||||
maxAttempts: formData.get("maxAttempts") || undefined,
|
||||
publish: formData.get("publish") || undefined,
|
||||
targetStudentIds:
|
||||
typeof targetStudentIdsJson === "string" && targetStudentIdsJson.length > 0
|
||||
? (JSON.parse(targetStudentIdsJson) as unknown)
|
||||
: typeof targetStudentIdsText === "string" && targetStudentIdsText.trim().length > 0
|
||||
? parseStudentIds(targetStudentIdsText)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
const publish = input.publish ?? true
|
||||
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, input.sourceExamId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!exam) return { success: false, message: "Exam not found" }
|
||||
|
||||
const assignmentId = createId()
|
||||
|
||||
const availableAt = input.availableAt ? new Date(input.availableAt) : null
|
||||
const dueAt = input.dueAt ? new Date(input.dueAt) : null
|
||||
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
|
||||
|
||||
const targetStudentIds =
|
||||
input.targetStudentIds && input.targetStudentIds.length > 0
|
||||
? input.targetStudentIds
|
||||
: (
|
||||
await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.role, "student"))
|
||||
).map((r) => r.id)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(homeworkAssignments).values({
|
||||
id: assignmentId,
|
||||
sourceExamId: input.sourceExamId,
|
||||
title: input.title?.trim().length ? input.title.trim() : exam.title,
|
||||
description: input.description ?? null,
|
||||
structure: publish ? (exam.structure as unknown) : null,
|
||||
status: publish ? "published" : "draft",
|
||||
creatorId: user.id,
|
||||
availableAt,
|
||||
dueAt,
|
||||
allowLate: input.allowLate ?? false,
|
||||
lateDueAt,
|
||||
maxAttempts: input.maxAttempts ?? 1,
|
||||
})
|
||||
|
||||
if (publish && exam.questions.length > 0) {
|
||||
await tx.insert(homeworkAssignmentQuestions).values(
|
||||
exam.questions.map((q) => ({
|
||||
assignmentId,
|
||||
questionId: q.questionId,
|
||||
score: q.score ?? 0,
|
||||
order: q.order ?? 0,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (publish && targetStudentIds.length > 0) {
|
||||
await tx.insert(homeworkAssignmentTargets).values(
|
||||
targetStudentIds.map((studentId) => ({
|
||||
assignmentId,
|
||||
studentId,
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/homework/assignments")
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
|
||||
return { success: true, message: "Assignment created", data: assignmentId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function startHomeworkSubmissionAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureStudent()
|
||||
const assignmentId = formData.get("assignmentId")
|
||||
if (typeof assignmentId !== "string" || assignmentId.length === 0) return { success: false, message: "Missing assignmentId" }
|
||||
|
||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||
where: eq(homeworkAssignments.id, assignmentId),
|
||||
})
|
||||
if (!assignment) return { success: false, message: "Assignment not found" }
|
||||
if (assignment.status !== "published") return { success: false, message: "Assignment not available" }
|
||||
|
||||
const target = await db.query.homeworkAssignmentTargets.findFirst({
|
||||
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, user.id)),
|
||||
})
|
||||
if (!target) return { success: false, message: "Not assigned" }
|
||||
|
||||
if (assignment.availableAt && assignment.availableAt > new Date()) return { success: false, message: "Not available yet" }
|
||||
|
||||
const [attemptRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, user.id)))
|
||||
|
||||
const attemptNo = (attemptRow?.c ?? 0) + 1
|
||||
if (attemptNo > assignment.maxAttempts) return { success: false, message: "No attempts left" }
|
||||
|
||||
const submissionId = createId()
|
||||
await db.insert(homeworkSubmissions).values({
|
||||
id: submissionId,
|
||||
assignmentId,
|
||||
studentId: user.id,
|
||||
attemptNo,
|
||||
status: "started",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
|
||||
revalidatePath("/student/learning/assignments")
|
||||
|
||||
return { success: true, message: "Started", data: submissionId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHomeworkAnswerAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureStudent()
|
||||
const submissionId = formData.get("submissionId")
|
||||
const questionId = formData.get("questionId")
|
||||
const answerJson = formData.get("answerJson")
|
||||
if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
|
||||
if (typeof questionId !== "string" || questionId.length === 0) return { success: false, message: "Missing questionId" }
|
||||
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
with: { assignment: true },
|
||||
})
|
||||
if (!submission) return { success: false, message: "Submission not found" }
|
||||
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
|
||||
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
|
||||
|
||||
const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const existing = await tx.query.homeworkAnswers.findFirst({
|
||||
where: and(eq(homeworkAnswers.submissionId, submissionId), eq(homeworkAnswers.questionId, questionId)),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(homeworkAnswers)
|
||||
.set({ answerContent: payload, updatedAt: new Date() })
|
||||
.where(eq(homeworkAnswers.id, existing.id))
|
||||
} else {
|
||||
await tx.insert(homeworkAnswers).values({
|
||||
id: createId(),
|
||||
submissionId,
|
||||
questionId,
|
||||
answerContent: payload,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, message: "Saved", data: submissionId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitHomeworkAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureStudent()
|
||||
const submissionId = formData.get("submissionId")
|
||||
if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
|
||||
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
with: { assignment: true },
|
||||
})
|
||||
if (!submission) return { success: false, message: "Submission not found" }
|
||||
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
|
||||
if (submission.status !== "started") return { success: false, message: "Already submitted" }
|
||||
|
||||
const now = new Date()
|
||||
const dueAt = submission.assignment.dueAt
|
||||
const allowLate = submission.assignment.allowLate
|
||||
const lateDueAt = submission.assignment.lateDueAt
|
||||
|
||||
if (dueAt && now > dueAt && !allowLate) return { success: false, message: "Past due" }
|
||||
if (allowLate && lateDueAt && now > lateDueAt) return { success: false, message: "Past late due" }
|
||||
|
||||
const isLate = Boolean(dueAt && now > dueAt)
|
||||
|
||||
await db
|
||||
.update(homeworkSubmissions)
|
||||
.set({ status: "submitted", submittedAt: now, isLate, updatedAt: now })
|
||||
.where(eq(homeworkSubmissions.id, submissionId))
|
||||
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
revalidatePath("/student/learning/assignments")
|
||||
|
||||
return { success: true, message: "Submitted", data: submissionId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function gradeHomeworkSubmissionAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await ensureTeacher()
|
||||
|
||||
const rawAnswers = formData.get("answersJson") as string | null
|
||||
const parsed = GradeHomeworkSchema.safeParse({
|
||||
submissionId: formData.get("submissionId"),
|
||||
answers: rawAnswers ? JSON.parse(rawAnswers) : [],
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid grading data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { submissionId, answers } = parsed.data
|
||||
let totalScore = 0
|
||||
|
||||
for (const ans of answers) {
|
||||
await db
|
||||
.update(homeworkAnswers)
|
||||
.set({ score: ans.score, feedback: ans.feedback ?? null, updatedAt: new Date() })
|
||||
.where(eq(homeworkAnswers.id, ans.id))
|
||||
totalScore += ans.score
|
||||
}
|
||||
|
||||
await db
|
||||
.update(homeworkSubmissions)
|
||||
.set({ score: totalScore, status: "graded", updatedAt: new Date() })
|
||||
.where(eq(homeworkSubmissions.id, submissionId))
|
||||
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
|
||||
return { success: true, message: "Grading saved" }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
138
src/modules/homework/components/homework-assignment-form.tsx
Normal file
138
src/modules/homework/components/homework-assignment-form.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
|
||||
import { createHomeworkAssignmentAction } from "../actions"
|
||||
|
||||
type ExamOption = { id: string; title: string }
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create Assignment"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
|
||||
const router = useRouter()
|
||||
|
||||
const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams])
|
||||
const [examId, setExamId] = useState<string>(initialExamId)
|
||||
const [allowLate, setAllowLate] = useState<boolean>(false)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!examId) {
|
||||
toast.error("Please select an exam")
|
||||
return
|
||||
}
|
||||
formData.set("sourceExamId", examId)
|
||||
formData.set("allowLate", allowLate ? "true" : "false")
|
||||
formData.set("publish", "true")
|
||||
|
||||
const result = await createHomeworkAssignmentAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/homework/assignments")
|
||||
} else {
|
||||
toast.error(result.message || "Failed to create")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Assignment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Source Exam</Label>
|
||||
<Select value={examId} onValueChange={setExamId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an exam" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{exams.map((e) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="sourceExamId" value={examId} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="title">Assignment Title (optional)</Label>
|
||||
<Input id="title" name="title" placeholder="Defaults to exam title" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="description">Description (optional)</Label>
|
||||
<Textarea id="description" name="description" className="min-h-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="availableAt">Available At (optional)</Label>
|
||||
<Input id="availableAt" name="availableAt" type="datetime-local" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dueAt">Due At (optional)</Label>
|
||||
<Input id="dueAt" name="dueAt" type="datetime-local" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:col-span-2">
|
||||
<input
|
||||
id="allowLate"
|
||||
type="checkbox"
|
||||
checked={allowLate}
|
||||
onChange={(e) => setAllowLate(e.target.checked)}
|
||||
/>
|
||||
<Label htmlFor="allowLate">Allow late submissions</Label>
|
||||
<input type="hidden" name="allowLate" value={allowLate ? "true" : "false"} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="lateDueAt">Late Due At (optional)</Label>
|
||||
<Input id="lateDueAt" name="lateDueAt" type="datetime-local" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="maxAttempts">Max Attempts</Label>
|
||||
<Input id="maxAttempts" name="maxAttempts" type="number" min={1} max={20} defaultValue={1} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="targetStudentIdsText">Target student IDs (optional)</Label>
|
||||
<Textarea
|
||||
id="targetStudentIdsText"
|
||||
name="targetStudentIdsText"
|
||||
placeholder="Leave empty to assign to all students. You can paste IDs separated by comma or newline."
|
||||
className="min-h-[90px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardFooter className="justify-end">
|
||||
<SubmitButton />
|
||||
</CardFooter>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,71 +11,70 @@ import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { gradeSubmissionAction } from "../actions"
|
||||
import { gradeHomeworkSubmissionAction } from "../actions"
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
type QuestionContent = { text?: string } & Record<string, unknown>
|
||||
|
||||
type Answer = {
|
||||
id: string
|
||||
questionId: string
|
||||
questionContent: any
|
||||
questionContent: QuestionContent | null
|
||||
questionType: string
|
||||
maxScore: number
|
||||
studentAnswer: any
|
||||
studentAnswer: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
type GradingViewProps = {
|
||||
type HomeworkGradingViewProps = {
|
||||
submissionId: string
|
||||
studentName: string
|
||||
examTitle: string
|
||||
assignmentTitle: string
|
||||
submittedAt: string | null
|
||||
status: string
|
||||
totalScore: number | null
|
||||
answers: Answer[]
|
||||
}
|
||||
|
||||
export function GradingView({
|
||||
export function HomeworkGradingView({
|
||||
submissionId,
|
||||
studentName,
|
||||
examTitle,
|
||||
submittedAt,
|
||||
status,
|
||||
totalScore,
|
||||
answers: initialAnswers
|
||||
}: GradingViewProps) {
|
||||
answers: initialAnswers,
|
||||
}: HomeworkGradingViewProps) {
|
||||
const router = useRouter()
|
||||
const [answers, setAnswers] = useState(initialAnswers)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleScoreChange = (id: string, val: string) => {
|
||||
const score = val === "" ? 0 : parseInt(val)
|
||||
setAnswers(prev => prev.map(a => a.id === id ? { ...a, score } : a))
|
||||
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score } : a)))
|
||||
}
|
||||
|
||||
const handleFeedbackChange = (id: string, val: string) => {
|
||||
setAnswers(prev => prev.map(a => a.id === id ? { ...a, feedback: val } : a))
|
||||
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, feedback: val } : a)))
|
||||
}
|
||||
|
||||
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
const payload = answers.map(a => ({
|
||||
const payload = answers.map((a) => ({
|
||||
id: a.id,
|
||||
score: a.score || 0,
|
||||
feedback: a.feedback
|
||||
feedback: a.feedback,
|
||||
}))
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set("submissionId", submissionId)
|
||||
formData.set("answersJson", JSON.stringify(payload))
|
||||
|
||||
const result = await gradeSubmissionAction(null, formData)
|
||||
|
||||
const result = await gradeHomeworkSubmissionAction(null, formData)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Grading saved")
|
||||
router.push("/teacher/exams/grading")
|
||||
router.push("/teacher/homework/submissions")
|
||||
} else {
|
||||
toast.error(result.message || "Failed to save")
|
||||
}
|
||||
@@ -84,7 +83,6 @@ export function GradingView({
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left: Questions & Answers */}
|
||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Student Response</h3>
|
||||
@@ -97,7 +95,6 @@ export function GradingView({
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">Question {index + 1}</span>
|
||||
<div className="text-sm">{ans.questionContent?.text}</div>
|
||||
{/* Render options if multiple choice, etc. - Simplified for now */}
|
||||
</div>
|
||||
<Badge variant="outline">Max: {ans.maxScore}</Badge>
|
||||
</div>
|
||||
@@ -105,12 +102,12 @@ export function GradingView({
|
||||
<div className="rounded-md bg-muted/50 p-4">
|
||||
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{typeof ans.studentAnswer?.answer === 'string'
|
||||
? ans.studentAnswer.answer
|
||||
{isRecord(ans.studentAnswer) && typeof ans.studentAnswer.answer === "string"
|
||||
? ans.studentAnswer.answer
|
||||
: JSON.stringify(ans.studentAnswer)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
@@ -118,7 +115,6 @@ export function GradingView({
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right: Grading Panel */}
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Grading</h3>
|
||||
@@ -127,7 +123,7 @@ export function GradingView({
|
||||
<span className="font-bold text-lg text-primary">{currentTotal}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
{answers.map((ans, index) => (
|
||||
@@ -141,10 +137,10 @@ export function GradingView({
|
||||
<CardContent className="py-3 px-4 space-y-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`}>Score</Label>
|
||||
<Input
|
||||
<Input
|
||||
id={`score-${ans.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
type="number"
|
||||
min={0}
|
||||
max={ans.maxScore}
|
||||
value={ans.score ?? ""}
|
||||
onChange={(e) => handleScoreChange(ans.id, e.target.value)}
|
||||
@@ -152,7 +148,7 @@ export function GradingView({
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`fb-${ans.id}`}>Feedback</Label>
|
||||
<Textarea
|
||||
<Textarea
|
||||
id={`fb-${ans.id}`}
|
||||
placeholder="Optional feedback..."
|
||||
className="min-h-[60px] resize-none"
|
||||
345
src/modules/homework/components/homework-take-view.tsx
Normal file
345
src/modules/homework/components/homework-take-view.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
import type { StudentHomeworkTakeData } from "../types"
|
||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
type Option = { id: string; text: string }
|
||||
|
||||
const getQuestionText = (content: unknown): string => {
|
||||
if (!isRecord(content)) return ""
|
||||
return typeof content.text === "string" ? content.text : ""
|
||||
}
|
||||
|
||||
const getOptions = (content: unknown): Option[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: Option[] = []
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue
|
||||
const id = typeof item.id === "string" ? item.id : ""
|
||||
const text = typeof item.text === "string" ? item.text : ""
|
||||
if (!id || !text) continue
|
||||
out.push({ id, text })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const toAnswerShape = (questionType: string, v: unknown) => {
|
||||
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
|
||||
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
|
||||
return { answer: v }
|
||||
}
|
||||
|
||||
const parseSavedAnswer = (saved: unknown, questionType: string) => {
|
||||
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
|
||||
return toAnswerShape(questionType, saved)
|
||||
}
|
||||
|
||||
type HomeworkTakeViewProps = {
|
||||
assignmentId: string
|
||||
initialData: StudentHomeworkTakeData
|
||||
}
|
||||
|
||||
export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeViewProps) {
|
||||
const router = useRouter()
|
||||
const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null)
|
||||
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
||||
const initialAnswersByQuestionId = useMemo(() => {
|
||||
const map = new Map<string, { answer: unknown }>()
|
||||
for (const q of initialData.questions) {
|
||||
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
|
||||
}
|
||||
return map
|
||||
}, [initialData.questions])
|
||||
|
||||
const [answersByQuestionId, setAnswersByQuestionId] = useState(() => {
|
||||
const obj: Record<string, { answer: unknown }> = {}
|
||||
for (const [k, v] of initialAnswersByQuestionId.entries()) obj[k] = v
|
||||
return obj
|
||||
})
|
||||
|
||||
const isStarted = submissionStatus === "started"
|
||||
const canEdit = isStarted && Boolean(submissionId)
|
||||
|
||||
const handleStart = async () => {
|
||||
setIsBusy(true)
|
||||
const fd = new FormData()
|
||||
fd.set("assignmentId", assignmentId)
|
||||
const res = await startHomeworkSubmissionAction(null, fd)
|
||||
if (res.success && res.data) {
|
||||
setSubmissionId(res.data)
|
||||
setSubmissionStatus("started")
|
||||
toast.success("Started")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to start")
|
||||
}
|
||||
setIsBusy(false)
|
||||
}
|
||||
|
||||
const handleSaveQuestion = async (questionId: string) => {
|
||||
if (!submissionId) return
|
||||
setIsBusy(true)
|
||||
const payload = answersByQuestionId[questionId]?.answer ?? null
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("questionId", questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer: payload }))
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (res.success) toast.success("Saved")
|
||||
else toast.error(res.message || "Failed to save")
|
||||
setIsBusy(false)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!submissionId) return
|
||||
setIsBusy(true)
|
||||
for (const q of initialData.questions) {
|
||||
const payload = answersByQuestionId[q.questionId]?.answer ?? null
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("questionId", q.questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer: payload }))
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "Failed to save")
|
||||
setIsBusy(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const submitFd = new FormData()
|
||||
submitFd.set("submissionId", submissionId)
|
||||
const submitRes = await submitHomeworkAction(null, submitFd)
|
||||
if (submitRes.success) {
|
||||
toast.success("Submitted")
|
||||
setSubmissionStatus("submitted")
|
||||
router.push("/student/learning/assignments")
|
||||
} else {
|
||||
toast.error(submitRes.message || "Failed to submit")
|
||||
}
|
||||
setIsBusy(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">Questions</h3>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{submissionStatus === "not_started" ? "not started" : submissionStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
{!canEdit ? (
|
||||
<Button onClick={handleStart} disabled={isBusy}>
|
||||
{isBusy ? "Starting..." : "Start"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={isBusy}>
|
||||
{isBusy ? "Submitting..." : "Submit"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
{initialData.questions.map((q, idx) => {
|
||||
const text = getQuestionText(q.questionContent)
|
||||
const options = getOptions(q.questionContent)
|
||||
const value = answersByQuestionId[q.questionId]?.answer
|
||||
|
||||
return (
|
||||
<Card key={q.questionId} className="border-l-4 border-l-primary/20">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<span>
|
||||
Q{idx + 1} <span className="text-muted-foreground font-normal">({q.questionType})</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Max: {q.maxScore}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-3 px-4 space-y-4">
|
||||
<div className="text-sm">{text || "—"}</div>
|
||||
|
||||
{q.questionType === "text" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<Textarea
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
...prev,
|
||||
[q.questionId]: { answer: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="min-h-[100px]"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
) : q.questionType === "judgment" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<Select
|
||||
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||
onValueChange={(v) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
...prev,
|
||||
[q.questionId]: { answer: v === "true" },
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : q.questionType === "single_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(v) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
...prev,
|
||||
[q.questionId]: { answer: v },
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : q.questionType === "multiple_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<div className="space-y-2">
|
||||
{options.map((o) => {
|
||||
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||
return (
|
||||
<label key={o.id} className="flex items-start gap-3 rounded-md border p-3">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked === true
|
||||
setAnswersByQuestionId((prev) => {
|
||||
const current = Array.isArray(prev[q.questionId]?.answer)
|
||||
? (prev[q.questionId]?.answer as string[])
|
||||
: []
|
||||
const next = isChecked
|
||||
? Array.from(new Set([...current, o.id]))
|
||||
: current.filter((x) => x !== o.id)
|
||||
return { ...prev, [q.questionId]: { answer: next } }
|
||||
})
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<span className="text-sm">{o.text}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">Unsupported question type</div>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSaveQuestion(q.questionId)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Info</h3>
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Status</span>
|
||||
<span className="capitalize text-foreground">{submissionStatus === "not_started" ? "not started" : submissionStatus}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Questions</span>
|
||||
<span className="text-foreground tabular-nums">{initialData.questions.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="text-muted-foreground">{initialData.assignment.description || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t p-4 bg-muted/20">
|
||||
{canEdit ? (
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
|
||||
{isBusy ? "Submitting..." : "Submit"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={handleStart} disabled={isBusy}>
|
||||
{isBusy ? "Starting..." : "Start"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
334
src/modules/homework/data-access.ts
Normal file
334
src/modules/homework/data-access.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, count, desc, eq, inArray, isNull, lte, or } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
homeworkAnswers,
|
||||
homeworkAssignmentQuestions,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
import type {
|
||||
HomeworkAssignmentListItem,
|
||||
HomeworkQuestionContent,
|
||||
HomeworkAssignmentStatus,
|
||||
HomeworkSubmissionDetails,
|
||||
HomeworkSubmissionListItem,
|
||||
StudentHomeworkAssignmentListItem,
|
||||
StudentHomeworkProgressStatus,
|
||||
StudentHomeworkTakeData,
|
||||
} from "./types"
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
|
||||
if (!isRecord(v)) return null
|
||||
return v as HomeworkQuestionContent
|
||||
}
|
||||
|
||||
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[] }) => {
|
||||
const conditions = []
|
||||
|
||||
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
|
||||
if (params?.ids && params.ids.length > 0) conditions.push(inArray(homeworkAssignments.id, params.ids))
|
||||
|
||||
const data = await db.query.homeworkAssignments.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
with: {
|
||||
sourceExam: true,
|
||||
},
|
||||
})
|
||||
|
||||
return data.map((a) => {
|
||||
const item: HomeworkAssignmentListItem = {
|
||||
id: a.id,
|
||||
sourceExamId: a.sourceExamId,
|
||||
sourceExamTitle: a.sourceExam.title,
|
||||
title: a.title,
|
||||
status: (a.status as HomeworkAssignmentListItem["status"]) ?? "draft",
|
||||
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
allowLate: a.allowLate,
|
||||
lateDueAt: a.lateDueAt ? a.lateDueAt.toISOString() : null,
|
||||
maxAttempts: a.maxAttempts,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
updatedAt: a.updatedAt.toISOString(),
|
||||
}
|
||||
return item
|
||||
})
|
||||
})
|
||||
|
||||
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string }) => {
|
||||
const conditions = []
|
||||
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
|
||||
|
||||
const data = await db.query.homeworkSubmissions.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||
with: {
|
||||
assignment: true,
|
||||
student: true,
|
||||
},
|
||||
})
|
||||
|
||||
return data.map((s) => {
|
||||
const item: HomeworkSubmissionListItem = {
|
||||
id: s.id,
|
||||
assignmentId: s.assignmentId,
|
||||
assignmentTitle: s.assignment.title,
|
||||
studentName: s.student.name || "Unknown",
|
||||
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
|
||||
score: s.score ?? null,
|
||||
status: (s.status as HomeworkSubmissionListItem["status"]) ?? "started",
|
||||
isLate: s.isLate,
|
||||
}
|
||||
return item
|
||||
})
|
||||
})
|
||||
|
||||
export const getHomeworkAssignmentById = cache(async (id: string) => {
|
||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||
where: eq(homeworkAssignments.id, id),
|
||||
with: {
|
||||
sourceExam: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!assignment) return null
|
||||
|
||||
const [targetsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.assignmentId, id))
|
||||
|
||||
const [submissionsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(eq(homeworkSubmissions.assignmentId, id))
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
description: assignment.description,
|
||||
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
|
||||
sourceExamId: assignment.sourceExamId,
|
||||
sourceExamTitle: assignment.sourceExam.title,
|
||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||
allowLate: assignment.allowLate,
|
||||
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
||||
maxAttempts: assignment.maxAttempts,
|
||||
targetCount: targetsRow?.c ?? 0,
|
||||
submissionCount: submissionsRow?.c ?? 0,
|
||||
createdAt: assignment.createdAt.toISOString(),
|
||||
updatedAt: assignment.updatedAt.toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
with: {
|
||||
student: true,
|
||||
assignment: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!submission) return null
|
||||
|
||||
const answers = await db.query.homeworkAnswers.findMany({
|
||||
where: eq(homeworkAnswers.submissionId, submissionId),
|
||||
with: {
|
||||
question: true,
|
||||
},
|
||||
})
|
||||
|
||||
const assignmentQ = await db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
|
||||
orderBy: [desc(homeworkAssignmentQuestions.order)],
|
||||
})
|
||||
|
||||
const answersWithDetails = answers
|
||||
.map((ans) => {
|
||||
const aqRel = assignmentQ.find((q) => q.questionId === ans.questionId)
|
||||
return {
|
||||
id: ans.id,
|
||||
questionId: ans.questionId,
|
||||
questionContent: toQuestionContent(ans.question.content),
|
||||
questionType: ans.question.type,
|
||||
maxScore: aqRel?.score || 0,
|
||||
studentAnswer: ans.answerContent,
|
||||
score: ans.score,
|
||||
feedback: ans.feedback,
|
||||
order: aqRel?.order || 0,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
return {
|
||||
id: submission.id,
|
||||
assignmentId: submission.assignmentId,
|
||||
assignmentTitle: submission.assignment.title,
|
||||
studentName: submission.student.name || "Unknown",
|
||||
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
|
||||
status: submission.status as HomeworkSubmissionDetails["status"],
|
||||
totalScore: submission.score,
|
||||
answers: answersWithDetails,
|
||||
}
|
||||
})
|
||||
|
||||
export const getDemoStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => {
|
||||
const student = await db.query.users.findFirst({
|
||||
where: eq(users.role, "student"),
|
||||
orderBy: (u, { asc }) => [asc(u.createdAt)],
|
||||
})
|
||||
if (student) return { id: student.id, name: student.name || "Student" }
|
||||
|
||||
const anyUser = await db.query.users.findFirst({
|
||||
orderBy: (u, { asc }) => [asc(u.createdAt)],
|
||||
})
|
||||
if (!anyUser) return null
|
||||
return { id: anyUser.id, name: anyUser.name || "User" }
|
||||
})
|
||||
|
||||
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
|
||||
if (v === "started") return "in_progress"
|
||||
if (v === "submitted") return "submitted"
|
||||
if (v === "graded") return "graded"
|
||||
return "not_started"
|
||||
}
|
||||
|
||||
export const getStudentHomeworkAssignments = cache(async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
|
||||
const now = new Date()
|
||||
|
||||
const targetAssignmentIds = db
|
||||
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.studentId, studentId))
|
||||
|
||||
const assignments = await db.query.homeworkAssignments.findMany({
|
||||
where: and(
|
||||
eq(homeworkAssignments.status, "published"),
|
||||
inArray(homeworkAssignments.id, targetAssignmentIds),
|
||||
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
|
||||
),
|
||||
orderBy: [desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt)],
|
||||
})
|
||||
|
||||
if (assignments.length === 0) return []
|
||||
|
||||
const assignmentIds = assignments.map((a) => a.id)
|
||||
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
|
||||
orderBy: [desc(homeworkSubmissions.createdAt)],
|
||||
})
|
||||
|
||||
const attemptsByAssignmentId = new Map<string, number>()
|
||||
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
|
||||
|
||||
for (const s of submissions) {
|
||||
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
|
||||
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
|
||||
}
|
||||
|
||||
return assignments.map((a) => {
|
||||
const latest = latestByAssignmentId.get(a.id) ?? null
|
||||
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
|
||||
|
||||
const item: StudentHomeworkAssignmentListItem = {
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
|
||||
maxAttempts: a.maxAttempts,
|
||||
attemptsUsed,
|
||||
progressStatus: toStudentProgressStatus(latest?.status),
|
||||
latestSubmissionId: latest?.id ?? null,
|
||||
latestSubmittedAt: latest?.submittedAt ? latest.submittedAt.toISOString() : null,
|
||||
latestScore: latest?.score ?? null,
|
||||
}
|
||||
return item
|
||||
})
|
||||
})
|
||||
|
||||
export const getStudentHomeworkTakeData = cache(async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
|
||||
const target = await db.query.homeworkAssignmentTargets.findFirst({
|
||||
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, studentId)),
|
||||
})
|
||||
if (!target) return null
|
||||
|
||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||
where: eq(homeworkAssignments.id, assignmentId),
|
||||
})
|
||||
if (!assignment) return null
|
||||
if (assignment.status !== "published") return null
|
||||
|
||||
const now = new Date()
|
||||
if (assignment.availableAt && assignment.availableAt > now) return null
|
||||
|
||||
const startedSubmission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||
eq(homeworkSubmissions.studentId, studentId),
|
||||
eq(homeworkSubmissions.status, "started")
|
||||
),
|
||||
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
||||
})
|
||||
|
||||
const latestSubmission =
|
||||
startedSubmission ??
|
||||
(await db.query.homeworkSubmissions.findFirst({
|
||||
where: and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, studentId)),
|
||||
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
||||
}))
|
||||
|
||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||
with: { question: true },
|
||||
orderBy: (q, { asc }) => [asc(q.order)],
|
||||
})
|
||||
|
||||
const savedByQuestionId = new Map<string, unknown>()
|
||||
if (latestSubmission) {
|
||||
const answers = await db.query.homeworkAnswers.findMany({
|
||||
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
|
||||
})
|
||||
for (const ans of answers) savedByQuestionId.set(ans.questionId, ans.answerContent)
|
||||
}
|
||||
|
||||
return {
|
||||
assignment: {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
description: assignment.description,
|
||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||
allowLate: assignment.allowLate,
|
||||
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
||||
maxAttempts: assignment.maxAttempts,
|
||||
},
|
||||
submission: latestSubmission
|
||||
? {
|
||||
id: latestSubmission.id,
|
||||
status: (latestSubmission.status as NonNullable<StudentHomeworkTakeData["submission"]>["status"]) ?? "started",
|
||||
attemptNo: latestSubmission.attemptNo,
|
||||
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
|
||||
score: latestSubmission.score ?? null,
|
||||
}
|
||||
: null,
|
||||
questions: assignmentQuestions.map((aq) => ({
|
||||
questionId: aq.questionId,
|
||||
questionType: aq.question.type,
|
||||
questionContent: toQuestionContent(aq.question.content),
|
||||
maxScore: aq.score ?? 0,
|
||||
order: aq.order ?? 0,
|
||||
savedAnswer: savedByQuestionId.get(aq.questionId) ?? null,
|
||||
})),
|
||||
}
|
||||
})
|
||||
28
src/modules/homework/schema.ts
Normal file
28
src/modules/homework/schema.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const CreateHomeworkAssignmentSchema = z.object({
|
||||
sourceExamId: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
availableAt: z.string().optional(),
|
||||
dueAt: z.string().optional(),
|
||||
allowLate: z.coerce.boolean().optional(),
|
||||
lateDueAt: z.string().optional(),
|
||||
maxAttempts: z.coerce.number().int().min(1).max(20).optional(),
|
||||
targetStudentIds: z.array(z.string().min(1)).optional(),
|
||||
publish: z.coerce.boolean().optional(),
|
||||
})
|
||||
|
||||
export type CreateHomeworkAssignmentInput = z.infer<typeof CreateHomeworkAssignmentSchema>
|
||||
|
||||
export const GradeHomeworkSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
answers: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
score: z.coerce.number().min(0),
|
||||
feedback: z.string().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
99
src/modules/homework/types.ts
Normal file
99
src/modules/homework/types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type HomeworkAssignmentStatus = "draft" | "published" | "archived"
|
||||
|
||||
export type HomeworkSubmissionStatus = "started" | "submitted" | "graded"
|
||||
|
||||
export interface HomeworkAssignmentListItem {
|
||||
id: string
|
||||
sourceExamId: string
|
||||
sourceExamTitle: string
|
||||
title: string
|
||||
status: HomeworkAssignmentStatus
|
||||
availableAt: string | null
|
||||
dueAt: string | null
|
||||
allowLate: boolean
|
||||
lateDueAt: string | null
|
||||
maxAttempts: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface HomeworkSubmissionListItem {
|
||||
id: string
|
||||
assignmentId: string
|
||||
assignmentTitle: string
|
||||
studentName: string
|
||||
submittedAt: string | null
|
||||
score: number | null
|
||||
status: HomeworkSubmissionStatus
|
||||
isLate: boolean
|
||||
}
|
||||
|
||||
export type HomeworkQuestionContent = { text?: string } & Record<string, unknown>
|
||||
|
||||
export type HomeworkSubmissionAnswerDetails = {
|
||||
id: string
|
||||
questionId: string
|
||||
questionContent: HomeworkQuestionContent | null
|
||||
questionType: string
|
||||
maxScore: number
|
||||
studentAnswer: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
export type HomeworkSubmissionDetails = {
|
||||
id: string
|
||||
assignmentId: string
|
||||
assignmentTitle: string
|
||||
studentName: string
|
||||
submittedAt: string | null
|
||||
status: HomeworkSubmissionStatus
|
||||
totalScore: number | null
|
||||
answers: HomeworkSubmissionAnswerDetails[]
|
||||
}
|
||||
|
||||
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
|
||||
|
||||
export interface StudentHomeworkAssignmentListItem {
|
||||
id: string
|
||||
title: string
|
||||
dueAt: string | null
|
||||
availableAt: string | null
|
||||
maxAttempts: number
|
||||
attemptsUsed: number
|
||||
progressStatus: StudentHomeworkProgressStatus
|
||||
latestSubmissionId: string | null
|
||||
latestSubmittedAt: string | null
|
||||
latestScore: number | null
|
||||
}
|
||||
|
||||
export type StudentHomeworkTakeQuestion = {
|
||||
questionId: string
|
||||
questionType: string
|
||||
questionContent: HomeworkQuestionContent | null
|
||||
maxScore: number
|
||||
order: number
|
||||
savedAnswer: unknown
|
||||
}
|
||||
|
||||
export type StudentHomeworkTakeData = {
|
||||
assignment: {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
availableAt: string | null
|
||||
dueAt: string | null
|
||||
allowLate: boolean
|
||||
lateDueAt: string | null
|
||||
maxAttempts: number
|
||||
}
|
||||
submission: {
|
||||
id: string
|
||||
status: HomeworkSubmissionStatus
|
||||
attemptNo: number
|
||||
submittedAt: string | null
|
||||
score: number | null
|
||||
} | null
|
||||
questions: StudentHomeworkTakeQuestion[]
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
BarChart,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Users,
|
||||
@@ -15,10 +14,11 @@ import {
|
||||
Library,
|
||||
PenTool
|
||||
} from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
export type NavItem = {
|
||||
title: string
|
||||
icon: any
|
||||
icon: LucideIcon
|
||||
href: string
|
||||
items?: { title: string; href: string }[]
|
||||
}
|
||||
@@ -96,7 +96,6 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
items: [
|
||||
{ title: "All Exams", href: "/teacher/exams/all" },
|
||||
{ title: "Create Exam", href: "/teacher/exams/create" },
|
||||
{ title: "Grading", href: "/teacher/exams/grading" },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
||||
import { CreateQuestionInput, CreateQuestionSchema } from "./schema";
|
||||
import { CreateQuestionSchema } from "./schema";
|
||||
import type { CreateQuestionInput } from "./schema";
|
||||
import { ActionState } from "@/shared/types/action-state";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ZodError } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
// --- Mock Auth Helper (Replace with actual Auth.js call) ---
|
||||
async function getCurrentUser() {
|
||||
// In production: const session = await auth(); return session?.user;
|
||||
// Mocking a teacher user for this demonstration
|
||||
return {
|
||||
id: "user_teacher_123",
|
||||
role: "teacher", // or "admin"
|
||||
role: "teacher",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,15 +25,14 @@ async function ensureTeacher() {
|
||||
return user;
|
||||
}
|
||||
|
||||
// --- Recursive Insert Helper ---
|
||||
// We pass 'tx' to ensure all operations run within the same transaction
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
async function insertQuestionWithRelations(
|
||||
tx: any, // using any or strict Drizzle Transaction type if imported
|
||||
input: CreateQuestionInput,
|
||||
tx: Tx,
|
||||
input: z.infer<typeof CreateQuestionSchema>,
|
||||
authorId: string,
|
||||
parentId: string | null = null
|
||||
) {
|
||||
// We generate ID explicitly here.
|
||||
const newQuestionId = createId();
|
||||
|
||||
await tx.insert(questions).values({
|
||||
@@ -47,7 +44,6 @@ async function insertQuestionWithRelations(
|
||||
parentId: parentId,
|
||||
});
|
||||
|
||||
// 2. Link Knowledge Points
|
||||
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
|
||||
await tx.insert(questionsToKnowledgePoints).values(
|
||||
input.knowledgePointIds.map((kpId) => ({
|
||||
@@ -57,7 +53,6 @@ async function insertQuestionWithRelations(
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Handle Sub-Questions (Recursion)
|
||||
if (input.subQuestions && input.subQuestions.length > 0) {
|
||||
for (const subQ of input.subQuestions) {
|
||||
await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId);
|
||||
@@ -67,28 +62,19 @@ async function insertQuestionWithRelations(
|
||||
return newQuestionId;
|
||||
}
|
||||
|
||||
// --- Main Server Action ---
|
||||
|
||||
export async function createNestedQuestion(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData | CreateQuestionInput // Support both FormData and JSON input
|
||||
formData: FormData | CreateQuestionInput
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
// 1. Auth Check
|
||||
const user = await ensureTeacher();
|
||||
|
||||
// 2. Parse Input
|
||||
// If formData is actual FormData, we need to convert it.
|
||||
// For complex nested structures, frontend usually sends JSON string or pure JSON object if using `useServerAction` with arguments.
|
||||
// Here we assume the client might send a raw object (if using direct function call) or we parse FormData.
|
||||
let rawInput: any = formData;
|
||||
let rawInput: unknown = formData;
|
||||
|
||||
if (formData instanceof FormData) {
|
||||
// Parsing complex nested JSON from FormData is messy.
|
||||
// We assume one field 'data' contains the JSON, or we expect direct object usage (common in modern Next.js RPC).
|
||||
const jsonString = formData.get("json");
|
||||
if (typeof jsonString === "string") {
|
||||
rawInput = JSON.parse(jsonString);
|
||||
rawInput = JSON.parse(jsonString) as unknown;
|
||||
} else {
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." };
|
||||
}
|
||||
@@ -106,13 +92,11 @@ export async function createNestedQuestion(
|
||||
|
||||
const input = validatedFields.data;
|
||||
|
||||
// 3. Database Transaction
|
||||
await db.transaction(async (tx) => {
|
||||
await insertQuestionWithRelations(tx, input, user.id, null);
|
||||
});
|
||||
|
||||
// 4. Revalidate Cache
|
||||
revalidatePath("/questions");
|
||||
revalidatePath("/teacher/questions");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -122,10 +106,7 @@ export async function createNestedQuestion(
|
||||
} catch (error) {
|
||||
console.error("Failed to create question:", error);
|
||||
|
||||
// Drizzle/DB Error Handling (Generic)
|
||||
if (error instanceof Error) {
|
||||
// Check for specific DB errors (constraints, etc.)
|
||||
// e.g., if (error.message.includes("Duplicate entry")) ...
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Database error occurred",
|
||||
@@ -138,3 +119,122 @@ export async function createNestedQuestion(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const UpdateQuestionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]),
|
||||
difficulty: z.number().min(1).max(5),
|
||||
content: z.any(),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export async function updateQuestionAction(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureTeacher();
|
||||
const canEditAll = user.role === "admin";
|
||||
|
||||
const jsonString = formData.get("json");
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." };
|
||||
}
|
||||
|
||||
const parsed = UpdateQuestionSchema.safeParse(JSON.parse(jsonString));
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(questions)
|
||||
.set({
|
||||
type: input.type,
|
||||
difficulty: input.difficulty,
|
||||
content: input.content,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(canEditAll ? eq(questions.id, input.id) : and(eq(questions.id, input.id), eq(questions.authorId, user.id)));
|
||||
|
||||
await tx
|
||||
.delete(questionsToKnowledgePoints)
|
||||
.where(eq(questionsToKnowledgePoints.questionId, input.id));
|
||||
|
||||
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
|
||||
await tx.insert(questionsToKnowledgePoints).values(
|
||||
input.knowledgePointIds.map((kpId) => ({
|
||||
questionId: input.id,
|
||||
knowledgePointId: kpId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/teacher/questions");
|
||||
|
||||
return { success: true, message: "Question updated successfully" };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "An unexpected error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteQuestionRecursive(tx: Tx, questionId: string) {
|
||||
const children = await tx
|
||||
.select({ id: questions.id })
|
||||
.from(questions)
|
||||
.where(eq(questions.parentId, questionId));
|
||||
|
||||
for (const child of children) {
|
||||
await deleteQuestionRecursive(tx, child.id);
|
||||
}
|
||||
|
||||
await tx.delete(questions).where(eq(questions.id, questionId));
|
||||
}
|
||||
|
||||
export async function deleteQuestionAction(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureTeacher();
|
||||
const canEditAll = user.role === "admin";
|
||||
|
||||
const id = formData.get("id");
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
return { success: false, message: "Missing question id" };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [owned] = await tx
|
||||
.select({ id: questions.id })
|
||||
.from(questions)
|
||||
.where(canEditAll ? eq(questions.id, id) : and(eq(questions.id, id), eq(questions.authorId, user.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!owned) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await deleteQuestionRecursive(tx, id);
|
||||
});
|
||||
|
||||
revalidatePath("/teacher/questions");
|
||||
|
||||
return { success: true, message: "Question deleted successfully" };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "An unexpected error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useForm, type SubmitHandler } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -34,19 +36,22 @@ import {
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { BaseQuestionSchema } from "../schema"
|
||||
import { createNestedQuestion } from "../actions"
|
||||
import { createNestedQuestion, updateQuestionAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { Question } from "../types"
|
||||
|
||||
// Extend schema for form usage (e.g. handling options for choice questions)
|
||||
const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||
difficulty: z.number().min(1).max(5),
|
||||
content: z.string().min(1, "Question content is required"),
|
||||
options: z.array(z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
isCorrect: z.boolean().default(false)
|
||||
})).optional(),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
isCorrect: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
type QuestionFormValues = z.input<typeof QuestionFormSchema>
|
||||
@@ -57,7 +62,43 @@ interface CreateQuestionDialogProps {
|
||||
initialData?: Question | null
|
||||
}
|
||||
|
||||
function getInitialTextFromContent(content: unknown) {
|
||||
if (typeof content === "string") return content
|
||||
if (content && typeof content === "object") {
|
||||
const text = (content as { text?: unknown }).text
|
||||
if (typeof text === "string") return text
|
||||
}
|
||||
if (content == null) return ""
|
||||
return JSON.stringify(content)
|
||||
}
|
||||
|
||||
function getInitialOptionsFromContent(content: unknown) {
|
||||
if (!content || typeof content !== "object") return undefined
|
||||
const rawOptions = (content as { options?: unknown }).options
|
||||
if (!Array.isArray(rawOptions)) return undefined
|
||||
|
||||
const mapped = rawOptions
|
||||
.map((opt) => {
|
||||
if (!opt || typeof opt !== "object") return null
|
||||
const id =
|
||||
(opt as { id?: unknown; value?: unknown }).id ?? (opt as { value?: unknown }).value
|
||||
const text =
|
||||
(opt as { text?: unknown; label?: unknown }).text ??
|
||||
(opt as { label?: unknown }).label
|
||||
const isCorrect = (opt as { isCorrect?: unknown }).isCorrect
|
||||
return {
|
||||
value: typeof id === "string" ? id : "",
|
||||
label: typeof text === "string" ? text : "",
|
||||
isCorrect: typeof isCorrect === "boolean" ? isCorrect : false,
|
||||
}
|
||||
})
|
||||
.filter((v): v is NonNullable<typeof v> => Boolean(v && v.value && v.label))
|
||||
|
||||
return mapped.length > 0 ? mapped : undefined
|
||||
}
|
||||
|
||||
export function CreateQuestionDialog({ open, onOpenChange, initialData }: CreateQuestionDialogProps) {
|
||||
const router = useRouter()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const isEdit = !!initialData
|
||||
|
||||
@@ -66,63 +107,101 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
||||
defaultValues: {
|
||||
type: initialData?.type || "single_choice",
|
||||
difficulty: initialData?.difficulty || 1,
|
||||
content: (typeof initialData?.content === 'string' ? initialData.content : "") || "",
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
],
|
||||
content: getInitialTextFromContent(initialData?.content),
|
||||
options:
|
||||
getInitialOptionsFromContent(initialData?.content) ?? [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Reset form when initialData changes
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
form.reset({
|
||||
type: initialData.type,
|
||||
difficulty: initialData.difficulty,
|
||||
content: typeof initialData.content === 'string' ? initialData.content : JSON.stringify(initialData.content),
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
]
|
||||
})
|
||||
form.reset({
|
||||
type: initialData.type,
|
||||
difficulty: initialData.difficulty,
|
||||
content: getInitialTextFromContent(initialData.content),
|
||||
options:
|
||||
getInitialOptionsFromContent(initialData.content) ?? [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
],
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
type: "single_choice",
|
||||
difficulty: 1,
|
||||
content: "",
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
]
|
||||
})
|
||||
form.reset({
|
||||
type: "single_choice",
|
||||
difficulty: 1,
|
||||
content: "",
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
],
|
||||
})
|
||||
}
|
||||
}, [initialData, form])
|
||||
}, [initialData, form, open])
|
||||
|
||||
const questionType = form.watch("type")
|
||||
|
||||
const buildContent = (data: QuestionFormValues) => {
|
||||
const text = data.content.trim()
|
||||
if (data.type === "single_choice" || data.type === "multiple_choice") {
|
||||
const rawOptions = (data.options ?? []).filter((o) => o.label.trim().length > 0)
|
||||
const base = rawOptions.map((o) => ({
|
||||
id: o.value,
|
||||
text: o.label.trim(),
|
||||
isCorrect: o.isCorrect,
|
||||
}))
|
||||
|
||||
if (base.length === 0) return { text }
|
||||
|
||||
if (data.type === "single_choice") {
|
||||
let selectedIndex = base.findIndex((o) => o.isCorrect)
|
||||
if (selectedIndex === -1) selectedIndex = 0
|
||||
return {
|
||||
text,
|
||||
options: base.map((o, idx) => ({ ...o, isCorrect: idx === selectedIndex })),
|
||||
}
|
||||
}
|
||||
|
||||
const hasCorrect = base.some((o) => o.isCorrect)
|
||||
const options = hasCorrect ? base : [{ ...base[0], isCorrect: true }, ...base.slice(1)]
|
||||
return { text, options }
|
||||
}
|
||||
|
||||
return { text }
|
||||
}
|
||||
|
||||
const onSubmit: SubmitHandler<QuestionFormValues> = async (data) => {
|
||||
setIsPending(true)
|
||||
try {
|
||||
if (isEdit && !initialData?.id) {
|
||||
toast.error("Missing question id")
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
...(isEdit && initialData ? { id: initialData.id } : {}),
|
||||
type: data.type,
|
||||
difficulty: data.difficulty,
|
||||
content: data.content,
|
||||
content: buildContent(data),
|
||||
knowledgePointIds: [],
|
||||
}
|
||||
const fd = new FormData()
|
||||
fd.set("json", JSON.stringify(payload))
|
||||
const res = await createNestedQuestion(undefined, fd)
|
||||
const res = isEdit
|
||||
? await updateQuestionAction(undefined, fd)
|
||||
: await createNestedQuestion(undefined, fd)
|
||||
if (res.success) {
|
||||
toast.success(isEdit ? "Updated question" : "Created question")
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
if (!isEdit) {
|
||||
form.reset()
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message || "Operation failed")
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error("Unexpected error")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
@@ -148,7 +227,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Question Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
@@ -159,6 +238,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -173,8 +253,8 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
||||
<FormItem>
|
||||
<FormLabel>Difficulty (1-5)</FormLabel>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||
defaultValue={String(field.value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -219,53 +299,76 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
||||
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Options</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const currentOptions = form.getValues("options") || [];
|
||||
form.setValue("options", [
|
||||
...currentOptions,
|
||||
{ label: `Option ${String.fromCharCode(65 + currentOptions.length)}`, value: String.fromCharCode(65 + currentOptions.length), isCorrect: false }
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3 w-3" /> Add Option
|
||||
</Button>
|
||||
<FormLabel>Options</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const currentOptions = form.getValues("options") || []
|
||||
const nextIndex = currentOptions.length
|
||||
const nextChar = nextIndex < 26 ? String.fromCharCode(65 + nextIndex) : String(nextIndex + 1)
|
||||
form.setValue("options", [
|
||||
...currentOptions,
|
||||
{
|
||||
label: `Option ${nextChar}`,
|
||||
value: nextChar,
|
||||
isCorrect: false,
|
||||
},
|
||||
])
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3 w-3" /> Add Option
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{form.watch("options")?.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...(form.getValues("options") || [])];
|
||||
newOptions[index].label = e.target.value;
|
||||
form.setValue("options", newOptions);
|
||||
}}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
onClick={() => {
|
||||
const newOptions = [...(form.getValues("options") || [])];
|
||||
newOptions.splice(index, 1);
|
||||
form.setValue("options", newOptions);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{form.watch("options")?.map((option, index) => (
|
||||
<div key={option.value || index} className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={option.isCorrect}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = [...(form.getValues("options") || [])]
|
||||
if (!next[index]) return
|
||||
|
||||
const isChecked = checked === true
|
||||
if (questionType === "single_choice" && isChecked) {
|
||||
for (let i = 0; i < next.length; i++) next[i].isCorrect = i === index
|
||||
} else {
|
||||
next[index].isCorrect = isChecked
|
||||
}
|
||||
form.setValue("options", next)
|
||||
}}
|
||||
aria-label="Mark correct"
|
||||
/>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => {
|
||||
const next = [...(form.getValues("options") || [])]
|
||||
if (!next[index]) return
|
||||
next[index].label = e.target.value
|
||||
form.setValue("options", next)
|
||||
}}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
onClick={() => {
|
||||
const next = [...(form.getValues("options") || [])]
|
||||
next.splice(index, 1)
|
||||
form.setValue("options", next)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -275,7 +378,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Creating..." : "Create Question"}
|
||||
{isPending ? (isEdit ? "Updating..." : "Creating...") : (isEdit ? "Update Question" : "Create Question")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import { Question } from "../types"
|
||||
import { deleteQuestionAction } from "../actions"
|
||||
import { CreateQuestionDialog } from "./create-question-dialog"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -39,6 +41,7 @@ interface QuestionActionsProps {
|
||||
}
|
||||
|
||||
export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
const router = useRouter()
|
||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
@@ -52,13 +55,17 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
// Simulate API call
|
||||
console.log("Deleting question:", question.id)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
toast.success("Question deleted successfully")
|
||||
setShowDeleteDialog(false)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const fd = new FormData()
|
||||
fd.set("id", question.id)
|
||||
const res = await deleteQuestionAction(undefined, fd)
|
||||
if (res.success) {
|
||||
toast.success("Question deleted successfully")
|
||||
setShowDeleteDialog(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete question")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete question")
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
@@ -95,14 +102,12 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<CreateQuestionDialog
|
||||
open={showEditDialog}
|
||||
onOpenChange={setShowEditDialog}
|
||||
initialData={question}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -128,7 +133,6 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* View Details Dialog (Simple Read-only View) */}
|
||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -138,7 +142,7 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Type:</span>
|
||||
<span className="col-span-3 capitalize">{question.type.replace('_', ' ')}</span>
|
||||
<span className="col-span-3 capitalize">{question.type.replaceAll("_", " ")}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Difficulty:</span>
|
||||
@@ -147,17 +151,17 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<span className="font-medium pt-1">Content:</span>
|
||||
<div className="col-span-3 rounded-md bg-muted p-2 text-sm">
|
||||
{typeof question.content === 'string' ? question.content : JSON.stringify(question.content, null, 2)}
|
||||
{typeof question.content === "string"
|
||||
? question.content
|
||||
: JSON.stringify(question.content, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Show Author if exists */}
|
||||
{question.author && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Author:</span>
|
||||
<span className="col-span-3">{question.author.name || "Unknown"}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Show Knowledge Points */}
|
||||
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Tags:</span>
|
||||
|
||||
@@ -5,34 +5,38 @@ import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Question, QuestionType } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { QuestionActions } from "./question-actions"
|
||||
|
||||
// Helper for Type Colors
|
||||
const getTypeColor = (type: QuestionType) => {
|
||||
switch (type) {
|
||||
case "single_choice":
|
||||
return "default"; // Primary
|
||||
return "default"
|
||||
case "multiple_choice":
|
||||
return "secondary";
|
||||
return "secondary"
|
||||
case "judgment":
|
||||
return "outline";
|
||||
return "outline"
|
||||
case "text":
|
||||
return "secondary"; // Changed from 'accent' which might not be a valid badge variant key in standard shadcn, usually it's default, secondary, destructive, outline.
|
||||
return "secondary"
|
||||
default:
|
||||
return "secondary";
|
||||
return "secondary"
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: QuestionType) => {
|
||||
switch (type) {
|
||||
case "single_choice": return "Single Choice";
|
||||
case "multiple_choice": return "Multiple Choice";
|
||||
case "judgment": return "True/False";
|
||||
case "text": return "Short Answer";
|
||||
case "composite": return "Composite";
|
||||
default: return type;
|
||||
}
|
||||
switch (type) {
|
||||
case "single_choice":
|
||||
return "Single Choice"
|
||||
case "multiple_choice":
|
||||
return "Multiple Choice"
|
||||
case "judgment":
|
||||
return "True/False"
|
||||
case "text":
|
||||
return "Short Answer"
|
||||
case "composite":
|
||||
return "Composite"
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<Question>[] = [
|
||||
@@ -71,14 +75,20 @@ export const columns: ColumnDef<Question>[] = [
|
||||
accessorKey: "content",
|
||||
header: "Content",
|
||||
cell: ({ row }) => {
|
||||
const content = row.getValue("content");
|
||||
let preview = "";
|
||||
if (typeof content === 'string') {
|
||||
preview = content;
|
||||
} else if (content && typeof content === 'object') {
|
||||
preview = JSON.stringify(content).slice(0, 50);
|
||||
const content = row.getValue("content") as unknown
|
||||
let preview = ""
|
||||
if (typeof content === "string") {
|
||||
preview = content
|
||||
} else if (content && typeof content === "object") {
|
||||
const text = (content as { text?: unknown }).text
|
||||
if (typeof text === "string") {
|
||||
preview = text
|
||||
} else {
|
||||
preview = JSON.stringify(content)
|
||||
}
|
||||
}
|
||||
|
||||
preview = preview.slice(0, 80)
|
||||
|
||||
return (
|
||||
<div className="max-w-[400px] truncate font-medium" title={preview}>
|
||||
{preview}
|
||||
@@ -90,17 +100,23 @@ export const columns: ColumnDef<Question>[] = [
|
||||
accessorKey: "difficulty",
|
||||
header: "Difficulty",
|
||||
cell: ({ row }) => {
|
||||
const diff = row.getValue("difficulty") as number;
|
||||
// 1-5 scale
|
||||
const diff = row.getValue("difficulty") as number
|
||||
const label =
|
||||
diff === 1
|
||||
? "Easy"
|
||||
: diff === 2
|
||||
? "Easy-Med"
|
||||
: diff === 3
|
||||
? "Medium"
|
||||
: diff === 4
|
||||
? "Med-Hard"
|
||||
: "Hard"
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span className={cn("font-medium",
|
||||
diff <= 2 ? "text-green-600" :
|
||||
diff === 3 ? "text-yellow-600" : "text-red-600"
|
||||
)}>
|
||||
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">({diff})</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -109,22 +125,24 @@ export const columns: ColumnDef<Question>[] = [
|
||||
accessorKey: "knowledgePoints",
|
||||
header: "Knowledge Points",
|
||||
cell: ({ row }) => {
|
||||
const kps = row.original.knowledgePoints;
|
||||
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{kps.slice(0, 2).map(kp => (
|
||||
<Badge key={kp.id} variant="outline" className="text-xs">
|
||||
{kp.name}
|
||||
</Badge>
|
||||
))}
|
||||
{kps.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{kps.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const kps = row.original.knowledgePoints
|
||||
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{kps.slice(0, 2).map((kp) => (
|
||||
<Badge key={kp.id} variant="outline" className="text-xs">
|
||||
{kp.name}
|
||||
</Badge>
|
||||
))}
|
||||
{kps.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{kps.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
@@ -132,7 +150,7 @@ export const columns: ColumnDef<Question>[] = [
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{new Date(row.getValue("createdAt")).toLocaleDateString()}
|
||||
{new Date(row.getValue("createdAt")).toLocaleDateString()}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -18,10 +18,6 @@ export function QuestionFilters() {
|
||||
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
|
||||
|
||||
// Debounce could be added here for search, but for simplicity we rely on 'Enter' or blur or just let it update (nuqs handles some updates well, but for text input usually we want debounce or on change).
|
||||
// Actually nuqs with shallow: false (default) triggers server re-render.
|
||||
// For text input, it's better to use local state and update URL on debounce or enter.
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
@@ -31,7 +27,7 @@ export function QuestionFilters() {
|
||||
placeholder="Search questions..."
|
||||
className="pl-8"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
|
||||
@@ -44,6 +40,7 @@ export function QuestionFilters() {
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||
@@ -61,8 +58,8 @@ export function QuestionFilters() {
|
||||
</Select>
|
||||
|
||||
{(search || type !== "all" || difficulty !== "all") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setType(null)
|
||||
|
||||
@@ -2,35 +2,52 @@ import 'server-only';
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
||||
import { and, eq, inArray, count, desc, sql } from "drizzle-orm";
|
||||
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
||||
import { cache } from "react";
|
||||
import type { Question, QuestionType } from "./types";
|
||||
|
||||
// Types for filters
|
||||
export type GetQuestionsParams = {
|
||||
q?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
ids?: string[];
|
||||
knowledgePointId?: string;
|
||||
type?: QuestionType;
|
||||
difficulty?: number;
|
||||
};
|
||||
|
||||
// Cached Data Access Function
|
||||
// Using React's cache() to deduplicate requests if called multiple times in one render pass
|
||||
export const getQuestions = cache(async ({
|
||||
q,
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
pageSize = 50,
|
||||
ids,
|
||||
knowledgePointId,
|
||||
type,
|
||||
difficulty,
|
||||
}: GetQuestionsParams = {}) => {
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// Build Where Conditions
|
||||
const conditions = [];
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
conditions.push(inArray(questions.id, ids));
|
||||
}
|
||||
|
||||
if (q && q.trim().length > 0) {
|
||||
const needle = `%${q.trim().toLowerCase()}%`;
|
||||
conditions.push(
|
||||
sql`LOWER(CAST(${questions.content} AS CHAR)) LIKE ${needle}`
|
||||
);
|
||||
}
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(questions.type, type));
|
||||
}
|
||||
|
||||
if (difficulty) {
|
||||
conditions.push(eq(questions.difficulty, difficulty));
|
||||
}
|
||||
|
||||
// Filter by Knowledge Point (using subquery pattern for Many-to-Many)
|
||||
if (knowledgePointId) {
|
||||
const subQuery = db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
@@ -40,35 +57,30 @@ export const getQuestions = cache(async ({
|
||||
conditions.push(inArray(questions.id, subQuery));
|
||||
}
|
||||
|
||||
// Only fetch top-level questions (parent questions)
|
||||
// Assuming we only want to list "root" questions, not sub-questions
|
||||
conditions.push(sql`${questions.parentId} IS NULL`);
|
||||
if (!ids || ids.length === 0) {
|
||||
conditions.push(sql`${questions.parentId} IS NULL`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// 1. Get Total Count (for Pagination)
|
||||
// Optimization: separate count query is often faster than fetching all data
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(questions)
|
||||
.where(whereClause);
|
||||
|
||||
const total = totalResult?.count ?? 0;
|
||||
const total = Number(totalResult?.count ?? 0);
|
||||
|
||||
// 2. Get Data with Relations
|
||||
const data = await db.query.questions.findMany({
|
||||
const rows = await db.query.questions.findMany({
|
||||
where: whereClause,
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
orderBy: [desc(questions.createdAt)],
|
||||
with: {
|
||||
// Preload Knowledge Points
|
||||
questionsToKnowledgePoints: {
|
||||
with: {
|
||||
knowledgePoint: true,
|
||||
},
|
||||
},
|
||||
// Preload Author
|
||||
author: {
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -76,13 +88,37 @@ export const getQuestions = cache(async ({
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
// Preload Child Questions (first level)
|
||||
children: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
data: rows.map((row) => {
|
||||
const knowledgePoints =
|
||||
row.questionsToKnowledgePoints?.map((rel) => rel.knowledgePoint) ?? [];
|
||||
|
||||
const author = row.author
|
||||
? {
|
||||
id: row.author.id,
|
||||
name: row.author.name,
|
||||
image: row.author.image,
|
||||
}
|
||||
: null;
|
||||
|
||||
const mapped: Question = {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
type: row.type,
|
||||
difficulty: row.difficulty ?? 1,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
author,
|
||||
knowledgePoints: knowledgePoints.map((kp) => ({ id: kp.id, name: kp.name })),
|
||||
childrenCount: row.children?.length ?? 0,
|
||||
};
|
||||
|
||||
return mapped;
|
||||
}),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod"
|
||||
|
||||
// Enum for Question Types matching DB
|
||||
export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]);
|
||||
export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"])
|
||||
|
||||
// Base Question Schema
|
||||
export const BaseQuestionSchema = z.object({
|
||||
content: z.any().describe("JSON content for the question (e.g. Slate nodes)"), // Using any for JSON flexibility, strict schemas can be applied if structure is known
|
||||
content: z.any().describe("JSON content for the question (e.g. Slate nodes)"),
|
||||
type: QuestionTypeEnum,
|
||||
difficulty: z.number().min(1).max(5).default(1),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
});
|
||||
})
|
||||
|
||||
// Recursive Schema for Nested Questions (e.g. Composite -> Sub Questions)
|
||||
export type CreateQuestionInput = z.infer<typeof BaseQuestionSchema> & {
|
||||
subQuestions?: CreateQuestionInput[];
|
||||
};
|
||||
subQuestions?: CreateQuestionInput[]
|
||||
}
|
||||
|
||||
export const CreateQuestionSchema: z.ZodType<CreateQuestionInput> = BaseQuestionSchema.extend({
|
||||
subQuestions: z.lazy(() => CreateQuestionSchema.array().optional()),
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { QuestionTypeEnum } from "./schema";
|
||||
import { z } from "zod"
|
||||
import { QuestionTypeEnum } from "./schema"
|
||||
|
||||
// Infer types from Zod Schema
|
||||
export type QuestionType = z.infer<typeof QuestionTypeEnum>;
|
||||
export type QuestionType = z.infer<typeof QuestionTypeEnum>
|
||||
|
||||
// UI Model for Question (matching the structure returned by data-access or mock)
|
||||
export interface Question {
|
||||
id: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
content: any; // Rich text content
|
||||
type: QuestionType;
|
||||
difficulty: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
id: string
|
||||
content: unknown
|
||||
type: QuestionType
|
||||
difficulty: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
author: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
id: string
|
||||
name: string | null
|
||||
image: string | null
|
||||
} | null
|
||||
knowledgePoints: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
// For UI display
|
||||
childrenCount?: number;
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
childrenCount?: number
|
||||
}
|
||||
|
||||
@@ -40,8 +40,7 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setIsEditing(false)
|
||||
// Update local state to reflect change immediately (optimistic-like)
|
||||
selectedChapter.content = editContent
|
||||
setSelectedChapter((prev) => (prev ? { ...prev, content: editContent } : prev))
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function TextbookFormDialog() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Textbook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new digital textbook. Click save when you're done.
|
||||
Create a new digital textbook. Click save when you're done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit}>
|
||||
|
||||
@@ -65,7 +65,7 @@ let MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [
|
||||
|
||||
export async function getTextbooks(query?: string, subject?: string, grade?: string): Promise<Textbook[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
let results = [...MOCK_TEXTBOOKS];
|
||||
const results = [...MOCK_TEXTBOOKS];
|
||||
// ... (filtering logic)
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
TTransformedValues = any
|
||||
TTransformedValues = unknown
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName, TTransformedValues>) => {
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
exams,
|
||||
examQuestions,
|
||||
examSubmissions,
|
||||
submissionAnswers
|
||||
submissionAnswers,
|
||||
homeworkAssignments,
|
||||
homeworkAssignmentQuestions,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkSubmissions,
|
||||
homeworkAnswers
|
||||
} from "./schema";
|
||||
|
||||
// --- Users & Roles Relations ---
|
||||
@@ -23,7 +28,9 @@ export const usersRelations = relations(users, ({ many }) => ({
|
||||
sessions: many(sessions),
|
||||
usersToRoles: many(usersToRoles),
|
||||
createdExams: many(exams),
|
||||
createdHomeworkAssignments: many(homeworkAssignments),
|
||||
submissions: many(examSubmissions),
|
||||
homeworkSubmissions: many(homeworkSubmissions),
|
||||
authoredQuestions: many(questions),
|
||||
}));
|
||||
|
||||
@@ -169,3 +176,62 @@ export const submissionAnswersRelations = relations(submissionAnswers, ({ one })
|
||||
references: [questions.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const homeworkAssignmentsRelations = relations(homeworkAssignments, ({ one, many }) => ({
|
||||
creator: one(users, {
|
||||
fields: [homeworkAssignments.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
sourceExam: one(exams, {
|
||||
fields: [homeworkAssignments.sourceExamId],
|
||||
references: [exams.id],
|
||||
}),
|
||||
questions: many(homeworkAssignmentQuestions),
|
||||
targets: many(homeworkAssignmentTargets),
|
||||
submissions: many(homeworkSubmissions),
|
||||
}));
|
||||
|
||||
export const homeworkAssignmentQuestionsRelations = relations(homeworkAssignmentQuestions, ({ one }) => ({
|
||||
assignment: one(homeworkAssignments, {
|
||||
fields: [homeworkAssignmentQuestions.assignmentId],
|
||||
references: [homeworkAssignments.id],
|
||||
}),
|
||||
question: one(questions, {
|
||||
fields: [homeworkAssignmentQuestions.questionId],
|
||||
references: [questions.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const homeworkAssignmentTargetsRelations = relations(homeworkAssignmentTargets, ({ one }) => ({
|
||||
assignment: one(homeworkAssignments, {
|
||||
fields: [homeworkAssignmentTargets.assignmentId],
|
||||
references: [homeworkAssignments.id],
|
||||
}),
|
||||
student: one(users, {
|
||||
fields: [homeworkAssignmentTargets.studentId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const homeworkSubmissionsRelations = relations(homeworkSubmissions, ({ one, many }) => ({
|
||||
assignment: one(homeworkAssignments, {
|
||||
fields: [homeworkSubmissions.assignmentId],
|
||||
references: [homeworkAssignments.id],
|
||||
}),
|
||||
student: one(users, {
|
||||
fields: [homeworkSubmissions.studentId],
|
||||
references: [users.id],
|
||||
}),
|
||||
answers: many(homeworkAnswers),
|
||||
}));
|
||||
|
||||
export const homeworkAnswersRelations = relations(homeworkAnswers, ({ one }) => ({
|
||||
submission: one(homeworkSubmissions, {
|
||||
fields: [homeworkAnswers.submissionId],
|
||||
references: [homeworkSubmissions.id],
|
||||
}),
|
||||
question: one(questions, {
|
||||
fields: [homeworkAnswers.questionId],
|
||||
references: [questions.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -269,6 +269,127 @@ export const submissionAnswers = mysqlTable("submission_answers", {
|
||||
submissionIdx: index("submission_idx").on(table.submissionId),
|
||||
}));
|
||||
|
||||
export const homeworkAssignments = mysqlTable("homework_assignments", {
|
||||
id: id("id").primaryKey(),
|
||||
sourceExamId: varchar("source_exam_id", { length: 128 }).notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
structure: json("structure"),
|
||||
status: varchar("status", { length: 50 }).default("draft"),
|
||||
creatorId: varchar("creator_id", { length: 128 }).notNull(),
|
||||
availableAt: timestamp("available_at"),
|
||||
dueAt: timestamp("due_at"),
|
||||
allowLate: boolean("allow_late").default(false).notNull(),
|
||||
lateDueAt: timestamp("late_due_at"),
|
||||
maxAttempts: int("max_attempts").default(1).notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
creatorIdx: index("hw_assignment_creator_idx").on(table.creatorId),
|
||||
sourceExamIdx: index("hw_assignment_source_exam_idx").on(table.sourceExamId),
|
||||
statusIdx: index("hw_assignment_status_idx").on(table.status),
|
||||
sourceExamFk: foreignKey({
|
||||
columns: [table.sourceExamId],
|
||||
foreignColumns: [exams.id],
|
||||
name: "hw_asg_exam_fk",
|
||||
}).onDelete("cascade"),
|
||||
creatorFk: foreignKey({
|
||||
columns: [table.creatorId],
|
||||
foreignColumns: [users.id],
|
||||
name: "hw_asg_creator_fk",
|
||||
}).onDelete("cascade"),
|
||||
}));
|
||||
|
||||
export const homeworkAssignmentQuestions = mysqlTable("homework_assignment_questions", {
|
||||
assignmentId: varchar("assignment_id", { length: 128 }).notNull(),
|
||||
questionId: varchar("question_id", { length: 128 }).notNull(),
|
||||
score: int("score").default(0),
|
||||
order: int("order").default(0),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.assignmentId, table.questionId] }),
|
||||
assignmentIdx: index("hw_assignment_questions_assignment_idx").on(table.assignmentId),
|
||||
assignmentFk: foreignKey({
|
||||
columns: [table.assignmentId],
|
||||
foreignColumns: [homeworkAssignments.id],
|
||||
name: "hw_aq_a_fk",
|
||||
}).onDelete("cascade"),
|
||||
questionFk: foreignKey({
|
||||
columns: [table.questionId],
|
||||
foreignColumns: [questions.id],
|
||||
name: "hw_aq_q_fk",
|
||||
}).onDelete("cascade"),
|
||||
}));
|
||||
|
||||
export const homeworkAssignmentTargets = mysqlTable("homework_assignment_targets", {
|
||||
assignmentId: varchar("assignment_id", { length: 128 }).notNull(),
|
||||
studentId: varchar("student_id", { length: 128 }).notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.assignmentId, table.studentId] }),
|
||||
assignmentIdx: index("hw_assignment_targets_assignment_idx").on(table.assignmentId),
|
||||
studentIdx: index("hw_assignment_targets_student_idx").on(table.studentId),
|
||||
assignmentFk: foreignKey({
|
||||
columns: [table.assignmentId],
|
||||
foreignColumns: [homeworkAssignments.id],
|
||||
name: "hw_at_a_fk",
|
||||
}).onDelete("cascade"),
|
||||
studentFk: foreignKey({
|
||||
columns: [table.studentId],
|
||||
foreignColumns: [users.id],
|
||||
name: "hw_at_s_fk",
|
||||
}).onDelete("cascade"),
|
||||
}));
|
||||
|
||||
export const homeworkSubmissions = mysqlTable("homework_submissions", {
|
||||
id: id("id").primaryKey(),
|
||||
assignmentId: varchar("assignment_id", { length: 128 }).notNull(),
|
||||
studentId: varchar("student_id", { length: 128 }).notNull(),
|
||||
attemptNo: int("attempt_no").default(1).notNull(),
|
||||
score: int("score"),
|
||||
status: varchar("status", { length: 50 }).default("started"),
|
||||
startedAt: timestamp("started_at").defaultNow().notNull(),
|
||||
submittedAt: timestamp("submitted_at"),
|
||||
isLate: boolean("is_late").default(false).notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
assignmentStudentIdx: index("hw_assignment_student_idx").on(table.assignmentId, table.studentId),
|
||||
assignmentFk: foreignKey({
|
||||
columns: [table.assignmentId],
|
||||
foreignColumns: [homeworkAssignments.id],
|
||||
name: "hw_sub_a_fk",
|
||||
}).onDelete("cascade"),
|
||||
studentFk: foreignKey({
|
||||
columns: [table.studentId],
|
||||
foreignColumns: [users.id],
|
||||
name: "hw_sub_student_fk",
|
||||
}).onDelete("cascade"),
|
||||
}));
|
||||
|
||||
export const homeworkAnswers = mysqlTable("homework_answers", {
|
||||
id: id("id").primaryKey(),
|
||||
submissionId: varchar("submission_id", { length: 128 }).notNull(),
|
||||
questionId: varchar("question_id", { length: 128 }).notNull(),
|
||||
answerContent: json("answer_content"),
|
||||
score: int("score"),
|
||||
feedback: text("feedback"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
submissionIdx: index("hw_answer_submission_idx").on(table.submissionId),
|
||||
submissionQuestionIdx: index("hw_answer_submission_question_idx").on(table.submissionId, table.questionId),
|
||||
submissionFk: foreignKey({
|
||||
columns: [table.submissionId],
|
||||
foreignColumns: [homeworkSubmissions.id],
|
||||
name: "hw_ans_sub_fk",
|
||||
}).onDelete("cascade"),
|
||||
questionFk: foreignKey({
|
||||
columns: [table.questionId],
|
||||
foreignColumns: [questions.id],
|
||||
name: "hw_ans_q_fk",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Re-export old courses table if needed or deprecate it.
|
||||
// Assuming we are replacing the old simple schema with this robust one.
|
||||
// But if there were existing tables, we might keep them or comment them out.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import tailwindcssAnimate from "tailwindcss-animate";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: "class",
|
||||
@@ -70,7 +71,7 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
plugins: [tailwindcssAnimate],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Reference in New Issue
Block a user