Files
NextEdu/src/modules/exams/components/assembly/structure-editor.tsx
SpecialX 2adf61faa8 feat(modules-assessment): update exams, files, grades, homework, invitation-codes, leave-requests
- exams: update assembly (exam-paper-preview, question-bank-list, structure-editor),

  exam-actions, exam-assembly, exam-columns, exam-data-table, exam-form,

  exam-preview-utils, exam-rich-form, editor (exam-nodes-to-editor-doc,

  exam-rich-editor-inner, question-block, selection-toolbar),

  hooks (use-exam-preview-rewrite, use-exam-preview-state, use-exam-preview-tasks,

  use-exam-preview)

- files: update data-access, use-file-batch-operations, use-file-upload

- grades: update batch-grade-entry, excel-import-dialog, export-button,

  grade-record-form, grade-record-list, knowledge-point-mastery-chart,

  report-card-print-action, report-card-print-button, data-access-analytics,

  use-batch-grade-entry-undo, use-draft-lock

- homework: update homework-assignment-form, homework-batch-grading-view,

  homework-grading-view, homework-scan-grading-view, homework-take-view, scan-uploader

- invitation-codes: update generate-invitation-codes-dialog, invitation-codes-view, data-access

- leave-requests: update leave-request-form, leave-review-dialog, data-access
2026-07-07 16:19:46 +08:00

771 lines
26 KiB
TypeScript

