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:
304
src/modules/elective/actions.ts
Normal file
304
src/modules/elective/actions.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
217
src/modules/elective/data-access-operations.ts
Normal file
217
src/modules/elective/data-access-operations.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
src/modules/elective/data-access-selections.ts
Normal file
189
src/modules/elective/data-access-selections.ts
Normal 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)
|
||||
}
|
||||
242
src/modules/elective/data-access.ts
Normal file
242
src/modules/elective/data-access.ts
Normal 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 }
|
||||
133
src/modules/elective/schema.ts
Normal file
133
src/modules/elective/schema.ts
Normal 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>
|
||||
108
src/modules/elective/types.ts
Normal file
108
src/modules/elective/types.ts
Normal 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",
|
||||
}
|
||||
Reference in New Issue
Block a user