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:
SpecialX
2026-06-17 19:12:51 +08:00
parent baf8f679bf
commit b86255f0ea
46 changed files with 13234 additions and 80 deletions

View File

@@ -0,0 +1,304 @@
"use server"
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import {
CreateElectiveCourseSchema,
UpdateElectiveCourseSchema,
SelectCourseSchema,
DropCourseSchema,
RunLotterySchema,
} from "./schema"
import {
getElectiveCourses,
getElectiveCourseById,
createElectiveCourse,
updateElectiveCourse,
deleteElectiveCourse,
openSelection,
closeSelection,
} from "./data-access"
import { runLottery, selectCourse, dropCourse } from "./data-access-operations"
import {
getStudentSelections,
getAvailableCoursesForStudent,
} from "./data-access-selections"
import type {
ElectiveCourseWithDetails,
CourseSelectionWithDetails,
GetElectiveCoursesParams,
} from "./types"
const handleError = (e: unknown): ActionState<never> => {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
const revalidateElectivePaths = (id?: string) => {
revalidatePath("/admin/elective")
revalidatePath("/teacher/elective")
revalidatePath("/student/elective")
if (id) {
revalidatePath(`/admin/elective/${id}`)
revalidatePath(`/admin/elective/${id}/edit`)
}
}
const requireCourseId = (formData: FormData): string => {
const id = String(formData.get("courseId") ?? "")
if (!id) throw new Error("Course ID is required")
return id
}
export async function createElectiveCourseAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const parsed = CreateElectiveCourseSchema.safeParse({
name: formData.get("name"),
subjectId: formData.get("subjectId") || undefined,
teacherId: formData.get("teacherId") || ctx.userId,
gradeId: formData.get("gradeId") || undefined,
description: formData.get("description") || undefined,
capacity: formData.get("capacity") || undefined,
classroom: formData.get("classroom") || undefined,
schedule: formData.get("schedule") || undefined,
startDate: formData.get("startDate") || undefined,
endDate: formData.get("endDate") || undefined,
selectionStartAt: formData.get("selectionStartAt") || undefined,
selectionEndAt: formData.get("selectionEndAt") || undefined,
selectionMode: formData.get("selectionMode") || undefined,
credit: formData.get("credit") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await createElectiveCourse(parsed.data, ctx.userId)
revalidateElectivePaths(id)
return { success: true, message: "Elective course created", data: id }
} catch (e) {
return handleError(e)
}
}
export async function updateElectiveCourseAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const existing = await getElectiveCourseById(id)
if (!existing) return { success: false, message: "Course not found" }
const parsed = UpdateElectiveCourseSchema.safeParse({
name: formData.get("name") || undefined,
subjectId: formData.get("subjectId") || undefined,
teacherId: formData.get("teacherId") || undefined,
gradeId: formData.get("gradeId") || undefined,
description: formData.get("description") || undefined,
capacity: formData.get("capacity") || undefined,
classroom: formData.get("classroom") || undefined,
schedule: formData.get("schedule") || undefined,
startDate: formData.get("startDate") || undefined,
endDate: formData.get("endDate") || undefined,
selectionStartAt: formData.get("selectionStartAt") || undefined,
selectionEndAt: formData.get("selectionEndAt") || undefined,
status: formData.get("status") || undefined,
selectionMode: formData.get("selectionMode") || undefined,
credit: formData.get("credit") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
await updateElectiveCourse(id, parsed.data)
revalidateElectivePaths(id)
return { success: true, message: "Elective course updated", data: id }
} catch (e) {
return handleError(e)
}
}
export async function deleteElectiveCourseAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const id = requireCourseId(formData)
const existing = await getElectiveCourseById(id)
if (!existing) return { success: false, message: "Course not found" }
await deleteElectiveCourse(id)
revalidateElectivePaths()
return { success: true, message: "Elective course deleted" }
} catch (e) {
return handleError(e)
}
}
export async function openSelectionAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const courseId = requireCourseId(formData)
await openSelection(courseId)
revalidateElectivePaths(courseId)
return { success: true, message: "Selection opened" }
} catch (e) {
return handleError(e)
}
}
export async function closeSelectionAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const courseId = requireCourseId(formData)
await closeSelection(courseId)
revalidateElectivePaths(courseId)
return { success: true, message: "Selection closed" }
} catch (e) {
return handleError(e)
}
}
export async function runLotteryAction(
prevState: ActionState<{ enrolled: number; waitlist: number }> | null,
formData: FormData
): Promise<ActionState<{ enrolled: number; waitlist: number }>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const parsed = RunLotterySchema.safeParse({
courseId: formData.get("courseId"),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const result = await runLottery(parsed.data.courseId)
revalidateElectivePaths(parsed.data.courseId)
return {
success: true,
message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`,
data: result,
}
} catch (e) {
return handleError(e)
}
}
export async function selectCourseAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
const parsed = SelectCourseSchema.safeParse({
courseId: formData.get("courseId"),
priority: formData.get("priority") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const result = await selectCourse(parsed.data.courseId, ctx.userId, parsed.data.priority)
revalidateElectivePaths(parsed.data.courseId)
return { success: true, message: result.message, data: result.status }
} catch (e) {
return handleError(e)
}
}
export async function dropCourseAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
const parsed = DropCourseSchema.safeParse({
courseId: formData.get("courseId"),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
await dropCourse(parsed.data.courseId, ctx.userId)
revalidateElectivePaths(parsed.data.courseId)
return { success: true, message: "Course dropped" }
} catch (e) {
return handleError(e)
}
}
export async function getElectiveCoursesAction(
params?: GetElectiveCoursesParams
): Promise<ActionState<ElectiveCourseWithDetails[]>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
const data = await getElectiveCourses({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
return { success: true, data }
} catch (e) {
return handleError(e)
}
}
export async function getStudentSelectionsAction(
studentId: string
): Promise<ActionState<CourseSelectionWithDetails[]>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
return { success: false, message: "Can only view your own selections" }
}
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
return { success: false, message: "Can only view your children's selections" }
}
const data = await getStudentSelections(studentId)
return { success: true, data }
} catch (e) {
return handleError(e)
}
}
export async function getAvailableCoursesAction(): Promise<ActionState<ElectiveCourseWithDetails[]>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
const data = await getAvailableCoursesForStudent(ctx.userId)
return { success: true, data }
} catch (e) {
return handleError(e)
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,217 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, asc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
courseSelections,
electiveCourses,
} from "@/shared/db/schema"
import type { CourseSelectionStatus } from "./types"
export async function runLottery(courseId: string): Promise<{
enrolled: number
waitlist: number
}> {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
if (!course) throw new Error("Course not found")
const selections = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.status, "selected")
)
)
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
if (selections.length === 0) {
return { enrolled: 0, waitlist: 0 }
}
const shuffled = [...selections].sort(() => Math.random() - 0.5)
const capacity = course.capacity
const now = new Date()
let enrolledCount = 0
let waitlistCount = 0
for (let i = 0; i < shuffled.length; i++) {
const sel = shuffled[i]
const rank = i + 1
if (i < capacity) {
await db
.update(courseSelections)
.set({
status: "enrolled",
lotteryRank: rank,
enrolledAt: now,
updatedAt: now,
})
.where(eq(courseSelections.id, sel.id))
enrolledCount++
} else {
await db
.update(courseSelections)
.set({
status: "waitlist",
lotteryRank: rank,
updatedAt: now,
})
.where(eq(courseSelections.id, sel.id))
waitlistCount++
}
}
await db
.update(electiveCourses)
.set({ enrolledCount, status: "closed", updatedAt: now })
.where(eq(electiveCourses.id, courseId))
return { enrolled: enrolledCount, waitlist: waitlistCount }
}
export async function selectCourse(
courseId: string,
studentId: string,
priority?: number
): Promise<{ status: CourseSelectionStatus; message: string }> {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
if (!course) throw new Error("Course not found")
if (course.status !== "open") throw new Error("Course selection is not open")
const now = new Date()
if (course.selectionStartAt && now < course.selectionStartAt) {
throw new Error("Selection has not started yet")
}
if (course.selectionEndAt && now > course.selectionEndAt) {
throw new Error("Selection has ended")
}
const [existing] = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
.limit(1)
if (existing) throw new Error("Already selected this course")
const id = createId()
let status: CourseSelectionStatus = "selected"
let enrolledAt: Date | null = null
if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) {
status = "enrolled"
enrolledAt = now
await db
.update(electiveCourses)
.set({
enrolledCount: course.enrolledCount + 1,
updatedAt: now,
})
.where(eq(electiveCourses.id, courseId))
} else if (course.selectionMode === "fcfs") {
status = "waitlist"
}
await db.insert(courseSelections).values({
id,
courseId,
studentId,
status,
priority: priority ?? 1,
selectedAt: now,
enrolledAt,
})
return {
status,
message:
status === "enrolled"
? "Enrolled successfully"
: status === "waitlist"
? "Added to waitlist"
: "Selection submitted",
}
}
export async function dropCourse(
courseId: string,
studentId: string
): Promise<void> {
const [existing] = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
.limit(1)
if (!existing) throw new Error("No active selection found")
const now = new Date()
await db
.update(courseSelections)
.set({ status: "dropped", droppedAt: now, updatedAt: now })
.where(eq(courseSelections.id, existing.id))
if (existing.status === "enrolled") {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
if (course && course.selectionMode === "fcfs") {
const newEnrolledCount = Math.max(0, course.enrolledCount - 1)
await db
.update(electiveCourses)
.set({ enrolledCount: newEnrolledCount, updatedAt: now })
.where(eq(electiveCourses.id, courseId))
const [nextWait] = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.status, "waitlist")
)
)
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
.limit(1)
if (nextWait) {
await db
.update(courseSelections)
.set({
status: "enrolled",
enrolledAt: now,
updatedAt: now,
})
.where(eq(courseSelections.id, nextWait.id))
await db
.update(electiveCourses)
.set({ enrolledCount: newEnrolledCount + 1, updatedAt: now })
.where(eq(electiveCourses.id, courseId))
}
}
}
}

View File

@@ -0,0 +1,189 @@
import "server-only"
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
courseSelections,
electiveCourses,
grades,
subjects,
users,
} from "@/shared/db/schema"
import type {
CourseSelectionStatus,
CourseSelectionWithDetails,
ElectiveCourseStatus,
ElectiveCourseWithDetails,
} from "./types"
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString()
const mapCourseRow = (
r: typeof electiveCourses.$inferSelect & {
teacherName: string | null
subjectName: string | null
gradeName: string | null
}
): ElectiveCourseWithDetails => ({
id: r.id,
name: r.name,
subjectId: r.subjectId,
teacherId: r.teacherId,
gradeId: r.gradeId,
description: r.description,
capacity: r.capacity,
enrolledCount: r.enrolledCount,
classroom: r.classroom,
schedule: r.schedule,
startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null,
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
selectionStartAt: toIso(r.selectionStartAt),
selectionEndAt: toIso(r.selectionEndAt),
status: r.status,
selectionMode: r.selectionMode,
credit: String(r.credit),
createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt),
teacherName: r.teacherName,
subjectName: r.subjectName,
gradeName: r.gradeName,
})
const buildCourseSelect = () =>
db
.select({
id: electiveCourses.id,
name: electiveCourses.name,
subjectId: electiveCourses.subjectId,
teacherId: electiveCourses.teacherId,
gradeId: electiveCourses.gradeId,
description: electiveCourses.description,
capacity: electiveCourses.capacity,
enrolledCount: electiveCourses.enrolledCount,
classroom: electiveCourses.classroom,
schedule: electiveCourses.schedule,
startDate: electiveCourses.startDate,
endDate: electiveCourses.endDate,
selectionStartAt: electiveCourses.selectionStartAt,
selectionEndAt: electiveCourses.selectionEndAt,
status: electiveCourses.status,
selectionMode: electiveCourses.selectionMode,
credit: electiveCourses.credit,
createdAt: electiveCourses.createdAt,
updatedAt: electiveCourses.updatedAt,
teacherName: users.name,
subjectName: subjects.name,
gradeName: grades.name,
})
.from(electiveCourses)
.leftJoin(users, eq(users.id, electiveCourses.teacherId))
.leftJoin(subjects, eq(subjects.id, electiveCourses.subjectId))
.leftJoin(grades, eq(grades.id, electiveCourses.gradeId))
const mapSelectionRow = (
r: typeof courseSelections.$inferSelect & {
courseName: string | null
studentName: string | null
courseCapacity: number | null
courseEnrolledCount: number | null
courseStatus: (typeof electiveCourses.status.enumValues)[number] | null
}
): CourseSelectionWithDetails => ({
id: r.id,
courseId: r.courseId,
studentId: r.studentId,
status: r.status as CourseSelectionStatus,
priority: r.priority,
selectedAt: toIsoRequired(r.selectedAt),
enrolledAt: toIso(r.enrolledAt),
droppedAt: toIso(r.droppedAt),
lotteryRank: r.lotteryRank,
createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt),
courseName: r.courseName,
studentName: r.studentName,
courseCapacity: r.courseCapacity,
courseEnrolledCount: r.courseEnrolledCount,
courseStatus: r.courseStatus as ElectiveCourseStatus | null,
})
const selectionDetailSelect = () =>
db
.select({
id: courseSelections.id,
courseId: courseSelections.courseId,
studentId: courseSelections.studentId,
status: courseSelections.status,
priority: courseSelections.priority,
selectedAt: courseSelections.selectedAt,
enrolledAt: courseSelections.enrolledAt,
droppedAt: courseSelections.droppedAt,
lotteryRank: courseSelections.lotteryRank,
createdAt: courseSelections.createdAt,
updatedAt: courseSelections.updatedAt,
courseName: electiveCourses.name,
studentName: users.name,
courseCapacity: electiveCourses.capacity,
courseEnrolledCount: electiveCourses.enrolledCount,
courseStatus: electiveCourses.status,
})
.from(courseSelections)
.leftJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))
.leftJoin(users, eq(users.id, courseSelections.studentId))
export async function getCourseSelections(
courseId: string
): Promise<CourseSelectionWithDetails[]> {
const rows = await selectionDetailSelect()
.where(eq(courseSelections.courseId, courseId))
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
return rows.map(mapSelectionRow)
}
export async function getStudentSelections(
studentId: string
): Promise<CourseSelectionWithDetails[]> {
const rows = await selectionDetailSelect()
.where(eq(courseSelections.studentId, studentId))
.orderBy(desc(courseSelections.selectedAt))
return rows.map(mapSelectionRow)
}
export async function getStudentGradeId(studentId: string): Promise<string | null> {
const [row] = await db
.select({ gradeId: classes.gradeId })
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(
and(
eq(classEnrollments.studentId, studentId),
eq(classEnrollments.status, "active")
)
)
.limit(1)
return row?.gradeId ?? null
}
export async function getAvailableCoursesForStudent(
studentId: string,
gradeId?: string | null
): Promise<ElectiveCourseWithDetails[]> {
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
if (resolvedGradeId) {
conditions.push(
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
)
}
const rows = await buildCourseSelect()
.where(and(...conditions))
.orderBy(desc(electiveCourses.createdAt))
return rows.map(mapCourseRow)
}

View File

@@ -0,0 +1,242 @@
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
electiveCourses,
grades,
subjects,
users,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
ElectiveCourseStatus,
ElectiveCourseWithDetails,
GetElectiveCoursesParams,
} from "./types"
import type {
CreateElectiveCourseInput,
UpdateElectiveCourseInput,
} from "./schema"
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString()
const buildScopeFilter = (scope: DataScope, userId?: string): SQL | null => {
if (scope.type === "all") return null
if (scope.type === "owned" && userId) return eq(electiveCourses.teacherId, userId)
if (scope.type === "class_taught" && userId) {
return eq(electiveCourses.teacherId, userId)
}
if (scope.type === "grade_managed") {
return scope.gradeIds.length > 0
? inArray(electiveCourses.gradeId, scope.gradeIds)
: sql`1=0`
}
if (scope.type === "class_members") return null
if (scope.type === "children") return null
return sql`1=0`
}
const mapCourseRow = (
r: typeof electiveCourses.$inferSelect & {
teacherName: string | null
subjectName: string | null
gradeName: string | null
}
): ElectiveCourseWithDetails => ({
id: r.id,
name: r.name,
subjectId: r.subjectId,
teacherId: r.teacherId,
gradeId: r.gradeId,
description: r.description,
capacity: r.capacity,
enrolledCount: r.enrolledCount,
classroom: r.classroom,
schedule: r.schedule,
startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null,
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
selectionStartAt: toIso(r.selectionStartAt),
selectionEndAt: toIso(r.selectionEndAt),
status: r.status,
selectionMode: r.selectionMode,
credit: String(r.credit),
createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt),
teacherName: r.teacherName,
subjectName: r.subjectName,
gradeName: r.gradeName,
})
const buildCourseSelect = () =>
db
.select({
id: electiveCourses.id,
name: electiveCourses.name,
subjectId: electiveCourses.subjectId,
teacherId: electiveCourses.teacherId,
gradeId: electiveCourses.gradeId,
description: electiveCourses.description,
capacity: electiveCourses.capacity,
enrolledCount: electiveCourses.enrolledCount,
classroom: electiveCourses.classroom,
schedule: electiveCourses.schedule,
startDate: electiveCourses.startDate,
endDate: electiveCourses.endDate,
selectionStartAt: electiveCourses.selectionStartAt,
selectionEndAt: electiveCourses.selectionEndAt,
status: electiveCourses.status,
selectionMode: electiveCourses.selectionMode,
credit: electiveCourses.credit,
createdAt: electiveCourses.createdAt,
updatedAt: electiveCourses.updatedAt,
teacherName: users.name,
subjectName: subjects.name,
gradeName: grades.name,
})
.from(electiveCourses)
.leftJoin(users, eq(users.id, electiveCourses.teacherId))
.leftJoin(subjects, eq(subjects.id, electiveCourses.subjectId))
.leftJoin(grades, eq(grades.id, electiveCourses.gradeId))
export const getElectiveCourses = cache(
async (
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
): Promise<ElectiveCourseWithDetails[]> => {
try {
const conditions: SQL[] = []
if (params?.status)
conditions.push(
eq(electiveCourses.status, params.status as ElectiveCourseStatus)
)
if (params?.gradeId) conditions.push(eq(electiveCourses.gradeId, params.gradeId))
if (params?.subjectId)
conditions.push(eq(electiveCourses.subjectId, params.subjectId))
if (params?.teacherId)
conditions.push(eq(electiveCourses.teacherId, params.teacherId))
if (params?.scope) {
const scopeFilter = buildScopeFilter(params.scope, params.currentUserId)
if (scopeFilter) conditions.push(scopeFilter)
}
const query = buildCourseSelect()
const rows = await (conditions.length > 0
? query.where(and(...conditions))
: query
).orderBy(desc(electiveCourses.createdAt))
return rows.map(mapCourseRow)
} catch {
return []
}
}
)
export const getElectiveCourseById = cache(
async (id: string): Promise<ElectiveCourseWithDetails | null> => {
try {
const [row] = await buildCourseSelect()
.where(eq(electiveCourses.id, id))
.limit(1)
if (!row) return null
return mapCourseRow(row)
} catch {
return null
}
}
)
export async function createElectiveCourse(
data: CreateElectiveCourseInput,
teacherId: string
): Promise<string> {
const id = createId()
await db.insert(electiveCourses).values({
id,
name: data.name,
subjectId: data.subjectId,
teacherId: data.teacherId ?? teacherId,
gradeId: data.gradeId,
description: data.description,
capacity: data.capacity,
enrolledCount: 0,
classroom: data.classroom,
schedule: data.schedule,
startDate: data.startDate ? new Date(data.startDate) : null,
endDate: data.endDate ? new Date(data.endDate) : null,
selectionStartAt: data.selectionStartAt ? new Date(data.selectionStartAt) : null,
selectionEndAt: data.selectionEndAt ? new Date(data.selectionEndAt) : null,
status: "draft",
selectionMode: data.selectionMode,
credit: data.credit,
})
return id
}
export async function updateElectiveCourse(
id: string,
data: Partial<UpdateElectiveCourseInput>
): Promise<void> {
const update: Partial<typeof electiveCourses.$inferSelect> = {}
if (data.name !== undefined) update.name = data.name
if (data.subjectId !== undefined) update.subjectId = data.subjectId
if (data.teacherId !== undefined) update.teacherId = data.teacherId
if (data.gradeId !== undefined) update.gradeId = data.gradeId
if (data.description !== undefined) update.description = data.description
if (data.capacity !== undefined) update.capacity = data.capacity
if (data.classroom !== undefined) update.classroom = data.classroom
if (data.schedule !== undefined) update.schedule = data.schedule
if (data.startDate !== undefined)
update.startDate = data.startDate ? new Date(data.startDate) : null
if (data.endDate !== undefined)
update.endDate = data.endDate ? new Date(data.endDate) : null
if (data.selectionStartAt !== undefined)
update.selectionStartAt = data.selectionStartAt ? new Date(data.selectionStartAt) : null
if (data.selectionEndAt !== undefined)
update.selectionEndAt = data.selectionEndAt ? new Date(data.selectionEndAt) : null
if (data.status !== undefined) update.status = data.status
if (data.selectionMode !== undefined) update.selectionMode = data.selectionMode
if (data.credit !== undefined) update.credit = data.credit
if (Object.keys(update).length === 0) return
await db.update(electiveCourses).set(update).where(eq(electiveCourses.id, id))
}
export async function deleteElectiveCourse(id: string): Promise<void> {
await db.delete(electiveCourses).where(eq(electiveCourses.id, id))
}
export async function openSelection(courseId: string): Promise<void> {
await db
.update(electiveCourses)
.set({ status: "open", updatedAt: new Date() })
.where(eq(electiveCourses.id, courseId))
}
export async function closeSelection(courseId: string): Promise<void> {
await db
.update(electiveCourses)
.set({ status: "closed", updatedAt: new Date() })
.where(eq(electiveCourses.id, courseId))
}
export async function getSubjectOptions(): Promise<{ id: string; name: string }[]> {
try {
const rows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.orderBy(asc(subjects.order), asc(subjects.name))
return rows.map((r) => ({ id: r.id, name: r.name }))
} catch {
return []
}
}
export type { ElectiveCourseWithDetails }

View File

@@ -0,0 +1,133 @@
import { z } from "zod"
export const ElectiveCourseStatusEnum = z.enum([
"draft",
"open",
"closed",
"cancelled",
])
export const ElectiveSelectionModeEnum = z.enum(["fcfs", "lottery"])
export const CourseSelectionStatusEnum = z.enum([
"selected",
"enrolled",
"waitlist",
"dropped",
"rejected",
])
const emptyToNull = (v: string | undefined | null) =>
v && v.length > 0 ? v : null
const optionalStringToNull = (v: string | undefined | null) =>
v === undefined ? undefined : emptyToNull(v)
export const CreateElectiveCourseSchema = z
.object({
name: z.string().trim().min(1).max(255),
subjectId: z.string().trim().optional().nullable(),
teacherId: z.string().trim().min(1),
gradeId: z.string().trim().optional().nullable(),
description: z.string().trim().optional().nullable(),
capacity: z.coerce.number().int().min(1).max(500).optional(),
classroom: z.string().trim().optional().nullable(),
schedule: z.string().trim().optional().nullable(),
startDate: z.string().trim().optional().nullable(),
endDate: z.string().trim().optional().nullable(),
selectionStartAt: z.string().trim().optional().nullable(),
selectionEndAt: z.string().trim().optional().nullable(),
selectionMode: ElectiveSelectionModeEnum.optional(),
credit: z.string().trim().optional().nullable(),
})
.transform((v) => ({
name: v.name,
subjectId: optionalStringToNull(v.subjectId) ?? null,
teacherId: v.teacherId,
gradeId: optionalStringToNull(v.gradeId) ?? null,
description: optionalStringToNull(v.description),
capacity: v.capacity ?? 30,
classroom: optionalStringToNull(v.classroom),
schedule: optionalStringToNull(v.schedule),
startDate: optionalStringToNull(v.startDate),
endDate: optionalStringToNull(v.endDate),
selectionStartAt: optionalStringToNull(v.selectionStartAt),
selectionEndAt: optionalStringToNull(v.selectionEndAt),
selectionMode: v.selectionMode ?? "fcfs",
credit: v.credit && v.credit.length > 0 ? v.credit : "1.0",
}))
export type CreateElectiveCourseInput = z.infer<typeof CreateElectiveCourseSchema>
export const UpdateElectiveCourseSchema = z
.object({
name: z.string().trim().min(1).max(255).optional(),
subjectId: z.string().trim().optional().nullable(),
teacherId: z.string().trim().min(1).optional(),
gradeId: z.string().trim().optional().nullable(),
description: z.string().trim().optional().nullable(),
capacity: z.coerce.number().int().min(1).max(500).optional(),
classroom: z.string().trim().optional().nullable(),
schedule: z.string().trim().optional().nullable(),
startDate: z.string().trim().optional().nullable(),
endDate: z.string().trim().optional().nullable(),
selectionStartAt: z.string().trim().optional().nullable(),
selectionEndAt: z.string().trim().optional().nullable(),
status: ElectiveCourseStatusEnum.optional(),
selectionMode: ElectiveSelectionModeEnum.optional(),
credit: z.string().trim().optional().nullable(),
})
.transform((v) => ({
...v,
subjectId:
v.subjectId !== undefined ? optionalStringToNull(v.subjectId) : undefined,
gradeId:
v.gradeId !== undefined ? optionalStringToNull(v.gradeId) : undefined,
description:
v.description !== undefined
? optionalStringToNull(v.description)
: undefined,
classroom:
v.classroom !== undefined ? optionalStringToNull(v.classroom) : undefined,
schedule:
v.schedule !== undefined ? optionalStringToNull(v.schedule) : undefined,
startDate:
v.startDate !== undefined ? optionalStringToNull(v.startDate) : undefined,
endDate:
v.endDate !== undefined ? optionalStringToNull(v.endDate) : undefined,
selectionStartAt:
v.selectionStartAt !== undefined
? optionalStringToNull(v.selectionStartAt)
: undefined,
selectionEndAt:
v.selectionEndAt !== undefined
? optionalStringToNull(v.selectionEndAt)
: undefined,
credit:
v.credit !== undefined
? v.credit && v.credit.length > 0
? v.credit
: "1.0"
: undefined,
}))
export type UpdateElectiveCourseInput = z.infer<typeof UpdateElectiveCourseSchema>
export const SelectCourseSchema = z.object({
courseId: z.string().trim().min(1),
priority: z.coerce.number().int().min(1).max(10).optional(),
})
export type SelectCourseInput = z.infer<typeof SelectCourseSchema>
export const DropCourseSchema = z.object({
courseId: z.string().trim().min(1),
})
export type DropCourseInput = z.infer<typeof DropCourseSchema>
export const RunLotterySchema = z.object({
courseId: z.string().trim().min(1),
})
export type RunLotteryInput = z.infer<typeof RunLotterySchema>

View File

@@ -0,0 +1,108 @@
export type ElectiveCourseStatus = "draft" | "open" | "closed" | "cancelled"
export type ElectiveSelectionMode = "fcfs" | "lottery"
export type CourseSelectionStatus =
| "selected"
| "enrolled"
| "waitlist"
| "dropped"
| "rejected"
export interface ElectiveCourse {
id: string
name: string
subjectId: string | null
teacherId: string
gradeId: string | null
description: string | null
capacity: number
enrolledCount: number
classroom: string | null
schedule: string | null
startDate: string | null
endDate: string | null
selectionStartAt: string | null
selectionEndAt: string | null
status: ElectiveCourseStatus
selectionMode: ElectiveSelectionMode
credit: string
createdAt: string
updatedAt: string
}
export interface ElectiveCourseWithDetails extends ElectiveCourse {
teacherName: string | null
subjectName: string | null
gradeName: string | null
}
export interface CourseSelection {
id: string
courseId: string
studentId: string
status: CourseSelectionStatus
priority: number | null
selectedAt: string
enrolledAt: string | null
droppedAt: string | null
lotteryRank: number | null
createdAt: string
updatedAt: string
}
export interface CourseSelectionWithDetails extends CourseSelection {
courseName: string | null
studentName: string | null
courseCapacity: number | null
courseEnrolledCount: number | null
courseStatus: ElectiveCourseStatus | null
}
export interface GetElectiveCoursesParams {
status?: ElectiveCourseStatus
gradeId?: string
subjectId?: string
teacherId?: string
}
export const ELECTIVE_STATUS_LABELS: Record<ElectiveCourseStatus, string> = {
draft: "Draft",
open: "Open",
closed: "Closed",
cancelled: "Cancelled",
}
export const ELECTIVE_STATUS_COLORS: Record<
ElectiveCourseStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
draft: "secondary",
open: "default",
closed: "outline",
cancelled: "destructive",
}
export const SELECTION_MODE_LABELS: Record<ElectiveSelectionMode, string> = {
fcfs: "First Come First Served",
lottery: "Lottery",
}
export const COURSE_SELECTION_STATUS_LABELS: Record<CourseSelectionStatus, string> = {
selected: "Selected",
enrolled: "Enrolled",
waitlist: "Waitlist",
dropped: "Dropped",
rejected: "Rejected",
}
export const COURSE_SELECTION_STATUS_COLORS: Record<
CourseSelectionStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
selected: "secondary",
enrolled: "default",
waitlist: "outline",
dropped: "destructive",
rejected: "destructive",
}