diff --git a/docs/design/005_exam_module_implementation.md b/docs/design/005_exam_module_implementation.md index a6c0f68..bbde1b0 100644 --- a/docs/design/005_exam_module_implementation.md +++ b/docs/design/005_exam_module_implementation.md @@ -12,7 +12,7 @@ - **SubmissionAnswers**: 链接到特定题目的单个答案。 ### 2.2 `structure` 字段 -为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`。这作为考试布局的“单一事实来源”(Source of Truth)。 +为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`。它作为“布局/呈现层”的单一事实来源(Source of Truth),用于渲染分组与排序;而 `exam_questions` 仍然承担题目关联、外键完整性与索引查询职责。 **JSON Schema:** ```typescript @@ -26,6 +26,9 @@ type ExamNode = { } ``` +### 2.3 `description` 元数据字段(当前实现) +当前版本将部分元数据(如 `subject/grade/difficulty/totalScore/durationMin/tags/scheduledAt`)以 JSON 字符串形式存入 `exams.description`,并在数据访问层解析后提供给列表页展示与筛选。 + ## 3. 组件架构 ### 3.1 组卷(构建器) @@ -52,6 +55,13 @@ type ExamNode = { - **状态**: 在提交前管理本地更改。 - **Actions**: `gradeSubmissionAction` 更新 `submissionAnswers` 并将总分聚合到 `examSubmissions`。 +### 3.3 列表页(All Exams) +位于 `/teacher/exams/all`。 + +- **Page (RSC)**: 负责解析 query(`q/status/difficulty`)并调用数据访问层获取 exams。 +- **`ExamFilters` (客户端组件)**: 使用 URL query 驱动筛选条件。 +- **`ExamDataTable` (客户端组件)**: 基于 TanStack Table 渲染列表,并在 actions 列中渲染 `ExamActions`。 + ## 4. 关键工作流 ### 4.1 创建与构建考试 @@ -72,6 +82,27 @@ type ExamNode = { - 教师输入分数(上限为满分)和反馈。 4. **提交**: 服务器更新单个答案记录并重新计算提交总分。 +### 4.3 考试管理(All Exams Actions) +位于 `/teacher/exams/all` 的表格行级菜单。 + +1. **Publish / Move to Draft / Archive** + - 客户端组件 `ExamActions` 触发 `updateExamAction`,传入 `examId` 与目标 `status`。 + - 服务器更新 `exams.status`,并对 `/teacher/exams/all` 执行缓存再验证。 + +2. **Duplicate** + - 客户端组件 `ExamActions` 触发 `duplicateExamAction`,传入 `examId`。 + - 服务器复制 `exams` 记录并复制关联的 `exam_questions`。 + - 新考试以 `draft` 状态创建,复制结构(`exams.structure`),并清空排期信息(`startTime/endTime`,以及 description 中的 `scheduledAt`)。 + - 成功后跳转到新考试的构建页 `/teacher/exams/[id]/build`。 + +3. **Delete** + - 客户端组件 `ExamActions` 触发 `deleteExamAction`,传入 `examId`。 + - 服务器删除 `exams` 记录;相关表(如 `exam_questions`、`exam_submissions`、`submission_answers`)通过外键级联删除。 + - 成功后刷新列表。 + +4. **Edit / Build** + - 当前统一跳转到 `/teacher/exams/[id]/build`。 + ## 5. 技术决策 ### 5.1 混合存储策略 @@ -87,4 +118,46 @@ type ExamNode = { - 面向未来(现代 React Hooks 模式)。 ### 5.3 Server Actions -所有变更操作(保存草稿、发布、评分)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。 +所有变更操作(保存草稿、发布、复制、删除、评分)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。 + +已落地的 Server Actions: +- `createExamAction` +- `updateExamAction` +- `duplicateExamAction` +- `deleteExamAction` +- `gradeSubmissionAction` + +## 6. 接口与数据影响 + +### 6.1 `updateExamAction` +- **入参(FormData)**: `examId`(必填),`status`(可选:draft/published/archived),`questionsJson`(可选),`structureJson`(可选) +- **行为**: + - 若传入 `questionsJson`:先清空 `exam_questions` 再批量写入,`order` 由数组顺序决定;未传入则不触碰 `exam_questions` + - 若传入 `structureJson`:写入 `exams.structure`;未传入则不更新该字段 + - 若传入 `status`:写入 `exams.status` +- **缓存**: `revalidatePath("/teacher/exams/all")` + +### 6.2 `duplicateExamAction` +- **入参(FormData)**: `examId`(必填) +- **行为**: + - 复制一条 `exams`(新 id、新 title:追加 “(Copy)”、`status` 强制为 `draft`) + - `startTime/endTime` 置空;同时尝试从 `description` JSON 中移除 `scheduledAt` + - 复制 `exam_questions`(保留 questionId/score/order) + - 复制 `exams.structure` +- **缓存**: `revalidatePath("/teacher/exams/all")` + +### 6.3 `deleteExamAction` +- **入参(FormData)**: `examId`(必填) +- **行为**: + - 删除 `exams` 记录 + - 依赖外键级联清理关联数据:`exam_questions`、`exam_submissions`、`submission_answers` +- **缓存**: + - `revalidatePath("/teacher/exams/all")` + - `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 数据 diff --git a/docs/scripts/reset-db.ts b/docs/scripts/reset-db.ts index b574a56..19067eb 100644 --- a/docs/scripts/reset-db.ts +++ b/docs/scripts/reset-db.ts @@ -16,8 +16,18 @@ async function reset() { `) // Drop each table - for (const row of (tables[0] as unknown as any[])) { - const tableName = row.TABLE_NAME || row.table_name + const rows = (tables as unknown as [unknown])[0] + if (!Array.isArray(rows)) return + + for (const row of rows) { + const record = row as Record + const tableName = + typeof record.TABLE_NAME === "string" + ? record.TABLE_NAME + : typeof record.table_name === "string" + ? record.table_name + : null + if (!tableName) continue console.log(`Dropping table: ${tableName}`) await db.execute(sql.raw(`DROP TABLE IF EXISTS \`${tableName}\`;`)) } diff --git a/docs/scripts/seed-exams.ts b/docs/scripts/seed-exams.ts index dd2e018..ce0718e 100644 --- a/docs/scripts/seed-exams.ts +++ b/docs/scripts/seed-exams.ts @@ -80,7 +80,12 @@ async function seed() { const qId = createId() questionIds.push(qId) - const type = faker.helpers.arrayElement(["single_choice", "multiple_choice", "text", "judgment"]) + const type = faker.helpers.arrayElement([ + "single_choice", + "multiple_choice", + "text", + "judgment", + ] as const) await db.insert(questions).values({ id: qId, @@ -93,7 +98,7 @@ async function seed() { { id: "D", text: faker.lorem.sentence(), isCorrect: false }, ] : undefined }, - type: type as any, + type, difficulty: faker.helpers.arrayElement(DIFFICULTY), authorId: teacherId, }) @@ -105,7 +110,7 @@ async function seed() { const examId = createId() const subject = faker.helpers.arrayElement(SUBJECTS) const grade = faker.helpers.arrayElement(GRADES) - const status = faker.helpers.arrayElement(["draft", "published", "archived"]) + const status = faker.helpers.arrayElement(["draft", "published", "archived"] as const) const scheduledAt = faker.date.soon({ days: 30 }) @@ -126,7 +131,7 @@ async function seed() { description: JSON.stringify(meta), creatorId: teacherId, startTime: scheduledAt, - status: status as any, + status, }) // Link some questions to this exam (random 5 questions) diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..585f758 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,11 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + rules: { + "react-hooks/incompatible-library": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/scripts/seed.ts b/scripts/seed.ts index 2ec67a9..80607e5 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -211,7 +211,7 @@ async function seed() { creatorId: "user_teacher_math", status: "published", startTime: new Date(), - structure: examStructure as any // Bypass strict typing for seed + structure: examStructure as unknown }); // Link questions physically (Source of Truth) diff --git a/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx index bae3f5c..cfb273d 100644 --- a/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx +++ b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx @@ -16,38 +16,93 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id: // In a real app, this might be paginated or filtered by exam subject/grade const { data: questionsData } = await getQuestions({ pageSize: 100 }) - const questionOptions: Question[] = questionsData.map((q) => ({ - id: q.id, - content: q.content as any, - type: q.type as any, - difficulty: q.difficulty ?? 1, - createdAt: new Date(q.createdAt), - updatedAt: new Date(q.updatedAt), - author: q.author ? { - id: q.author.id, - name: q.author.name || "Unknown", - image: q.author.image || null - } : null, - knowledgePoints: (q.questionsToKnowledgePoints || []).map((kp) => ({ - id: kp.knowledgePoint.id, - name: kp.knowledgePoint.name - })) - })) - const initialSelected = (exam.questions || []).map(q => ({ id: q.id, score: q.score || 0 })) - // Prepare initialStructure on server side to avoid hydration mismatch with random IDs - let initialStructure: ExamNode[] = exam.structure as ExamNode[] || [] + const selectedQuestionIds = initialSelected.map((s) => s.id) + const { data: selectedQuestionsData } = selectedQuestionIds.length + ? await getQuestions({ ids: selectedQuestionIds, pageSize: Math.max(10, selectedQuestionIds.length) }) + : { data: [] as typeof questionsData } + + type RawQuestion = (typeof questionsData)[number] + + const toQuestionOption = (q: RawQuestion): Question => ({ + id: q.id, + content: q.content as Question["content"], + type: q.type as Question["type"], + difficulty: q.difficulty ?? 1, + createdAt: new Date(q.createdAt), + updatedAt: new Date(q.updatedAt), + author: q.author + ? { + id: q.author.id, + name: q.author.name || "Unknown", + image: q.author.image || null, + } + : null, + knowledgePoints: + q.questionsToKnowledgePoints?.map((kp) => ({ + id: kp.knowledgePoint.id, + name: kp.knowledgePoint.name, + })) ?? [], + }) + + const questionOptionsById = new Map() + for (const q of questionsData) questionOptionsById.set(q.id, toQuestionOption(q)) + for (const q of selectedQuestionsData) questionOptionsById.set(q.id, toQuestionOption(q)) + const questionOptions = Array.from(questionOptionsById.values()) + + const normalizeStructure = (nodes: unknown): ExamNode[] => { + const seen = new Set() + const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null + + const normalize = (raw: unknown[]): ExamNode[] => { + return raw + .map((n) => { + if (!isRecord(n)) return null + const type = n.type + if (type !== "group" && type !== "question") return null + + let id = typeof n.id === "string" && n.id.length > 0 ? n.id : createId() + while (seen.has(id)) id = createId() + seen.add(id) + + if (type === "group") { + return { + id, + type: "group", + title: typeof n.title === "string" ? n.title : undefined, + children: normalize(Array.isArray(n.children) ? n.children : []), + } satisfies ExamNode + } + + if (typeof n.questionId !== "string" || n.questionId.length === 0) return null + + return { + id, + type: "question", + questionId: n.questionId, + score: typeof n.score === "number" ? n.score : undefined, + } satisfies ExamNode + }) + .filter(Boolean) as ExamNode[] + } + + if (!Array.isArray(nodes)) return [] + return normalize(nodes) + } + + let initialStructure: ExamNode[] = normalizeStructure(exam.structure) if (initialStructure.length === 0 && initialSelected.length > 0) { - initialStructure = initialSelected.map(s => ({ - id: createId(), // Generate stable ID on server - type: 'question', + initialStructure = initialSelected.map((s) => ({ + id: createId(), + type: "question", questionId: s.id, - score: s.score + score: s.score, })) } diff --git a/src/app/(dashboard)/teacher/exams/all/page.tsx b/src/app/(dashboard)/teacher/exams/all/page.tsx index 7410a8d..2001c7c 100644 --- a/src/app/(dashboard)/teacher/exams/all/page.tsx +++ b/src/app/(dashboard)/teacher/exams/all/page.tsx @@ -1,24 +1,137 @@ import { Suspense } from "react" import Link from "next/link" import { Button } from "@/shared/components/ui/button" +import { Badge } from "@/shared/components/ui/badge" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Skeleton } from "@/shared/components/ui/skeleton" import { ExamDataTable } from "@/modules/exams/components/exam-data-table" import { examColumns } from "@/modules/exams/components/exam-columns" import { ExamFilters } from "@/modules/exams/components/exam-filters" import { getExams } from "@/modules/exams/data-access" +import { FileText, PlusCircle } from "lucide-react" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +async function ExamsResults({ searchParams }: { searchParams: Promise }) { + const params = await searchParams + + const q = getParam(params, "q") + const status = getParam(params, "status") + const difficulty = getParam(params, "difficulty") + + const exams = await getExams({ + q, + status, + difficulty, + }) + + const hasFilters = Boolean(q || (status && status !== "all") || (difficulty && difficulty !== "all")) + + const counts = exams.reduce( + (acc, e) => { + acc.total += 1 + if (e.status === "draft") acc.draft += 1 + if (e.status === "published") acc.published += 1 + if (e.status === "archived") acc.archived += 1 + return acc + }, + { total: 0, draft: 0, published: 0, archived: 0 } + ) + + return ( +
+
+
+ Showing + {counts.total} + exams + + Draft {counts.draft} + + Published {counts.published} + Archived {counts.archived} +
+
+ + +
+
+ + {exams.length === 0 ? ( + + ) : ( + + )} +
+ ) +} + +function ExamsResultsFallback() { + return ( +
+
+
+ + + + +
+
+ + +
+
+
+
+ +
+
+ {Array.from({ length: 6 }).map((_, idx) => ( + + ))} +
+
+
+ ) +} export default async function AllExamsPage({ searchParams, }: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }> + searchParams: Promise }) { - const params = await searchParams - - const exams = await getExams({ - q: params.q as string, - status: params.status as string, - difficulty: params.difficulty as string, - }) - return (
@@ -26,21 +139,16 @@ export default async function AllExamsPage({

All Exams

View and manage all your exams.

-
- -
- }> + }> -
- -
+ }> + +
) diff --git a/src/modules/auth/components/login-form.tsx b/src/modules/auth/components/login-form.tsx index 456e5a6..c50aab9 100644 --- a/src/modules/auth/components/login-form.tsx +++ b/src/modules/auth/components/login-form.tsx @@ -8,7 +8,7 @@ import { Label } from "@/shared/components/ui/label" import { cn } from "@/shared/lib/utils" import { Loader2, Github } from "lucide-react" -interface LoginFormProps extends React.HTMLAttributes {} +type LoginFormProps = React.HTMLAttributes export function LoginForm({ className, ...props }: LoginFormProps) { const [isLoading, setIsLoading] = React.useState(false) diff --git a/src/modules/auth/components/register-form.tsx b/src/modules/auth/components/register-form.tsx index d8e2abd..21e14d5 100644 --- a/src/modules/auth/components/register-form.tsx +++ b/src/modules/auth/components/register-form.tsx @@ -8,7 +8,7 @@ import { Label } from "@/shared/components/ui/label" import { cn } from "@/shared/lib/utils" import { Loader2, Github } from "lucide-react" -interface RegisterFormProps extends React.HTMLAttributes {} +type RegisterFormProps = React.HTMLAttributes export function RegisterForm({ className, ...props }: RegisterFormProps) { const [isLoading, setIsLoading] = React.useState(false) diff --git a/src/modules/dashboard/components/teacher-schedule.tsx b/src/modules/dashboard/components/teacher-schedule.tsx index 81fd94f..dc03c6e 100644 --- a/src/modules/dashboard/components/teacher-schedule.tsx +++ b/src/modules/dashboard/components/teacher-schedule.tsx @@ -40,9 +40,9 @@ export function TeacherSchedule() { const hasSchedule = MOCK_SCHEDULE.length > 0; return ( - + - Today's Schedule + Today's Schedule {!hasSchedule ? ( diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index 99473fb..5af7b4e 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -101,8 +101,8 @@ const ExamUpdateSchema = z.object({ score: z.coerce.number().int().min(0), }) ) - .default([]), - structure: z.any().optional(), // Accept structure JSON + .optional(), + structure: z.unknown().optional(), status: z.enum(["draft", "published", "archived"]).optional(), }) @@ -110,13 +110,15 @@ export async function updateExamAction( prevState: ActionState | null, formData: FormData ): Promise> { - const rawQuestions = formData.get("questionsJson") as string | null - const rawStructure = formData.get("structureJson") as string | null + const rawQuestions = formData.get("questionsJson") + const rawStructure = formData.get("structureJson") + const hasQuestions = typeof rawQuestions === "string" + const hasStructure = typeof rawStructure === "string" const parsed = ExamUpdateSchema.safeParse({ examId: formData.get("examId"), - questions: rawQuestions ? JSON.parse(rawQuestions) : [], - structure: rawStructure ? JSON.parse(rawStructure) : undefined, + questions: hasQuestions ? JSON.parse(rawQuestions) : undefined, + structure: hasStructure ? JSON.parse(rawStructure) : undefined, status: formData.get("status") ?? undefined, }) @@ -131,22 +133,24 @@ export async function updateExamAction( const { examId, questions, structure, status } = parsed.data try { - await db.delete(examQuestions).where(eq(examQuestions.examId, examId)) - if (questions.length > 0) { - await db.insert(examQuestions).values( - questions.map((q, idx) => ({ - examId, - questionId: q.id, - score: q.score ?? 0, - order: idx, - })) - ) + if (questions) { + await db.delete(examQuestions).where(eq(examQuestions.examId, examId)) + if (questions.length > 0) { + await db.insert(examQuestions).values( + questions.map((q, idx) => ({ + examId, + questionId: q.id, + score: q.score ?? 0, + order: idx, + })) + ) + } } // Prepare update object - const updateData: any = {} + const updateData: Partial = {} if (status) updateData.status = status - if (structure) updateData.structure = structure + if (structure !== undefined) updateData.structure = structure if (Object.keys(updateData).length > 0) { await db.update(exams).set(updateData).where(eq(exams.id, examId)) @@ -169,6 +173,143 @@ export async function updateExamAction( } } +const ExamDeleteSchema = z.object({ + examId: z.string().min(1), +}) + +export async function deleteExamAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + const parsed = ExamDeleteSchema.safeParse({ + examId: formData.get("examId"), + }) + + if (!parsed.success) { + return { + success: false, + message: "Invalid delete data", + errors: parsed.error.flatten().fieldErrors, + } + } + + const { examId } = parsed.data + + try { + await db.delete(exams).where(eq(exams.id, examId)) + } catch (error) { + console.error("Failed to delete exam:", error) + return { + success: false, + message: "Database error: Failed to delete exam", + } + } + + revalidatePath("/teacher/exams/all") + revalidatePath("/teacher/exams/grading") + + return { + success: true, + message: "Exam deleted", + data: examId, + } +} + +const ExamDuplicateSchema = z.object({ + examId: z.string().min(1), +}) + +const omitScheduledAtFromDescription = (description: string | null) => { + if (!description) return null + try { + const parsed: unknown = JSON.parse(description) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return description + const meta = parsed as Record + if ("scheduledAt" in meta) delete meta.scheduledAt + return JSON.stringify(meta) + } catch { + return description + } +} + +export async function duplicateExamAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + const parsed = ExamDuplicateSchema.safeParse({ + examId: formData.get("examId"), + }) + + if (!parsed.success) { + return { + success: false, + message: "Invalid duplicate data", + errors: parsed.error.flatten().fieldErrors, + } + } + + const { examId } = parsed.data + + const source = await db.query.exams.findFirst({ + where: eq(exams.id, examId), + with: { + questions: { + orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)], + }, + }, + }) + + if (!source) { + return { + success: false, + message: "Exam not found", + } + } + + const newExamId = createId() + const user = await getCurrentUser() + + try { + await db.transaction(async (tx) => { + await tx.insert(exams).values({ + id: newExamId, + title: `${source.title} (Copy)`, + description: omitScheduledAtFromDescription(source.description), + creatorId: user?.id ?? "user_teacher_123", + startTime: null, + endTime: null, + status: "draft", + structure: source.structure, + }) + + if (source.questions.length > 0) { + await tx.insert(examQuestions).values( + source.questions.map((q) => ({ + examId: newExamId, + questionId: q.questionId, + score: q.score ?? 0, + order: q.order ?? 0, + })) + ) + } + }) + } catch (error) { + console.error("Failed to duplicate exam:", error) + return { + success: false, + message: "Database error: Failed to duplicate exam", + } + } + + revalidatePath("/teacher/exams/all") + + return { + success: true, + message: "Exam duplicated", + data: newExamId, + } +} + const GradingSchema = z.object({ submissionId: z.string().min(1), answers: z.array(z.object({ diff --git a/src/modules/exams/components/assembly/exam-paper-preview.tsx b/src/modules/exams/components/assembly/exam-paper-preview.tsx new file mode 100644 index 0000000..0827b85 --- /dev/null +++ b/src/modules/exams/components/assembly/exam-paper-preview.tsx @@ -0,0 +1,141 @@ +"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" +import { Eye, Printer } from "lucide-react" +import type { ExamNode } from "./selected-question-list" + +type ChoiceOption = { + id: string + text: string +} + +type QuestionContent = { + text?: string + options?: ChoiceOption[] +} + +type ExamPaperPreviewProps = { + title: string + subject: string + grade: string + durationMin: number + totalScore: number + nodes: ExamNode[] +} + +export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) { + // Helper to flatten questions for continuous numbering + let questionCounter = 0 + + const renderNode = (node: ExamNode, depth: number = 0) => { + if (node.type === 'group') { + return ( +
+
+

+ {node.title || "Section"} +

+ {/* Optional: Show section score if needed */} +
+
+ {node.children?.map(child => renderNode(child, depth + 1))} +
+
+ ) + } + + if (node.type === 'question' && node.question) { + questionCounter++ + const q = node.question + const content = q.content as QuestionContent + + return ( +
+
+ {questionCounter}. +
+
+ {content.text ?? ""} + ({node.score}分) +
+ + {/* Options for Choice Questions */} + {(q.type === 'single_choice' || q.type === 'multiple_choice') && content.options && ( +
+ {content.options.map((opt) => ( +
+ {opt.id}. + {opt.text} +
+ ))} +
+ )} + + {/* Space for written answers */} + {q.type === 'text' && ( +
+ )} +
+
+
+ ) + } + return null + } + + return ( + + + + + + +
+ Exam Preview + +
+
+ + +
+ {/* Header */} +
+

{title}

+
+ Subject: {subject} + Grade: {grade} + Time: {durationMin} mins + Total: {totalScore} pts +
+
+
Class:
+
Name:
+
No.:
+
+
+ + {/* Content */} +
+ {nodes.length === 0 ? ( +
+ Empty Exam Paper +
+ ) : ( + nodes.map(node => renderNode(node)) + )} +
+
+
+
+
+ ) +} diff --git a/src/modules/exams/components/assembly/structure-editor.tsx b/src/modules/exams/components/assembly/structure-editor.tsx index 9646626..6a544d8 100644 --- a/src/modules/exams/components/assembly/structure-editor.tsx +++ b/src/modules/exams/components/assembly/structure-editor.tsx @@ -5,7 +5,6 @@ import { DndContext, pointerWithin, rectIntersection, - getFirstCollision, CollisionDetection, KeyboardSensor, PointerSensor, @@ -34,7 +33,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/co import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react" import { cn } from "@/shared/lib/utils" import type { ExamNode } from "./selected-question-list" -import type { Question } from "@/modules/questions/types" // --- Types --- @@ -47,6 +45,15 @@ type StructureEditorProps = { onAddGroup: () => void } +function cloneExamNodes(nodes: ExamNode[]): ExamNode[] { + return nodes.map((node) => { + if (node.type === "group") { + return { ...node, children: cloneExamNodes(node.children || []) } + } + return { ...node } + }) +} + // --- Components --- function SortableItem({ @@ -201,10 +208,20 @@ function StructureRenderer({ nodes, ...props }: { onScoreChange: (id: string, score: number) => void onGroupTitleChange: (id: string, title: string) => void }) { + // Deduplicate nodes to prevent React key errors + const uniqueNodes = useMemo(() => { + const seen = new Set() + return nodes.filter(n => { + if (seen.has(n.id)) return false + seen.add(n.id) + return true + }) + }, [nodes]) + return ( - n.id)} strategy={verticalListSortingStrategy}> - {nodes.map(node => ( - + n.id)} strategy={verticalListSortingStrategy}> + {uniqueNodes.map(node => ( +
{node.type === 'group' ? ( props.onScoreChange(node.id, val)} /> )} - +
))}
) @@ -362,7 +379,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh if (activeContainerId !== overNode.id) { // ... implementation continues ... - const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[] + const newItems = cloneExamNodes(items) // Remove active from old location const removeRecursive = (list: ExamNode[]): ExamNode | null => { @@ -386,7 +403,10 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh for (const node of list) { if (node.id === overId) { if (!node.children) node.children = [] - node.children.push(movedItem) + // Extra safety: Check if movedItem.id is already in children + if (!node.children.some(c => c.id === movedItem.id)) { + node.children.push(movedItem) + } return true } if (node.children) { @@ -404,8 +424,12 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh // Scenario 2: Moving between different lists (e.g. from Root to Group A, or Group A to Group B) if (activeContainerId !== overContainerId) { + // FIX: If we are already inside the group we are hovering (i.e. activeContainerId IS overId), + // do not try to move "next to" the group (which would move us out). + if (activeContainerId === overId) return + // Standard Sortable Move - const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[] + const newItems = cloneExamNodes(items) const removeRecursive = (list: ExamNode[]): ExamNode | null => { const idx = list.findIndex(i => i.id === activeId) @@ -484,7 +508,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh if (activeContainerId === overContainerId) { // Same container reorder - const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[] + const newItems = cloneExamNodes(items) const getMutableList = (groupId?: string): ExamNode[] => { if (groupId === 'root') return newItems @@ -560,7 +584,9 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh ) : (
-

{(activeItem.question?.content as any)?.text || "Question"}

+

+ {(activeItem.question?.content as { text?: string } | undefined)?.text || "Question"} +

) ) : null} diff --git a/src/modules/exams/components/exam-actions.tsx b/src/modules/exams/components/exam-actions.tsx index 8e9cdf3..1009fa9 100644 --- a/src/modules/exams/components/exam-actions.tsx +++ b/src/modules/exams/components/exam-actions.tsx @@ -32,6 +32,7 @@ import { DialogTitle, } from "@/shared/components/ui/dialog" +import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions" import { Exam } from "../types" interface ExamActionsProps { @@ -42,31 +43,70 @@ export function ExamActions({ exam }: ExamActionsProps) { const router = useRouter() const [showViewDialog, setShowViewDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [isWorking, setIsWorking] = useState(false) const copyId = () => { navigator.clipboard.writeText(exam.id) toast.success("Exam ID copied to clipboard") } - const publishExam = async () => { - toast.success("Exam published") + const setStatus = async (status: Exam["status"]) => { + setIsWorking(true) + try { + const formData = new FormData() + formData.set("examId", exam.id) + formData.set("status", status) + const result = await updateExamAction(null, formData) + if (result.success) { + toast.success(status === "published" ? "Exam published" : status === "archived" ? "Exam archived" : "Exam moved to draft") + router.refresh() + } else { + toast.error(result.message || "Failed to update exam") + } + } catch { + toast.error("Failed to update exam") + } finally { + setIsWorking(false) + } } - const unpublishExam = async () => { - toast.success("Exam moved to draft") - } - - const archiveExam = async () => { - toast.success("Exam archived") + const duplicateExam = async () => { + setIsWorking(true) + try { + const formData = new FormData() + formData.set("examId", exam.id) + const result = await duplicateExamAction(null, formData) + if (result.success && result.data) { + toast.success("Exam duplicated") + router.push(`/teacher/exams/${result.data}/build`) + router.refresh() + } else { + toast.error(result.message || "Failed to duplicate exam") + } + } catch { + toast.error("Failed to duplicate exam") + } finally { + setIsWorking(false) + } } const handleDelete = async () => { + setIsWorking(true) try { - await new Promise((r) => setTimeout(r, 800)) - toast.success("Exam deleted successfully") - setShowDeleteDialog(false) - } catch (e) { + const formData = new FormData() + formData.set("examId", exam.id) + const result = await deleteExamAction(null, formData) + if (result.success) { + toast.success("Exam deleted successfully") + setShowDeleteDialog(false) + router.refresh() + } else { + toast.error(result.message || "Failed to delete exam") + } + } catch { toast.error("Failed to delete exam") + } finally { + setIsWorking(false) } } @@ -88,25 +128,39 @@ export function ExamActions({ exam }: ExamActionsProps) { setShowViewDialog(true)}> View - + router.push(`/teacher/exams/${exam.id}/build`)}> Edit router.push(`/teacher/exams/${exam.id}/build`)}> Build - + + Duplicate + + + setStatus("published")} + disabled={isWorking || exam.status === "published"} + > Publish - + setStatus("draft")} + disabled={isWorking || exam.status === "draft"} + > Move to Draft - + setStatus("archived")} + disabled={isWorking || exam.status === "archived"} + > Archive setShowDeleteDialog(true)} + disabled={isWorking} > Delete @@ -159,6 +213,7 @@ export function ExamActions({ exam }: ExamActionsProps) { e.preventDefault() handleDelete() }} + disabled={isWorking} > Delete diff --git a/src/modules/exams/components/exam-assembly.tsx b/src/modules/exams/components/exam-assembly.tsx index ed13bd7..0000020 100644 --- a/src/modules/exams/components/exam-assembly.tsx +++ b/src/modules/exams/components/exam-assembly.tsx @@ -1,6 +1,6 @@ "use client" -import { useMemo, useState } from "react" +import { useDeferredValue, useMemo, useState } from "react" import { useFormStatus } from "react-dom" import { useRouter } from "next/navigation" import { toast } from "sonner" @@ -19,6 +19,7 @@ import { updateExamAction } from "@/modules/exams/actions" import { StructureEditor } from "./assembly/structure-editor" import { QuestionBankList } from "./assembly/question-bank-list" import type { ExamNode } from "./assembly/selected-question-list" +import { ExamPaperPreview } from "./assembly/exam-paper-preview" import { createId } from "@paralleldrive/cuid2" type ExamAssemblyProps = { @@ -48,17 +49,20 @@ export function ExamAssembly(props: ExamAssemblyProps) { const [search, setSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") const [difficultyFilter, setDifficultyFilter] = useState("all") + const deferredSearch = useDeferredValue(search) // Initialize structure state const [structure, setStructure] = useState(() => { - // Hydrate structure with full question objects + const questionById = new Map() + for (const q of props.questionOptions) questionById.set(q.id, q) + const hydrate = (nodes: ExamNode[]): ExamNode[] => { - return nodes.map(node => { - if (node.type === 'question') { - const q = props.questionOptions.find(opt => opt.id === node.questionId) + return nodes.map((node) => { + if (node.type === "question") { + const q = node.questionId ? questionById.get(node.questionId) : undefined return { ...node, question: q } } - if (node.type === 'group') { + if (node.type === "group") { return { ...node, children: hydrate(node.children || []) } } return node @@ -77,8 +81,8 @@ export function ExamAssembly(props: ExamAssemblyProps) { const filteredQuestions = useMemo(() => { let list: Question[] = [...props.questionOptions] - if (search) { - const lower = search.toLowerCase() + if (deferredSearch) { + const lower = deferredSearch.toLowerCase() list = list.filter(q => { const content = q.content as { text?: string } return content.text?.toLowerCase().includes(lower) @@ -93,7 +97,7 @@ export function ExamAssembly(props: ExamAssemblyProps) { list = list.filter((q) => q.difficulty === d) } return list - }, [search, typeFilter, difficultyFilter, props.questionOptions]) + }, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions]) // Recursively calculate total score const assignedTotal = useMemo(() => { @@ -109,17 +113,40 @@ export function ExamAssembly(props: ExamAssemblyProps) { const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100)) - const handleAdd = (question: Question) => { - setStructure(prev => [ - ...prev, - { - id: createId(), - type: 'question', - questionId: question.id, - score: 10, - question + const addedQuestionIds = useMemo(() => { + const ids = new Set() + const walk = (nodes: ExamNode[]) => { + for (const n of nodes) { + if (n.type === "question" && n.questionId) ids.add(n.questionId) + if (n.type === "group" && n.children) walk(n.children) } - ]) + } + walk(structure) + return ids + }, [structure]) + + const handleAdd = (question: Question) => { + setStructure((prev) => { + const has = (nodes: ExamNode[]): boolean => { + return nodes.some((n) => { + if (n.type === "question") return n.questionId === question.id + if (n.type === "group" && n.children) return has(n.children) + return false + }) + } + if (has(prev)) return prev + + return [ + ...prev, + { + id: createId(), + type: "question", + questionId: question.id, + score: 10, + question, + }, + ] + }) } const handleAddGroup = () => { @@ -171,10 +198,14 @@ export function ExamAssembly(props: ExamAssemblyProps) { // Helper to extract flat list for DB examQuestions table const getFlatQuestions = () => { const list: Array<{ id: string; score: number }> = [] + const seen = new Set() const traverse = (nodes: ExamNode[]) => { nodes.forEach(n => { if (n.type === 'question' && n.questionId) { - list.push({ id: n.questionId, score: n.score || 0 }) + if (!seen.has(n.questionId)) { + seen.add(n.questionId) + list.push({ id: n.questionId, score: n.score || 0 }) + } } if (n.type === 'group') { traverse(n.children || []) @@ -187,9 +218,12 @@ export function ExamAssembly(props: ExamAssemblyProps) { // Helper to strip runtime question objects for DB structure storage const getCleanStructure = () => { - const clean = (nodes: ExamNode[]): any[] => { + type CleanExamNode = Omit & { children?: CleanExamNode[] } + + const clean = (nodes: ExamNode[]): CleanExamNode[] => { return nodes.map(n => { const { question, ...rest } = n + void question if (n.type === 'group') { return { ...rest, children: clean(n.children || []) } } @@ -233,7 +267,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
- Exam Structure +
+ Exam Structure + +
{assignedTotal} / {props.totalScore} @@ -324,17 +368,7 @@ export function ExamAssembly(props: ExamAssemblyProps) { { - // Check if question is added anywhere in the structure - const isAddedRecursive = (nodes: ExamNode[]): boolean => { - return nodes.some(n => { - if (n.type === 'question' && n.questionId === id) return true - if (n.type === 'group' && n.children) return isAddedRecursive(n.children) - return false - }) - } - return isAddedRecursive(structure) - }} + isAdded={(id) => addedQuestionIds.has(id)} /> diff --git a/src/modules/exams/components/exam-columns.tsx b/src/modules/exams/components/exam-columns.tsx index 3ab7fb6..28137a1 100644 --- a/src/modules/exams/components/exam-columns.tsx +++ b/src/modules/exams/components/exam-columns.tsx @@ -2,7 +2,7 @@ import { ColumnDef } from "@tanstack/react-table" import { Checkbox } from "@/shared/components/ui/checkbox" -import { Badge } from "@/shared/components/ui/badge" +import { Badge, type BadgeProps } from "@/shared/components/ui/badge" import { cn, formatDate } from "@/shared/lib/utils" import { Exam } from "../types" import { ExamActions } from "./exam-actions" @@ -36,8 +36,8 @@ export const examColumns: ColumnDef[] = [ {row.original.title} {row.original.tags && row.original.tags.length > 0 && (
- {row.original.tags.slice(0, 2).map((t) => ( - + {row.original.tags.slice(0, 2).map((t, idx) => ( + {t} ))} @@ -65,9 +65,14 @@ export const examColumns: ColumnDef[] = [ header: "Status", cell: ({ row }) => { const status = row.original.status - const variant = status === "published" ? "secondary" : status === "archived" ? "destructive" : "outline" + const variant: BadgeProps["variant"] = + status === "published" + ? "secondary" + : status === "archived" + ? "destructive" + : "outline" return ( - + {status} ) @@ -134,4 +139,3 @@ export const examColumns: ColumnDef[] = [ cell: ({ row }) => , }, ] - diff --git a/src/modules/exams/components/grading-view.tsx b/src/modules/exams/components/grading-view.tsx index 2e5e57b..6471784 100644 --- a/src/modules/exams/components/grading-view.tsx +++ b/src/modules/exams/components/grading-view.tsx @@ -13,13 +13,17 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area" import { Separator } from "@/shared/components/ui/separator" import { gradeSubmissionAction } from "../actions" +const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null + +type QuestionContent = { text?: string } & Record + type Answer = { id: string questionId: string - questionContent: any + questionContent: QuestionContent | null questionType: string maxScore: number - studentAnswer: any + studentAnswer: unknown score: number | null feedback: string | null order: number @@ -105,8 +109,8 @@ export function GradingView({

- {typeof ans.studentAnswer?.answer === 'string' - ? ans.studentAnswer.answer + {isRecord(ans.studentAnswer) && typeof ans.studentAnswer.answer === "string" + ? ans.studentAnswer.answer : JSON.stringify(ans.studentAnswer)}

diff --git a/src/modules/exams/components/submission-columns.tsx b/src/modules/exams/components/submission-columns.tsx index 9030111..ffeee95 100644 --- a/src/modules/exams/components/submission-columns.tsx +++ b/src/modules/exams/components/submission-columns.tsx @@ -32,7 +32,7 @@ export const submissionColumns: ColumnDef[] = [ cell: ({ row }) => { const status = row.original.status const variant = status === "graded" ? "secondary" : "outline" - return {status} + return {status} }, }, { @@ -60,4 +60,3 @@ export const submissionColumns: ColumnDef[] = [ ), }, ] - diff --git a/src/modules/exams/data-access.ts b/src/modules/exams/data-access.ts index 86f304d..7ac2357 100644 --- a/src/modules/exams/data-access.ts +++ b/src/modules/exams/data-access.ts @@ -1,9 +1,9 @@ import { db } from "@/shared/db" -import { exams, examQuestions, examSubmissions, submissionAnswers, users } from "@/shared/db/schema" +import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema" import { eq, desc, like, and, or } from "drizzle-orm" import { cache } from "react" -import type { ExamStatus } from "./types" +import type { Exam, ExamDifficulty, ExamStatus } from "./types" export type GetExamsParams = { q?: string @@ -13,6 +13,40 @@ export type GetExamsParams = { pageSize?: number } +const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null + +const parseExamMeta = (description: string | null): Record => { + if (!description) return {} + try { + const parsed: unknown = JSON.parse(description) + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } +} + +const getString = (obj: Record, key: string): string | undefined => { + const v = obj[key] + return typeof v === "string" ? v : undefined +} + +const getNumber = (obj: Record, key: string): number | undefined => { + const v = obj[key] + return typeof v === "number" ? v : undefined +} + +const getStringArray = (obj: Record, key: string): string[] | undefined => { + const v = obj[key] + if (!Array.isArray(v)) return undefined + const items = v.filter((x): x is string => typeof x === "string") + return items.length === v.length ? items : undefined +} + +const toExamDifficulty = (n: number | undefined): ExamDifficulty => { + if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n + return 1 +} + export const getExams = cache(async (params: GetExamsParams) => { const conditions = [] @@ -23,7 +57,7 @@ export const getExams = cache(async (params: GetExamsParams) => { } if (params.status && params.status !== "all") { - conditions.push(eq(exams.status, params.status as any)) + conditions.push(eq(exams.status, params.status)) } // Note: Difficulty is stored in JSON description field in current schema, @@ -37,25 +71,23 @@ export const getExams = cache(async (params: GetExamsParams) => { }) // Transform and Filter (especially for JSON fields) - let result = data.map((exam) => { - let meta: any = {} - try { - meta = JSON.parse(exam.description || "{}") - } catch { } + let result: Exam[] = data.map((exam) => { + const meta = parseExamMeta(exam.description || null) return { id: exam.id, title: exam.title, status: (exam.status as ExamStatus) || "draft", - subject: meta.subject || "General", - grade: meta.grade || "General", - difficulty: meta.difficulty || 1, - totalScore: meta.totalScore || 100, - durationMin: meta.durationMin || 60, - questionCount: meta.questionCount || 0, + subject: getString(meta, "subject") || "General", + grade: getString(meta, "grade") || "General", + difficulty: toExamDifficulty(getNumber(meta, "difficulty")), + totalScore: getNumber(meta, "totalScore") || 100, + durationMin: getNumber(meta, "durationMin") || 60, + questionCount: getNumber(meta, "questionCount") || 0, scheduledAt: exam.startTime?.toISOString(), createdAt: exam.createdAt.toISOString(), - tags: meta.tags || [], + updatedAt: exam.updatedAt?.toISOString(), + tags: getStringArray(meta, "tags") || [], } }) @@ -82,30 +114,27 @@ export const getExamById = cache(async (id: string) => { if (!exam) return null - let meta: any = {} - try { - meta = JSON.parse(exam.description || "{}") - } catch { } + const meta = parseExamMeta(exam.description || null) return { id: exam.id, title: exam.title, status: (exam.status as ExamStatus) || "draft", - subject: meta.subject || "General", - grade: meta.grade || "General", - difficulty: meta.difficulty || 1, - totalScore: meta.totalScore || 100, - durationMin: meta.durationMin || 60, + subject: getString(meta, "subject") || "General", + grade: getString(meta, "grade") || "General", + difficulty: toExamDifficulty(getNumber(meta, "difficulty")), + totalScore: getNumber(meta, "totalScore") || 100, + durationMin: getNumber(meta, "durationMin") || 60, scheduledAt: exam.startTime?.toISOString(), createdAt: exam.createdAt.toISOString(), - tags: meta.tags || [], - structure: exam.structure as any, // Return structure - questions: exam.questions.map(eq => ({ - id: eq.questionId, - score: eq.score, - order: eq.order, - // ... include question details if needed - })) + updatedAt: exam.updatedAt?.toISOString(), + tags: getStringArray(meta, "tags") || [], + structure: exam.structure as unknown, + questions: exam.questions.map((eqRel) => ({ + id: eqRel.questionId, + score: eqRel.score ?? 0, + order: eqRel.order ?? 0, + })), } }) @@ -154,13 +183,20 @@ export const getSubmissionDetails = cache(async (submissionId: string) => { orderBy: [desc(examQuestions.order)], }) + type QuestionContent = { text?: string } & Record + + 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: ans.question.content, + questionContent: toQuestionContent(ans.question.content), questionType: ans.question.type, maxScore: eqRel?.score || 0, studentAnswer: ans.answerContent, diff --git a/src/modules/layout/config/navigation.ts b/src/modules/layout/config/navigation.ts index 7a7ea15..7b66b8b 100644 --- a/src/modules/layout/config/navigation.ts +++ b/src/modules/layout/config/navigation.ts @@ -2,7 +2,6 @@ import { BarChart, BookOpen, Calendar, - GraduationCap, LayoutDashboard, Settings, Users, @@ -15,10 +14,11 @@ import { Library, PenTool } from "lucide-react" +import type { LucideIcon } from "lucide-react" export type NavItem = { title: string - icon: any + icon: LucideIcon href: string items?: { title: string; href: string }[] } diff --git a/src/modules/questions/actions.ts b/src/modules/questions/actions.ts index c8720cb..d58a429 100644 --- a/src/modules/questions/actions.ts +++ b/src/modules/questions/actions.ts @@ -5,8 +5,6 @@ import { questions, questionsToKnowledgePoints } from "@/shared/db/schema"; import { CreateQuestionInput, CreateQuestionSchema } from "./schema"; import { ActionState } from "@/shared/types/action-state"; import { revalidatePath } from "next/cache"; -import { ZodError } from "zod"; -import { eq } from "drizzle-orm"; import { createId } from "@paralleldrive/cuid2"; // --- Mock Auth Helper (Replace with actual Auth.js call) --- @@ -29,8 +27,10 @@ async function ensureTeacher() { // --- Recursive Insert Helper --- // We pass 'tx' to ensure all operations run within the same transaction +type Tx = Parameters[0]>[0] + async function insertQuestionWithRelations( - tx: any, // using any or strict Drizzle Transaction type if imported + tx: Tx, input: CreateQuestionInput, authorId: string, parentId: string | null = null @@ -81,14 +81,14 @@ export async function createNestedQuestion( // If formData is actual FormData, we need to convert it. // For complex nested structures, frontend usually sends JSON string or pure JSON object if using `useServerAction` with arguments. // Here we assume the client might send a raw object (if using direct function call) or we parse FormData. - let rawInput: any = formData; + let rawInput: unknown = formData; if (formData instanceof FormData) { // Parsing complex nested JSON from FormData is messy. // We assume one field 'data' contains the JSON, or we expect direct object usage (common in modern Next.js RPC). const jsonString = formData.get("json"); if (typeof jsonString === "string") { - rawInput = JSON.parse(jsonString); + rawInput = JSON.parse(jsonString) as unknown; } else { return { success: false, message: "Invalid submission format. Expected JSON." }; } diff --git a/src/modules/questions/data-access.ts b/src/modules/questions/data-access.ts index 3631986..ed9fb54 100644 --- a/src/modules/questions/data-access.ts +++ b/src/modules/questions/data-access.ts @@ -9,6 +9,7 @@ import { cache } from "react"; export type GetQuestionsParams = { page?: number; pageSize?: number; + ids?: string[]; knowledgePointId?: string; difficulty?: number; }; @@ -18,6 +19,7 @@ export type GetQuestionsParams = { export const getQuestions = cache(async ({ page = 1, pageSize = 10, + ids, knowledgePointId, difficulty, }: GetQuestionsParams = {}) => { @@ -26,6 +28,10 @@ export const getQuestions = cache(async ({ // Build Where Conditions const conditions = []; + if (ids && ids.length > 0) { + conditions.push(inArray(questions.id, ids)); + } + if (difficulty) { conditions.push(eq(questions.difficulty, difficulty)); } @@ -40,9 +46,9 @@ export const getQuestions = cache(async ({ conditions.push(inArray(questions.id, subQuery)); } - // Only fetch top-level questions (parent questions) - // Assuming we only want to list "root" questions, not sub-questions - conditions.push(sql`${questions.parentId} IS NULL`); + if (!ids || ids.length === 0) { + conditions.push(sql`${questions.parentId} IS NULL`) + } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; diff --git a/src/modules/textbooks/components/textbook-content-layout.tsx b/src/modules/textbooks/components/textbook-content-layout.tsx index 3d7acb8..13cae66 100644 --- a/src/modules/textbooks/components/textbook-content-layout.tsx +++ b/src/modules/textbooks/components/textbook-content-layout.tsx @@ -40,8 +40,7 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }: if (result.success) { toast.success(result.message) setIsEditing(false) - // Update local state to reflect change immediately (optimistic-like) - selectedChapter.content = editContent + setSelectedChapter((prev) => (prev ? { ...prev, content: editContent } : prev)) } else { toast.error(result.message) } diff --git a/src/modules/textbooks/components/textbook-form-dialog.tsx b/src/modules/textbooks/components/textbook-form-dialog.tsx index 67c92ef..f1d735a 100644 --- a/src/modules/textbooks/components/textbook-form-dialog.tsx +++ b/src/modules/textbooks/components/textbook-form-dialog.tsx @@ -61,7 +61,7 @@ export function TextbookFormDialog() { Add New Textbook - Create a new digital textbook. Click save when you're done. + Create a new digital textbook. Click save when you're done.
diff --git a/src/modules/textbooks/data-access.ts b/src/modules/textbooks/data-access.ts index 9299dfa..fb98caa 100644 --- a/src/modules/textbooks/data-access.ts +++ b/src/modules/textbooks/data-access.ts @@ -65,7 +65,7 @@ let MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [ export async function getTextbooks(query?: string, subject?: string, grade?: string): Promise { await new Promise((resolve) => setTimeout(resolve, 500)); - let results = [...MOCK_TEXTBOOKS]; + const results = [...MOCK_TEXTBOOKS]; // ... (filtering logic) return results; } diff --git a/src/shared/components/ui/form.tsx b/src/shared/components/ui/form.tsx index dbc256f..544890f 100644 --- a/src/shared/components/ui/form.tsx +++ b/src/shared/components/ui/form.tsx @@ -28,7 +28,7 @@ const FormFieldContext = React.createContext( const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, - TTransformedValues = any + TTransformedValues = unknown >({ ...props }: ControllerProps) => { diff --git a/tailwind.config.ts b/tailwind.config.ts index 4f35474..78eee31 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,5 @@ import type { Config } from "tailwindcss"; +import tailwindcssAnimate from "tailwindcss-animate"; const config: Config = { darkMode: "class", @@ -70,7 +71,7 @@ const config: Config = { }, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [tailwindcssAnimate], }; export default config;