Merge exams grading into homework
Redirect /teacher/exams/grading* to /teacher/homework/submissions; remove exam grading UI/actions/data-access; add homework student workflow and update design docs.
This commit is contained in:
@@ -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,15 +1,17 @@
|
||||
# 考试模块实现设计文档
|
||||
|
||||
## 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),用于渲染分组与排序;而 `exam_questions` 仍然承担题目关联、外键完整性与索引查询职责。
|
||||
@@ -46,14 +48,9 @@ type ExamNode = {
|
||||
- 可搜索/筛选的可用题目列表。
|
||||
- “添加”操作将节点追加到结构树中。
|
||||
|
||||
### 3.2 阅卷界面
|
||||
位于 `/teacher/exams/grading/[submissionId]`。
|
||||
|
||||
- **`GradingView` (客户端组件)**
|
||||
- **左侧面板**: 只读视图,显示学生的答案与题目内容。
|
||||
- **右侧面板**: 评分和反馈的输入字段。
|
||||
- **状态**: 在提交前管理本地更改。
|
||||
- **Actions**: `gradeSubmissionAction` 更新 `submissionAnswers` 并将总分聚合到 `examSubmissions`。
|
||||
### 3.2 阅卷界面(已下线)
|
||||
原阅卷路由 `/teacher/exams/grading` 与 `/teacher/exams/grading/[submissionId]` 已移除业务能力并重定向到 Homework:
|
||||
- `/teacher/exams/grading*` → `/teacher/homework/submissions`
|
||||
|
||||
### 3.3 列表页(All Exams)
|
||||
位于 `/teacher/exams/all`。
|
||||
@@ -74,13 +71,10 @@ 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` 的表格行级菜单。
|
||||
@@ -118,14 +112,13 @@ type ExamNode = {
|
||||
- 面向未来(现代 React Hooks 模式)。
|
||||
|
||||
### 5.3 Server Actions
|
||||
所有变更操作(保存草稿、发布、复制、删除、评分)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。
|
||||
所有变更操作(保存草稿、发布、复制、删除)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。
|
||||
|
||||
已落地的 Server Actions:
|
||||
- `createExamAction`
|
||||
- `updateExamAction`
|
||||
- `duplicateExamAction`
|
||||
- `deleteExamAction`
|
||||
- `gradeSubmissionAction`
|
||||
|
||||
## 6. 接口与数据影响
|
||||
|
||||
@@ -153,11 +146,16 @@ type ExamNode = {
|
||||
- 依赖外键级联清理关联数据:`exam_questions`、`exam_submissions`、`submission_answers`
|
||||
- **缓存**:
|
||||
- `revalidatePath("/teacher/exams/all")`
|
||||
- `revalidatePath("/teacher/exams/grading")`
|
||||
|
||||
### 6.4 数据访问层(Data Access)
|
||||
位于 `src/modules/exams/data-access.ts`,对外提供与页面/组件解耦的查询函数。
|
||||
|
||||
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
|
||||
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
|
||||
- `getExamSubmissions()`: 为阅卷列表提供 submissions 数据
|
||||
|
||||
## 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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"docs/scripts/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,9 +57,6 @@ async function ExamsResults({ searchParams }: { searchParams: Promise<SearchPara
|
||||
<Badge variant="outline">Archived {counts.archived}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/exams/grading">Go to Grading</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/teacher/exams/create" className="inline-flex items-center gap-2">
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
@@ -206,7 +206,6 @@ export async function deleteExamAction(
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
revalidatePath("/teacher/exams/grading")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -310,76 +309,6 @@ export async function duplicateExamAction(
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}))
|
||||
})
|
||||
|
||||
export async function gradeSubmissionAction(
|
||||
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) : []
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid grading data",
|
||||
errors: parsed.error.flatten().fieldErrors
|
||||
}
|
||||
}
|
||||
|
||||
const { submissionId, answers } = 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))
|
||||
|
||||
} catch (error) {
|
||||
console.error("Grading failed:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error during grading"
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/teacher/exams/grading`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Grading saved successfully"
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentUser() {
|
||||
return { id: "user_teacher_123", role: "teacher" }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
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"
|
||||
|
||||
@@ -6,14 +6,12 @@ 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"
|
||||
|
||||
@@ -1,62 +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} 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,5 +1,5 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
|
||||
import { exams } from "@/shared/db/schema"
|
||||
import { eq, desc, like, and, or } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
|
||||
@@ -137,82 +137,3 @@ export const getExamById = cache(async (id: string) => {
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
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)],
|
||||
})
|
||||
|
||||
type QuestionContent = { text?: string } & Record<string, unknown>
|
||||
|
||||
const toQuestionContent = (v: unknown): QuestionContent | null => {
|
||||
if (!isRecord(v)) return null
|
||||
return v as QuestionContent
|
||||
}
|
||||
|
||||
// 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: toQuestionContent(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
|
||||
}
|
||||
})
|
||||
|
||||
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,7 +11,7 @@ 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
|
||||
|
||||
@@ -29,57 +29,52 @@ type Answer = {
|
||||
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")
|
||||
}
|
||||
@@ -88,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>
|
||||
@@ -101,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>
|
||||
@@ -114,7 +107,7 @@ export function GradingView({
|
||||
: JSON.stringify(ans.studentAnswer)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
@@ -122,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>
|
||||
@@ -131,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) => (
|
||||
@@ -145,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)}
|
||||
@@ -156,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[]
|
||||
}
|
||||
@@ -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" },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user