"use client"
import React, { useCallback, useMemo, useState } from "react"
import {
DndContext,
pointerWithin,
rectIntersection,
CollisionDetection,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
defaultDropAnimationSideEffects,
DragStartEvent,
DragOverEvent,
DragEndEvent,
DropAnimation,
MeasuringStrategy,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible"
import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { useTranslations } from "next-intl"
import type { ExamNode } from "./selected-question-list"
import { isRecord, isQuestionContentObj, toSortableId } from "../../lib/type-guards"
// --- Types ---
type StructureEditorProps = {
items: ExamNode[]
onChange: (items: ExamNode[]) => void
onScoreChange: (id: string, score: number) => void
onGroupTitleChange: (id: string, title: string) => void
onRemove: (id: string) => void
onAddGroup: () => void
onAddSection?: () => void
}
function cloneExamNodes(nodes: ExamNode[]): ExamNode[] {
return nodes.map((node) => {
if (node.type === "group" || node.type === "section") {
return { ...node, children: cloneExamNodes(node.children || []) }
}
return { ...node }
})
}
// Safely extract a text preview from a question's content (which may be a string,
// object, or JSON string). Avoids `as` assertions by runtime narrowing.
const extractQuestionText = (raw: unknown): string => {
if (!raw) return ""
if (typeof raw === "string") {
// Content might be a JSON string or plain text
try {
const parsed: unknown = JSON.parse(raw)
if (isRecord(parsed) && typeof parsed.text === "string") {
return parsed.text
}
return raw
} catch {
return raw
}
}
if (isRecord(raw) && typeof raw.text === "string") {
return raw.text
}
return ""
}
// --- Components ---
function SortableItem({
id,
item,
onRemove,
onScoreChange
}: {
id: string
item: ExamNode
onRemove: () => void
onScoreChange: (val: number) => void
}) {
const t = useTranslations("examHomework.exam.build")
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const rawContent = item.question?.content
const parsedContent = (() => {
if (isQuestionContentObj(rawContent)) return rawContent
if (typeof rawContent === "string") {
try {
const parsed: unknown = JSON.parse(rawContent)
if (isQuestionContentObj(parsed)) return parsed
return { text: rawContent }
} catch {
return { text: rawContent }
}
}
return { text: "" }
})()
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
return (
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
<div className="flex items-start justify-between gap-3">
<div className="flex gap-2 items-start flex-1">
<button {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
<GripVertical className="h-4 w-4" />
</button>
<p className="text-sm line-clamp-2 pt-0.5 select-none">
{parsedContent.text || t("questionContent")}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 pl-8 text-xs text-muted-foreground">
{options.map((opt, idx) => (
<div key={opt.id ?? idx} className="flex gap-2">
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
<span>{opt.text ?? ""}</span>
</div>
))}
</div>
)}
<div className="flex items-center justify-end pl-8">
<div className="flex items-center gap-2">
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
{t("score")}
</Label>
<Input
id={`score-${item.id}`}
type="number"
min={0}
className="h-7 w-16 text-right"
value={item.score}
onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
</div>
)
}
function SortableGroup({
id,
item,
children,
onRemove,
onTitleChange
}: {
id: string
item: ExamNode
children: React.ReactNode
onRemove: () => void
onTitleChange: (val: string) => void
}) {
const t = useTranslations("examHomework.exam.build")
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const [isOpen, setIsOpen] = useState(true)
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const childrenKey = JSON.stringify(item.children || [])
const totalScore = useMemo(() => {
const calc = (nodes: ExamNode[]): number => {
return nodes.reduce((acc, node) => {
if (node.type === 'question') return acc + (node.score || 0)
if (node.type === 'group') return acc + calc(node.children || [])
return acc
}, 0)
}
return calc(item.children || [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [childrenKey])
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} ref={setNodeRef} style={style} className={cn("rounded-lg border bg-muted/10 p-3 space-y-2", isDragging && "ring-2 ring-primary")}>
<div className="flex items-center gap-3">
<button {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
<GripVertical className="h-5 w-5" />
</button>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 h-6 w-6 hover:bg-transparent">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<Input
value={item.title || ""}
onChange={(e) => onTitleChange(e.target.value)}
placeholder={t("groupTitlePlaceholder")}
className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background flex-1"
/>
<div className="flex items-center gap-1 text-muted-foreground text-xs bg-background/50 px-2 py-1 rounded">
<Calculator className="h-3 w-3" />
<span>{totalScore} {t("pts")}</span>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={onRemove}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<CollapsibleContent className="pl-4 border-l-2 border-muted space-y-3 min-h-[50px] animate-in slide-in-from-top-2 fade-in duration-200">
{children}
</CollapsibleContent>
</Collapsible>
)
}
function SortableSection({
id,
item,
children,
onRemove,
onTitleChange
}: {
id: string
item: ExamNode
children: React.ReactNode
onRemove: () => void
onTitleChange: (val: string) => void
}) {
const t = useTranslations("examHomework.exam.build")
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const [isOpen, setIsOpen] = useState(true)
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const childrenKey = JSON.stringify(item.children || [])
const totalScore = useMemo(() => {
const calc = (nodes: ExamNode[]): number => {
return nodes.reduce((acc, node) => {
if (node.type === 'question') return acc + (node.score || 0)
if (node.type === 'group' || node.type === 'section') return acc + calc(node.children || [])
return acc
}, 0)
}
return calc(item.children || [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [childrenKey])
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} ref={setNodeRef} style={style} className={cn("rounded-lg border-2 border-primary/20 bg-primary/5 p-3 space-y-2", isDragging && "ring-2 ring-primary")}>
<div className="flex items-center gap-3">
<button {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
<GripVertical className="h-5 w-5" />
</button>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 h-6 w-6 hover:bg-transparent">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<Input
value={item.title || ""}
onChange={(e) => onTitleChange(e.target.value)}
placeholder={t("sectionTitlePlaceholder")}
className="font-bold h-9 bg-transparent border-transparent hover:border-input focus:bg-background flex-1"
/>
<div className="flex items-center gap-1 text-muted-foreground text-xs bg-background/50 px-2 py-1 rounded">
<Calculator className="h-3 w-3" />
<span>{totalScore} {t("pts")}</span>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={onRemove}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<CollapsibleContent className="pl-4 border-l-2 border-primary/30 space-y-3 min-h-[50px] animate-in slide-in-from-top-2 fade-in duration-200">
{children}
</CollapsibleContent>
</Collapsible>
)
}
function StructureRenderer({ nodes, ...props }: {
nodes: ExamNode[]
onRemove: (id: string) => void
onScoreChange: (id: string, score: number) => void
onGroupTitleChange: (id: string, title: string) => void
}) {
const t = useTranslations("examHomework.exam.build")
// Deduplicate nodes to prevent React key errors
const nodesKey = JSON.stringify(nodes.map(n => n.id))
const uniqueNodes = useMemo(() => {
const seen = new Set()
return nodes.filter(n => {
if (seen.has(n.id)) return false
seen.add(n.id)
return true
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodesKey])
return (
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
{uniqueNodes.map(node => (
<div key={node.id}>
{node.type === 'section' ? (
<SortableSection
id={node.id}
item={node}
onRemove={() => props.onRemove(node.id)}
onTitleChange={(val) => props.onGroupTitleChange(node.id, val)}
>
<StructureRenderer
nodes={node.children || []}
onRemove={props.onRemove}
onScoreChange={props.onScoreChange}
onGroupTitleChange={props.onGroupTitleChange}
/>
{(!node.children || node.children.length === 0) && (
<div className="text-xs text-muted-foreground italic py-2 text-center border-2 border-dashed border-muted/50 rounded">
{t("dragItemsHere")}
</div>
)}
</SortableSection>
) : node.type === 'group' ? (
<SortableGroup
id={node.id}
item={node}
onRemove={() => props.onRemove(node.id)}
onTitleChange={(val) => props.onGroupTitleChange(node.id, val)}
>
<StructureRenderer
nodes={node.children || []}
onRemove={props.onRemove}
onScoreChange={props.onScoreChange}
onGroupTitleChange={props.onGroupTitleChange}
/>
{(!node.children || node.children.length === 0) && (
<div className="text-xs text-muted-foreground italic py-2 text-center border-2 border-dashed border-muted/50 rounded">
{t("dragItemsHere")}
</div>
)}
</SortableGroup>
) : (
<SortableItem
id={node.id}
item={node}
onRemove={() => props.onRemove(node.id)}
onScoreChange={(val) => props.onScoreChange(node.id, val)}
/>
)}
</div>
))}
</SortableContext>
)
}
// --- Main Component ---
const dropAnimation: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0.5',
},
},
}),
}
export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup, onAddSection }: StructureEditorProps) {
const t = useTranslations("examHomework.exam.build")
const [activeId, setActiveId] = useState<string | null>(null)
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// Recursively find item
const findItem = useCallback((id: string, nodes: ExamNode[] = items): ExamNode | null => {
const walk = (list: ExamNode[]): ExamNode | null => {
for (const node of list) {
if (node.id === id) return node
if (node.children) {
const found = walk(node.children)
if (found) return found
}
}
return null
}
return walk(nodes)
}, [items])
const activeItem = activeId ? findItem(activeId) : null
// DND Handlers
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string)
}, [])
// Custom collision detection for nested sortables
const customCollisionDetection: CollisionDetection = useCallback((args) => {
// 1. First check pointer within for precise container detection
const pointerCollisions = pointerWithin(args)
// If we have pointer collisions, prioritize the most specific one (usually the smallest/innermost container)
if (pointerCollisions.length > 0) {
return pointerCollisions
}
// 2. Fallback to rect intersection for smoother sortable reordering when not directly over a container
return rectIntersection(args)
}, [])
function handleDragOver(event: DragOverEvent) {
const { active, over } = event
if (!over) return
const activeId = active.id as string
const overId = toSortableId(over.id)
if (activeId === overId) return
// Find if we are moving over a Group container
// "overId" could be a SortableItem (Question) OR a SortableGroup (Group)
const activeNode = findItem(activeId)
const overNode = findItem(overId)
if (!activeNode || !overNode) return
// CRITICAL FIX: Prevent dragging a node onto its own descendant
// This happens when dragging a group and hovering over its own children.
// If we proceed, we would remove the group (and its children) and then fail to find the child to insert next to.
const isDescendantOfActive = (childId: string): boolean => {
const check = (node: ExamNode): boolean => {
if (!node.children) return false
return node.children.some(c => c.id === childId || check(c))
}
return check(activeNode)
}
if (isDescendantOfActive(overId)) return
// Find which list the `over` item belongs to
const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => {
if (list.some(i => i.id === id)) return parentId
for (const node of list) {
if (node.children) {
const res = findContainerId(id, node.children, node.id)
if (res) return res
}
}
return undefined
}
const activeContainerId = findContainerId(activeId, items)
const overContainerId = findContainerId(overId, items)
// Scenario 1: Moving item into a Group/Section by hovering over the Group/Section itself
// If overNode is a Group or Section, we might want to move INTO it
if (overNode.type === 'group' || overNode.type === 'section') {
// Logic: If active item is NOT in this container already
// AND we are not trying to move a container into its own descendant (circular check)
const isDescendant = (parent: ExamNode, childId: string): boolean => {
if (!parent.children) return false
for (const c of parent.children) {
if (c.id === childId) return true
if (isDescendant(c, childId)) return true
}
return false
}
// If moving a container, check if overNode is a descendant of activeNode
if ((activeNode.type === 'group' || activeNode.type === 'section') && isDescendant(activeNode, overNode.id)) {
return
}
if (activeContainerId !== overNode.id) {
// ... implementation continues ...
const newItems = cloneExamNodes(items)
// Remove active from old location
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
const idx = list.findIndex(i => i.id === activeId)
if (idx !== -1) return list.splice(idx, 1)[0]
for (const node of list) {
if (node.children) {
const res = removeRecursive(node.children)
if (res) return res
}
}
return null
}
const movedItem = removeRecursive(newItems)
if (!movedItem) return
// Insert into new Group (overNode)
// We need to find the overNode in the NEW structure (since we cloned it)
const findGroupAndInsert = (list: ExamNode[]) => {
for (const node of list) {
if (node.id === overId) {
if (!node.children) node.children = []
// 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) {
if (findGroupAndInsert(node.children)) return true
}
}
return false
}
findGroupAndInsert(newItems)
onChange(newItems)
return
}
}
// 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 = cloneExamNodes(items)
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
const idx = list.findIndex(i => i.id === activeId)
if (idx !== -1) return list.splice(idx, 1)[0]
for (const node of list) {
if (node.children) {
const res = removeRecursive(node.children)
if (res) return res
}
}
return null
}
const movedItem = removeRecursive(newItems)
if (!movedItem) return
// Insert into destination list at specific index
// We need to find the destination list array and the index of `overId`
const insertRecursive = (list: ExamNode[]): boolean => {
const idx = list.findIndex(i => i.id === overId)
if (idx !== -1) {
// Insert before or after based on direction?
// Usually dnd-kit handles order if we are in same container, but cross-container we need to pick a spot.
// We'll insert at the index of `overId`.
// However, if we insert AT the index, dnd-kit might get confused if we are dragging DOWN vs UP.
// But since we are changing containers, just inserting at the target index is usually fine.
// The issue "swapping positions is not smooth" might be because we insert *at* index, displacing the target.
// Let's try to determine if we are "below" or "above" the target?
// For cross-container, simpler is better. Inserting at index is standard.
list.splice(idx, 0, movedItem)
return true
}
for (const node of list) {
if (node.children) {
if (insertRecursive(node.children)) return true
}
}
return false
}
insertRecursive(newItems)
onChange(newItems)
}
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
setActiveId(null)
if (!over) return
const activeId = active.id as string
const overId = toSortableId(over.id)
if (activeId === overId) return
// Re-find positions in the potentially updated state
// Note: Since we mutate in DragOver, the item might already be in the new container.
// So activeContainerId might equal overContainerId now!
const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => {
if (list.some(i => i.id === id)) return parentId
for (const node of list) {
if (node.children) {
const res = findContainerId(id, node.children, node.id)
if (res) return res
}
}
return undefined
}
const activeContainerId = findContainerId(activeId, items)
const overContainerId = findContainerId(overId, items)
if (activeContainerId === overContainerId) {
// Same container reorder
const newItems = cloneExamNodes(items)
const getMutableList = (groupId?: string): ExamNode[] => {
if (groupId === 'root') return newItems
// Need recursive find
const findGroup = (list: ExamNode[]): ExamNode | null => {
for (const node of list) {
if (node.id === groupId) return node
if (node.children) {
const res = findGroup(node.children)
if (res) return res
}
}
return null
}
return findGroup(newItems)?.children || []
}
const list = getMutableList(activeContainerId)
const oldIndex = list.findIndex(i => i.id === activeId)
const newIndex = list.findIndex(i => i.id === overId)
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const moved = arrayMove(list, oldIndex, newIndex)
// Update the list reference in parent
if (activeContainerId === 'root') {
onChange(moved)
} else if (activeContainerId) {
// list is already a reference to children array if we did it right?
// getMutableList returned `group.children`. Modifying `list` directly via arrayMove returns NEW array.
// So we need to re-assign.
const group = findItem(activeContainerId, newItems)
if (group) group.children = moved
onChange(newItems)
}
}
}
}
return (
<DndContext
id="structure-editor-dnd"
sensors={sensors}
collisionDetection={customCollisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
>
<div className="space-y-4">
<StructureRenderer
nodes={items}
onRemove={onRemove}
onScoreChange={onScoreChange}
onGroupTitleChange={onGroupTitleChange}
/>
<div className="flex justify-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onAddGroup} className="border-dashed">
{t("addGroup")}
</Button>
{onAddSection && (
<Button variant="outline" size="sm" onClick={onAddSection} className="border-dashed">
{t("addSection")}
</Button>
)}
</div>
</div>
<DragOverlay dropAnimation={dropAnimation}>
{activeItem ? (
activeItem.type === 'section' ? (
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4 shadow-lg opacity-80 w-[300px]">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5" />
<span className="font-bold">{activeItem.title || t("section")}</span>
</div>
</div>
) : activeItem.type === 'group' ? (
<div className="rounded-lg border bg-background p-4 shadow-lg opacity-80 w-[300px]">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5" />
<span className="font-semibold">{activeItem.title || t("group")}</span>
</div>
</div>
) : (
<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">
{extractQuestionText(activeItem.question?.content) || t("question")}
</p>
</div>
)
) : null}
</DragOverlay>
</DndContext>
)
}