feat: exam actions and data safety fixes
This commit is contained in:
@@ -8,7 +8,7 @@ import { Label } from "@/shared/components/ui/label"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Loader2, Github } from "lucide-react"
|
||||
|
||||
interface LoginFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Label } from "@/shared/components/ui/label"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Loader2, Github } from "lucide-react"
|
||||
|
||||
interface RegisterFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function RegisterForm({ className, ...props }: RegisterFormProps) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
|
||||
@@ -40,9 +40,9 @@ export function TeacherSchedule() {
|
||||
const hasSchedule = MOCK_SCHEDULE.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Today's Schedule</CardTitle>
|
||||
<CardTitle>Today's Schedule</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasSchedule ? (
|
||||
|
||||
@@ -101,8 +101,8 @@ const ExamUpdateSchema = z.object({
|
||||
score: z.coerce.number().int().min(0),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
structure: z.any().optional(), // Accept structure JSON
|
||||
.optional(),
|
||||
structure: z.unknown().optional(),
|
||||
status: z.enum(["draft", "published", "archived"]).optional(),
|
||||
})
|
||||
|
||||
@@ -110,13 +110,15 @@ export async function updateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
const rawStructure = formData.get("structureJson") as string | null
|
||||
const rawQuestions = formData.get("questionsJson")
|
||||
const rawStructure = formData.get("structureJson")
|
||||
const hasQuestions = typeof rawQuestions === "string"
|
||||
const hasStructure = typeof rawStructure === "string"
|
||||
|
||||
const parsed = ExamUpdateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
structure: rawStructure ? JSON.parse(rawStructure) : undefined,
|
||||
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
|
||||
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
|
||||
status: formData.get("status") ?? undefined,
|
||||
})
|
||||
|
||||
@@ -131,22 +133,24 @@ export async function updateExamAction(
|
||||
const { examId, questions, structure, status } = parsed.data
|
||||
|
||||
try {
|
||||
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
|
||||
if (questions.length > 0) {
|
||||
await db.insert(examQuestions).values(
|
||||
questions.map((q, idx) => ({
|
||||
examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
order: idx,
|
||||
}))
|
||||
)
|
||||
if (questions) {
|
||||
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
|
||||
if (questions.length > 0) {
|
||||
await db.insert(examQuestions).values(
|
||||
questions.map((q, idx) => ({
|
||||
examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
order: idx,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update object
|
||||
const updateData: any = {}
|
||||
const updateData: Partial<typeof exams.$inferInsert> = {}
|
||||
if (status) updateData.status = status
|
||||
if (structure) updateData.structure = structure
|
||||
if (structure !== undefined) updateData.structure = structure
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.update(exams).set(updateData).where(eq(exams.id, examId))
|
||||
@@ -169,6 +173,143 @@ export async function updateExamAction(
|
||||
}
|
||||
}
|
||||
|
||||
const ExamDeleteSchema = z.object({
|
||||
examId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function deleteExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
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<string, unknown>
|
||||
if ("scheduledAt" in meta) delete meta.scheduledAt
|
||||
return JSON.stringify(meta)
|
||||
} catch {
|
||||
return description
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const parsed = ExamDuplicateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid duplicate data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
const source = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!source) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Exam not found",
|
||||
}
|
||||
}
|
||||
|
||||
const newExamId = createId()
|
||||
const user = await getCurrentUser()
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(exams).values({
|
||||
id: newExamId,
|
||||
title: `${source.title} (Copy)`,
|
||||
description: omitScheduledAtFromDescription(source.description),
|
||||
creatorId: user?.id ?? "user_teacher_123",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
status: "draft",
|
||||
structure: source.structure,
|
||||
})
|
||||
|
||||
if (source.questions.length > 0) {
|
||||
await tx.insert(examQuestions).values(
|
||||
source.questions.map((q) => ({
|
||||
examId: newExamId,
|
||||
questionId: q.questionId,
|
||||
score: q.score ?? 0,
|
||||
order: q.order ?? 0,
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate exam:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to duplicate exam",
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Exam duplicated",
|
||||
data: newExamId,
|
||||
}
|
||||
}
|
||||
|
||||
const GradingSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
answers: z.array(z.object({
|
||||
|
||||
141
src/modules/exams/components/assembly/exam-paper-preview.tsx
Normal file
141
src/modules/exams/components/assembly/exam-paper-preview.tsx
Normal file
@@ -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 (
|
||||
<div key={node.id} className="space-y-4 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-bold ${depth === 0 ? 'text-lg' : 'text-md'} text-foreground/90`}>
|
||||
{node.title || "Section"}
|
||||
</h3>
|
||||
{/* Optional: Show section score if needed */}
|
||||
</div>
|
||||
<div className="pl-0">
|
||||
{node.children?.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'question' && node.question) {
|
||||
questionCounter++
|
||||
const q = node.question
|
||||
const content = q.content as QuestionContent
|
||||
|
||||
return (
|
||||
<div key={node.id} className="mb-6 break-inside-avoid">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-semibold text-foreground min-w-[24px]">{questionCounter}.</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{content.text ?? ""}
|
||||
<span className="text-muted-foreground text-sm ml-2">({node.score}分)</span>
|
||||
</div>
|
||||
|
||||
{/* Options for Choice Questions */}
|
||||
{(q.type === 'single_choice' || q.type === 'multiple_choice') && content.options && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-2 gap-x-4 mt-2 pl-2">
|
||||
{content.options.map((opt) => (
|
||||
<div key={opt.id} className="flex gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<span className="font-medium">{opt.id}.</span>
|
||||
<span>{opt.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Space for written answers */}
|
||||
{q.type === 'text' && (
|
||||
<div className="mt-4 h-24 border-b border-dashed border-muted-foreground/30 w-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" size="sm" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview Exam
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="p-6 pb-2 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Exam Preview</DialogTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden">
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-8 bg-white/50 dark:bg-zinc-950/50">
|
||||
<div className="max-w-3xl mx-auto bg-card shadow-sm border p-12 min-h-[1000px] print:shadow-none print:border-none">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
|
||||
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
|
||||
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
|
||||
<span>Subject: {subject}</span>
|
||||
<span>Grade: {grade}</span>
|
||||
<span>Time: {durationMin} mins</span>
|
||||
<span>Total: {totalScore} pts</span>
|
||||
</div>
|
||||
<div className="flex justify-center gap-12 text-sm pt-4">
|
||||
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
|
||||
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
|
||||
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-2">
|
||||
{nodes.length === 0 ? (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
Empty Exam Paper
|
||||
</div>
|
||||
) : (
|
||||
nodes.map(node => renderNode(node))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DndContext,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
getFirstCollision,
|
||||
CollisionDetection,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
@@ -34,7 +33,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/co
|
||||
import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ExamNode } from "./selected-question-list"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -47,6 +45,15 @@ type StructureEditorProps = {
|
||||
onAddGroup: () => void
|
||||
}
|
||||
|
||||
function cloneExamNodes(nodes: ExamNode[]): ExamNode[] {
|
||||
return nodes.map((node) => {
|
||||
if (node.type === "group") {
|
||||
return { ...node, children: cloneExamNodes(node.children || []) }
|
||||
}
|
||||
return { ...node }
|
||||
})
|
||||
}
|
||||
|
||||
// --- Components ---
|
||||
|
||||
function SortableItem({
|
||||
@@ -201,10 +208,20 @@ function StructureRenderer({ nodes, ...props }: {
|
||||
onScoreChange: (id: string, score: number) => void
|
||||
onGroupTitleChange: (id: string, title: string) => void
|
||||
}) {
|
||||
// Deduplicate nodes to prevent React key errors
|
||||
const uniqueNodes = useMemo(() => {
|
||||
const seen = new Set()
|
||||
return nodes.filter(n => {
|
||||
if (seen.has(n.id)) return false
|
||||
seen.add(n.id)
|
||||
return true
|
||||
})
|
||||
}, [nodes])
|
||||
|
||||
return (
|
||||
<SortableContext items={nodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
||||
{nodes.map(node => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
||||
{uniqueNodes.map(node => (
|
||||
<div key={node.id}>
|
||||
{node.type === 'group' ? (
|
||||
<SortableGroup
|
||||
id={node.id}
|
||||
@@ -232,7 +249,7 @@ function StructureRenderer({ nodes, ...props }: {
|
||||
onScoreChange={(val) => props.onScoreChange(node.id, val)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
)
|
||||
@@ -362,7 +379,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
if (activeContainerId !== overNode.id) {
|
||||
// ... implementation continues ...
|
||||
|
||||
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
|
||||
const newItems = cloneExamNodes(items)
|
||||
|
||||
// Remove active from old location
|
||||
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
|
||||
@@ -386,7 +403,10 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
for (const node of list) {
|
||||
if (node.id === overId) {
|
||||
if (!node.children) node.children = []
|
||||
node.children.push(movedItem)
|
||||
// Extra safety: Check if movedItem.id is already in children
|
||||
if (!node.children.some(c => c.id === movedItem.id)) {
|
||||
node.children.push(movedItem)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (node.children) {
|
||||
@@ -404,8 +424,12 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
|
||||
// Scenario 2: Moving between different lists (e.g. from Root to Group A, or Group A to Group B)
|
||||
if (activeContainerId !== overContainerId) {
|
||||
// FIX: If we are already inside the group we are hovering (i.e. activeContainerId IS overId),
|
||||
// do not try to move "next to" the group (which would move us out).
|
||||
if (activeContainerId === overId) return
|
||||
|
||||
// Standard Sortable Move
|
||||
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
|
||||
const newItems = cloneExamNodes(items)
|
||||
|
||||
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
|
||||
const idx = list.findIndex(i => i.id === activeId)
|
||||
@@ -484,7 +508,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
|
||||
if (activeContainerId === overContainerId) {
|
||||
// Same container reorder
|
||||
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
|
||||
const newItems = cloneExamNodes(items)
|
||||
|
||||
const getMutableList = (groupId?: string): ExamNode[] => {
|
||||
if (groupId === 'root') return newItems
|
||||
@@ -560,7 +584,9 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
) : (
|
||||
<div className="rounded-md border bg-background p-3 shadow-lg opacity-80 w-[300px] flex items-center gap-3">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
<p className="text-sm line-clamp-1">{(activeItem.question?.content as any)?.text || "Question"}</p>
|
||||
<p className="text-sm line-clamp-1">
|
||||
{(activeItem.question?.content as { text?: string } | undefined)?.text || "Question"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions"
|
||||
import { Exam } from "../types"
|
||||
|
||||
interface ExamActionsProps {
|
||||
@@ -42,31 +43,70 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
const router = useRouter()
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const copyId = () => {
|
||||
navigator.clipboard.writeText(exam.id)
|
||||
toast.success("Exam ID copied to clipboard")
|
||||
}
|
||||
|
||||
const publishExam = async () => {
|
||||
toast.success("Exam published")
|
||||
const setStatus = async (status: Exam["status"]) => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("examId", exam.id)
|
||||
formData.set("status", status)
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(status === "published" ? "Exam published" : status === "archived" ? "Exam archived" : "Exam moved to draft")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to update exam")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update exam")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const unpublishExam = async () => {
|
||||
toast.success("Exam moved to draft")
|
||||
}
|
||||
|
||||
const archiveExam = async () => {
|
||||
toast.success("Exam archived")
|
||||
const duplicateExam = async () => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("examId", exam.id)
|
||||
const result = await duplicateExamAction(null, formData)
|
||||
if (result.success && result.data) {
|
||||
toast.success("Exam duplicated")
|
||||
router.push(`/teacher/exams/${result.data}/build`)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to duplicate exam")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to duplicate exam")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
toast.success("Exam deleted successfully")
|
||||
setShowDeleteDialog(false)
|
||||
} catch (e) {
|
||||
const formData = new FormData()
|
||||
formData.set("examId", exam.id)
|
||||
const result = await deleteExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Exam deleted successfully")
|
||||
setShowDeleteDialog(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to delete exam")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete exam")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,25 +128,39 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={publishExam}>
|
||||
<DropdownMenuItem onClick={duplicateExam} disabled={isWorking}>
|
||||
<Copy className="mr-2 h-4 w-4" /> Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setStatus("published")}
|
||||
disabled={isWorking || exam.status === "published"}
|
||||
>
|
||||
<UploadCloud className="mr-2 h-4 w-4" /> Publish
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={unpublishExam}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setStatus("draft")}
|
||||
disabled={isWorking || exam.status === "draft"}
|
||||
>
|
||||
<Undo2 className="mr-2 h-4 w-4" /> Move to Draft
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={archiveExam}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setStatus("archived")}
|
||||
disabled={isWorking || exam.status === "archived"}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" /> Archive
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -159,6 +213,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={isWorking}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -1,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<string>("all")
|
||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||
const deferredSearch = useDeferredValue(search)
|
||||
|
||||
// Initialize structure state
|
||||
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
||||
// Hydrate structure with full question objects
|
||||
const questionById = new Map<string, Question>()
|
||||
for (const q of props.questionOptions) questionById.set(q.id, q)
|
||||
|
||||
const hydrate = (nodes: ExamNode[]): ExamNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.type === 'question') {
|
||||
const q = props.questionOptions.find(opt => opt.id === node.questionId)
|
||||
return nodes.map((node) => {
|
||||
if (node.type === "question") {
|
||||
const q = node.questionId ? questionById.get(node.questionId) : undefined
|
||||
return { ...node, question: q }
|
||||
}
|
||||
if (node.type === 'group') {
|
||||
if (node.type === "group") {
|
||||
return { ...node, children: hydrate(node.children || []) }
|
||||
}
|
||||
return node
|
||||
@@ -77,8 +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<string>()
|
||||
const walk = (nodes: ExamNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.type === "question" && n.questionId) ids.add(n.questionId)
|
||||
if (n.type === "group" && n.children) walk(n.children)
|
||||
}
|
||||
])
|
||||
}
|
||||
walk(structure)
|
||||
return ids
|
||||
}, [structure])
|
||||
|
||||
const handleAdd = (question: Question) => {
|
||||
setStructure((prev) => {
|
||||
const has = (nodes: ExamNode[]): boolean => {
|
||||
return nodes.some((n) => {
|
||||
if (n.type === "question") return n.questionId === question.id
|
||||
if (n.type === "group" && n.children) return has(n.children)
|
||||
return false
|
||||
})
|
||||
}
|
||||
if (has(prev)) return prev
|
||||
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: createId(),
|
||||
type: "question",
|
||||
questionId: question.id,
|
||||
score: 10,
|
||||
question,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddGroup = () => {
|
||||
@@ -171,10 +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<string>()
|
||||
const traverse = (nodes: ExamNode[]) => {
|
||||
nodes.forEach(n => {
|
||||
if (n.type === 'question' && n.questionId) {
|
||||
list.push({ id: n.questionId, score: n.score || 0 })
|
||||
if (!seen.has(n.questionId)) {
|
||||
seen.add(n.questionId)
|
||||
list.push({ id: n.questionId, score: n.score || 0 })
|
||||
}
|
||||
}
|
||||
if (n.type === 'group') {
|
||||
traverse(n.children || [])
|
||||
@@ -187,9 +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<ExamNode, "question"> & { children?: CleanExamNode[] }
|
||||
|
||||
const clean = (nodes: ExamNode[]): CleanExamNode[] => {
|
||||
return nodes.map(n => {
|
||||
const { question, ...rest } = n
|
||||
void question
|
||||
if (n.type === 'group') {
|
||||
return { ...rest, children: clean(n.children || []) }
|
||||
}
|
||||
@@ -233,7 +267,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
|
||||
<CardHeader className="bg-muted/30 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Exam Structure</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle>Exam Structure</CardTitle>
|
||||
<ExamPaperPreview
|
||||
title={props.title}
|
||||
subject={props.subject}
|
||||
grade={props.grade}
|
||||
durationMin={props.durationMin}
|
||||
totalScore={props.totalScore}
|
||||
nodes={structure}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
|
||||
@@ -324,17 +368,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
<QuestionBankList
|
||||
questions={filteredQuestions}
|
||||
onAdd={handleAdd}
|
||||
isAdded={(id) => {
|
||||
// Check if question is added anywhere in the structure
|
||||
const isAddedRecursive = (nodes: ExamNode[]): boolean => {
|
||||
return nodes.some(n => {
|
||||
if (n.type === 'question' && n.questionId === id) return true
|
||||
if (n.type === 'group' && n.children) return isAddedRecursive(n.children)
|
||||
return false
|
||||
})
|
||||
}
|
||||
return isAddedRecursive(structure)
|
||||
}}
|
||||
isAdded={(id) => addedQuestionIds.has(id)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import { Exam } from "../types"
|
||||
import { ExamActions } from "./exam-actions"
|
||||
@@ -36,8 +36,8 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
{row.original.tags && row.original.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.tags.slice(0, 2).map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-xs">
|
||||
{row.original.tags.slice(0, 2).map((t, idx) => (
|
||||
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -65,9 +65,14 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const variant = status === "published" ? "secondary" : status === "archived" ? "destructive" : "outline"
|
||||
const variant: BadgeProps["variant"] =
|
||||
status === "published"
|
||||
? "secondary"
|
||||
: status === "archived"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
return (
|
||||
<Badge variant={variant as any} className="capitalize">
|
||||
<Badge variant={variant} className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
@@ -134,4 +139,3 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
cell: ({ row }) => <ExamActions exam={row.original} />,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
type QuestionContent = { text?: string } & Record<string, unknown>
|
||||
|
||||
type Answer = {
|
||||
id: string
|
||||
questionId: string
|
||||
questionContent: any
|
||||
questionContent: QuestionContent | null
|
||||
questionType: string
|
||||
maxScore: number
|
||||
studentAnswer: any
|
||||
studentAnswer: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
@@ -105,8 +109,8 @@ export function GradingView({
|
||||
<div className="rounded-md bg-muted/50 p-4">
|
||||
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{typeof ans.studentAnswer?.answer === 'string'
|
||||
? ans.studentAnswer.answer
|
||||
{isRecord(ans.studentAnswer) && typeof ans.studentAnswer.answer === "string"
|
||||
? ans.studentAnswer.answer
|
||||
: JSON.stringify(ans.studentAnswer)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const submissionColumns: ColumnDef<ExamSubmission>[] = [
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const variant = status === "graded" ? "secondary" : "outline"
|
||||
return <Badge variant={variant as any} className="capitalize">{status}</Badge>
|
||||
return <Badge variant={variant} className="capitalize">{status}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -60,4 +60,3 @@ export const submissionColumns: ColumnDef<ExamSubmission>[] = [
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
const parseExamMeta = (description: string | null): Record<string, unknown> => {
|
||||
if (!description) return {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(description)
|
||||
return isRecord(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const getString = (obj: Record<string, unknown>, key: string): string | undefined => {
|
||||
const v = obj[key]
|
||||
return typeof v === "string" ? v : undefined
|
||||
}
|
||||
|
||||
const getNumber = (obj: Record<string, unknown>, key: string): number | undefined => {
|
||||
const v = obj[key]
|
||||
return typeof v === "number" ? v : undefined
|
||||
}
|
||||
|
||||
const getStringArray = (obj: Record<string, unknown>, key: string): string[] | undefined => {
|
||||
const v = obj[key]
|
||||
if (!Array.isArray(v)) return undefined
|
||||
const items = v.filter((x): x is string => typeof x === "string")
|
||||
return items.length === v.length ? items : undefined
|
||||
}
|
||||
|
||||
const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
|
||||
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
export const getExams = cache(async (params: GetExamsParams) => {
|
||||
const conditions = []
|
||||
@@ -23,7 +57,7 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
||||
}
|
||||
|
||||
if (params.status && params.status !== "all") {
|
||||
conditions.push(eq(exams.status, params.status as any))
|
||||
conditions.push(eq(exams.status, params.status))
|
||||
}
|
||||
|
||||
// Note: Difficulty is stored in JSON description field in current schema,
|
||||
@@ -37,25 +71,23 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
||||
})
|
||||
|
||||
// Transform and Filter (especially for JSON fields)
|
||||
let result = data.map((exam) => {
|
||||
let meta: any = {}
|
||||
try {
|
||||
meta = JSON.parse(exam.description || "{}")
|
||||
} catch { }
|
||||
let result: Exam[] = data.map((exam) => {
|
||||
const meta = parseExamMeta(exam.description || null)
|
||||
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
subject: meta.subject || "General",
|
||||
grade: meta.grade || "General",
|
||||
difficulty: meta.difficulty || 1,
|
||||
totalScore: meta.totalScore || 100,
|
||||
durationMin: meta.durationMin || 60,
|
||||
questionCount: meta.questionCount || 0,
|
||||
subject: getString(meta, "subject") || "General",
|
||||
grade: getString(meta, "grade") || "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
totalScore: getNumber(meta, "totalScore") || 100,
|
||||
durationMin: getNumber(meta, "durationMin") || 60,
|
||||
questionCount: getNumber(meta, "questionCount") || 0,
|
||||
scheduledAt: exam.startTime?.toISOString(),
|
||||
createdAt: exam.createdAt.toISOString(),
|
||||
tags: meta.tags || [],
|
||||
updatedAt: exam.updatedAt?.toISOString(),
|
||||
tags: getStringArray(meta, "tags") || [],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -82,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<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: ans.question.content,
|
||||
questionContent: toQuestionContent(ans.question.content),
|
||||
questionType: ans.question.type,
|
||||
maxScore: eqRel?.score || 0,
|
||||
studentAnswer: ans.answerContent,
|
||||
|
||||
@@ -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 }[]
|
||||
}
|
||||
|
||||
@@ -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<Parameters<typeof db.transaction>[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." };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -40,8 +40,7 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setIsEditing(false)
|
||||
// Update local state to reflect change immediately (optimistic-like)
|
||||
selectedChapter.content = editContent
|
||||
setSelectedChapter((prev) => (prev ? { ...prev, content: editContent } : prev))
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function TextbookFormDialog() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Textbook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new digital textbook. Click save when you're done.
|
||||
Create a new digital textbook. Click save when you're done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit}>
|
||||
|
||||
@@ -65,7 +65,7 @@ let MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [
|
||||
|
||||
export async function getTextbooks(query?: string, subject?: string, grade?: string): Promise<Textbook[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
let results = [...MOCK_TEXTBOOKS];
|
||||
const results = [...MOCK_TEXTBOOKS];
|
||||
// ... (filtering logic)
|
||||
return results;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user