feat(P2): 实现选课管理、考试监考、学情诊断三大功能模块
## 新增功能模块 ### 1. 选课管理(elective) - 新增表:electiveCourses、courseSelections - 新增权限:ELECTIVE_MANAGE/ELECTIVE_READ/ELECTIVE_SELECT - 支持先到先得 + 抽签两种选课模式 - admin/teacher/student 三端页面 ### 2. 考试监考(proctoring) - exams 表扩展:examMode/durationMinutes/antiCheatEnabled 等字段 - 新增表:examProctoringEvents - 新增权限:EXAM_PROCTOR/EXAM_PROCTOR_READ - 教师监考面板 + 学生端防作弊监控 - API:/api/proctoring/event 接收事件上报 ### 3. 学情诊断报告(diagnostic) - 新增表:knowledgePointMastery、learningDiagnosticReports - 新增权限:DIAGNOSTIC_MANAGE/DIAGNOSTIC_READ - 基于提交答案自动计算知识点掌握度 - 生成个人/班级诊断报告(强项/弱项/建议) - 雷达图可视化 ## 其他改动 - 项目规则:单文件行数限制从 300 行调整为企业级规范(组件≤500/Actions≤800/硬上限1000) - scripts/seed.ts:消除全部 any 类型,定义内部类型,0 lint 错误 - 架构文档 004/005 同步更新三个新模块 - 迁移文件 0001_heavy_sage.sql 生成 ## 验证 - npx tsc --noEmit:0 错误 - npm run lint:0 错误 0 警告
This commit is contained in:
293
src/modules/elective/components/elective-course-form.tsx
Normal file
293
src/modules/elective/components/elective-course-form.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
|
||||
import type { ElectiveCourseWithDetails } from "../types"
|
||||
|
||||
type Mode = "create" | "edit"
|
||||
|
||||
interface Option {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function ElectiveCourseForm({
|
||||
mode,
|
||||
course,
|
||||
subjects = [],
|
||||
grades = [],
|
||||
teachers = [],
|
||||
backHref,
|
||||
}: {
|
||||
mode: Mode
|
||||
course?: ElectiveCourseWithDetails
|
||||
subjects?: Option[]
|
||||
grades?: Option[]
|
||||
teachers?: Option[]
|
||||
backHref?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const [subjectId, setSubjectId] = useState(course?.subjectId ?? "")
|
||||
const [gradeId, setGradeId] = useState(course?.gradeId ?? "")
|
||||
const [teacherId, setTeacherId] = useState(course?.teacherId ?? "")
|
||||
const [selectionMode, setSelectionMode] = useState(course?.selectionMode ?? "fcfs")
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("subjectId", subjectId)
|
||||
formData.set("gradeId", gradeId)
|
||||
formData.set("teacherId", teacherId)
|
||||
formData.set("selectionMode", selectionMode)
|
||||
|
||||
const res =
|
||||
mode === "create"
|
||||
? await createElectiveCourseAction(null, formData)
|
||||
: course
|
||||
? await updateElectiveCourseAction(course.id, null, formData)
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error("Invalid form state")
|
||||
return
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective"
|
||||
router.push(redirectBase)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to save course")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save course")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{mode === "create" ? "New Elective Course" : "Edit Elective Course"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Course Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={course?.name ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Subject</Label>
|
||||
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a subject" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="subjectId" value={subjectId} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Grade</Label>
|
||||
<Select value={gradeId} onValueChange={setGradeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a grade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{grades.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="gradeId" value={gradeId} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Teacher</Label>
|
||||
<Select value={teacherId} onValueChange={setTeacherId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a teacher" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="teacherId" value={teacherId} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="capacity">Capacity</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
defaultValue={course?.capacity ?? 30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="classroom">Classroom</Label>
|
||||
<Input
|
||||
id="classroom"
|
||||
name="classroom"
|
||||
defaultValue={course?.classroom ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="schedule">Schedule</Label>
|
||||
<Input
|
||||
id="schedule"
|
||||
name="schedule"
|
||||
placeholder="e.g. Mon 14:00-15:30"
|
||||
defaultValue={course?.schedule ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="credit">Credit</Label>
|
||||
<Input
|
||||
id="credit"
|
||||
name="credit"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min={0}
|
||||
defaultValue={course?.credit ?? "1.0"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Selection Mode</Label>
|
||||
<Select value={selectionMode} onValueChange={(v) => setSelectionMode(v as "fcfs" | "lottery")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fcfs">First Come First Served</SelectItem>
|
||||
<SelectItem value="lottery">Lottery</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="selectionMode" value={selectionMode} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Start Date</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
type="date"
|
||||
defaultValue={course?.startDate ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">End Date</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="date"
|
||||
defaultValue={course?.endDate ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="selectionStartAt">Selection Start</Label>
|
||||
<Input
|
||||
id="selectionStartAt"
|
||||
name="selectionStartAt"
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
course?.selectionStartAt
|
||||
? new Date(course.selectionStartAt).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="selectionEndAt">Selection End</Label>
|
||||
<Input
|
||||
id="selectionEndAt"
|
||||
name="selectionEndAt"
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
course?.selectionEndAt
|
||||
? new Date(course.selectionEndAt).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Course description..."
|
||||
className="min-h-[80px]"
|
||||
defaultValue={course?.description ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardFooter className="justify-end gap-2 px-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(backHref ?? "/admin/elective")}
|
||||
disabled={isWorking}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
233
src/modules/elective/components/elective-course-list.tsx
Normal file
233
src/modules/elective/components/elective-course-list.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import {
|
||||
ELECTIVE_STATUS_COLORS,
|
||||
ELECTIVE_STATUS_LABELS,
|
||||
SELECTION_MODE_LABELS,
|
||||
} from "../types"
|
||||
import type { ElectiveCourseWithDetails } from "../types"
|
||||
import {
|
||||
deleteElectiveCourseAction,
|
||||
openSelectionAction,
|
||||
closeSelectionAction,
|
||||
runLotteryAction,
|
||||
} from "../actions"
|
||||
|
||||
export function ElectiveCourseList({
|
||||
courses,
|
||||
createHref,
|
||||
editHrefBuilder,
|
||||
canManage,
|
||||
}: {
|
||||
courses: ElectiveCourseWithDetails[]
|
||||
createHref?: string
|
||||
editHrefBuilder?: (id: string) => string
|
||||
canManage?: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const manageResolved = canManage ?? hasPermission(Permissions.ELECTIVE_MANAGE)
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const runAction = async (
|
||||
action: (prevState: never, formData: FormData) => Promise<{ success: boolean; message?: string }>,
|
||||
courseId: string,
|
||||
successMsg: string
|
||||
) => {
|
||||
setPendingId(courseId)
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.set("courseId", courseId)
|
||||
const res = await action(null as never, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? successMsg)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Operation failed")
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = (courseId: string) => {
|
||||
setPendingId(courseId)
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.set("courseId", courseId)
|
||||
const res = await deleteElectiveCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? "Course deleted")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Delete failed")
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{courses.length} course{courses.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
{manageResolved && createHref ? (
|
||||
<Button asChild>
|
||||
<a href={createHref}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Course
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No elective courses"
|
||||
description="There are no elective courses available."
|
||||
icon={Plus}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{courses.map((course) => {
|
||||
const isFull = course.enrolledCount >= course.capacity
|
||||
const isPendingThis = isPending && pendingId === course.id
|
||||
return (
|
||||
<Card key={course.id} className="flex h-full flex-col">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||
<Badge variant={ELECTIVE_STATUS_COLORS[course.status]} className="shrink-0">
|
||||
{ELECTIVE_STATUS_LABELS[course.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{course.subjectName ? (
|
||||
<Badge variant="outline">{course.subjectName}</Badge>
|
||||
) : null}
|
||||
{course.gradeName ? (
|
||||
<Badge variant="outline">{course.gradeName}</Badge>
|
||||
) : null}
|
||||
<span>Credit: {course.credit}</span>
|
||||
</div>
|
||||
|
||||
{course.description ? (
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{course.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Teacher:</span>{" "}
|
||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Mode:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{SELECTION_MODE_LABELS[course.selectionMode]}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Capacity:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{course.enrolledCount}/{course.capacity}
|
||||
{isFull ? " (Full)" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{course.classroom ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Room:</span>{" "}
|
||||
<span className="font-medium">{course.classroom}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{course.schedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Schedule:</span> {course.schedule}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{manageResolved ? (
|
||||
<div className="mt-auto flex flex-wrap gap-2 pt-2">
|
||||
{editHrefBuilder ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<a href={editHrefBuilder(course.id)}>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
Edit
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{course.status === "draft" || course.status === "closed" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => runAction(openSelectionAction, course.id, "Selection opened")}
|
||||
>
|
||||
<Unlock className="mr-1 h-3 w-3" />
|
||||
Open
|
||||
</Button>
|
||||
) : null}
|
||||
{course.status === "open" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => runAction(closeSelectionAction, course.id, "Selection closed")}
|
||||
>
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
Close
|
||||
</Button>
|
||||
) : null}
|
||||
{course.selectionMode === "lottery" && course.status !== "draft" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => runAction(runLotteryAction, course.id, "Lottery completed")}
|
||||
>
|
||||
<Shuffle className="mr-1 h-3 w-3" />
|
||||
Lottery
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => handleDelete(course.id)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
215
src/modules/elective/components/student-selection-view.tsx
Normal file
215
src/modules/elective/components/student-selection-view.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
import {
|
||||
COURSE_SELECTION_STATUS_COLORS,
|
||||
COURSE_SELECTION_STATUS_LABELS,
|
||||
ELECTIVE_STATUS_LABELS,
|
||||
SELECTION_MODE_LABELS,
|
||||
} from "../types"
|
||||
import type {
|
||||
CourseSelectionWithDetails,
|
||||
ElectiveCourseWithDetails,
|
||||
} from "../types"
|
||||
import { selectCourseAction, dropCourseAction } from "../actions"
|
||||
|
||||
export function StudentSelectionView({
|
||||
availableCourses,
|
||||
mySelections,
|
||||
}: {
|
||||
availableCourses: ElectiveCourseWithDetails[]
|
||||
mySelections: CourseSelectionWithDetails[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const activeSelections = mySelections.filter((s) =>
|
||||
["selected", "enrolled", "waitlist"].includes(s.status)
|
||||
)
|
||||
const selectedCourseIds = new Set(
|
||||
activeSelections.map((s) => s.courseId)
|
||||
)
|
||||
|
||||
const handleSelect = (courseId: string) => {
|
||||
setPendingId(courseId)
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.set("courseId", courseId)
|
||||
const res = await selectCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Failed to select course")
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
}
|
||||
|
||||
const handleDrop = (courseId: string) => {
|
||||
setPendingId(courseId)
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.set("courseId", courseId)
|
||||
const res = await dropCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Failed to drop course")
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">My Selections</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activeSelections.length} active
|
||||
</span>
|
||||
</div>
|
||||
{activeSelections.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No selections yet"
|
||||
description="Browse available courses below and select your electives."
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{activeSelections.map((sel) => (
|
||||
<Card key={sel.id}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
{sel.courseName ?? "Unknown course"}
|
||||
</CardTitle>
|
||||
<Badge variant={COURSE_SELECTION_STATUS_COLORS[sel.status]}>
|
||||
{COURSE_SELECTION_STATUS_LABELS[sel.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enrolled: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
</p>
|
||||
) : null}
|
||||
{sel.lotteryRank ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lottery rank: #{sel.lotteryRank}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPending && pendingId === sel.courseId}
|
||||
onClick={() => handleDrop(sel.courseId)}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
Drop
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Available Courses</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{availableCourses.length} open
|
||||
</span>
|
||||
</div>
|
||||
{availableCourses.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No available courses"
|
||||
description="There are no elective courses open for selection right now."
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{availableCourses.map((course) => {
|
||||
const isFull = course.enrolledCount >= course.capacity
|
||||
const alreadySelected = selectedCourseIds.has(course.id)
|
||||
const isPendingThis = isPending && pendingId === course.id
|
||||
return (
|
||||
<Card key={course.id} className="flex h-full flex-col">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||
<Badge variant="outline">
|
||||
{ELECTIVE_STATUS_LABELS[course.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{course.subjectName ? (
|
||||
<Badge variant="outline">{course.subjectName}</Badge>
|
||||
) : null}
|
||||
<span>Credit: {course.credit}</span>
|
||||
<span>· {SELECTION_MODE_LABELS[course.selectionMode]}</span>
|
||||
</div>
|
||||
{course.description ? (
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{course.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Teacher:</span>{" "}
|
||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Capacity:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{course.enrolledCount}/{course.capacity}
|
||||
{isFull ? " (Full)" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{course.schedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Schedule:</span> {course.schedule}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-auto pt-2">
|
||||
{alreadySelected ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Already selected
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => handleSelect(course.id)}
|
||||
>
|
||||
{isPendingThis ? "Selecting..." : "Select"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user