feat(dashboard,diagnostic,elective): add widgets, layout, parent dashboard, role-config, services, elective components

dashboard:

- Add comparison-badge, dashboard-notification-widget, dashboard-responsive-layout, dashboard-time-range-filter

- Add parent-dashboard components directory

- Add config, hooks, and services directories

diagnostic:

- Add role-config and services directory

elective:

- Add elective-course-detail, elective-stats-cards, parent-selection-view components

- Add data-access-settings and data-access-stats
This commit is contained in:
SpecialX
2026-07-03 10:25:46 +08:00
parent dfffb61e94
commit 138b6f1b00
58 changed files with 3313 additions and 695 deletions

View File

@@ -0,0 +1,198 @@
import Link from "next/link"
import { useTranslations } from "next-intl"
import { ArrowLeft, Pencil, Users } 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_BADGE_VARIANTS,
COURSE_SELECTION_STATUS_LABEL_KEYS,
ELECTIVE_STATUS_BADGE_VARIANTS,
ELECTIVE_STATUS_LABEL_KEYS,
SELECTION_MODE_LABEL_KEYS,
} from "../constants"
import type {
CourseSelectionWithDetails,
ElectiveCourseWithDetails,
} from "../types"
/**
* 选修课程详情视图admin/teacher 共用)。
*
* 设计原则:
* - 通过 props 注入数据,不直接调用 data-access便于测试与复用
* - 组合优先:分成课程信息卡片 + 选课名单表格两个独立区块
* - 编辑按钮通过 editHref 参数化admin/teacher 路由各自传入
*/
export function ElectiveCourseDetail({
course,
selections,
editHref,
backHref,
showEditButton = true,
}: {
course: ElectiveCourseWithDetails
selections: CourseSelectionWithDetails[]
editHref?: string
backHref: string
showEditButton?: boolean
}) {
const t = useTranslations("elective")
const activeSelections = selections.filter((s) =>
["selected", "enrolled", "waitlist"].includes(s.status)
)
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Button asChild variant="ghost" size="sm">
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
{t("detail.back")}
</Link>
</Button>
{showEditButton && editHref ? (
<Button asChild size="sm">
<Link href={editHref}>
<Pencil className="mr-1 h-4 w-4" />
{t("detail.editCourse")}
</Link>
</Button>
) : null}
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="space-y-1">
<CardTitle className="text-2xl">{course.name}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("description.detail")}
</p>
</div>
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]} className="shrink-0">
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<DetailField label={t("fields.subject")} value={course.subjectName} />
<DetailField label={t("fields.grade")} value={course.gradeName} />
<DetailField label={t("fields.teacher")} value={course.teacherName} />
<DetailField
label={t("fields.capacity")}
value={`${course.enrolledCount} / ${course.capacity}`}
/>
<DetailField label={t("fields.classroom")} value={course.classroom} />
<DetailField
label={t("fields.selectionMode")}
value={t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}
/>
<DetailField label={t("fields.credit")} value={course.credit} />
<DetailField label={t("fields.startDate")} value={course.startDate} />
<DetailField label={t("fields.endDate")} value={course.endDate} />
<DetailField label={t("fields.selectionStart")} value={course.selectionStartAt} />
<DetailField label={t("fields.selectionEnd")} value={course.selectionEndAt} />
</div>
{course.schedule ? (
<div className="mt-4">
<p className="text-sm font-medium text-muted-foreground">{t("fields.schedule")}</p>
<p className="mt-1 text-sm">{course.schedule}</p>
</div>
) : null}
{course.description ? (
<div className="mt-4">
<p className="text-sm font-medium text-muted-foreground">{t("fields.description")}</p>
<p className="mt-1 text-sm">{course.description}</p>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<Users className="h-5 w-5" />
{t("detail.studentsTitle")}
</CardTitle>
<span className="text-sm text-muted-foreground">
{activeSelections.length}
</span>
</div>
</CardHeader>
<CardContent>
{activeSelections.length === 0 ? (
<EmptyState
title={t("detail.noStudents")}
description={t("detail.noStudentsDescription")}
icon={Users}
className="h-auto border-none shadow-none"
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">#</th>
<th className="pb-2 pr-4 font-medium">{t("detail.studentName")}</th>
<th className="pb-2 pr-4 font-medium">{t("export.statusHeader")}</th>
<th className="pb-2 pr-4 font-medium">{t("detail.priority")}</th>
<th className="pb-2 pr-4 font-medium">{t("detail.selectedAt")}</th>
<th className="pb-2 pr-4 font-medium">{t("detail.enrolledAt")}</th>
</tr>
</thead>
<tbody>
{activeSelections.map((sel, idx) => (
<tr key={sel.id} className="border-b last:border-0">
<td className="py-2 pr-4 text-muted-foreground">{idx + 1}</td>
<td className="py-2 pr-4 font-medium">
{sel.studentName ?? "—"}
</td>
<td className="py-2 pr-4">
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
</Badge>
</td>
<td className="py-2 pr-4 tabular-nums">
{sel.priority ?? "—"}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{sel.selectedAt
? new Date(sel.selectedAt).toLocaleDateString()
: "—"}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{sel.enrolledAt
? new Date(sel.enrolledAt).toLocaleDateString()
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function DetailField({
label,
value,
}: {
label: string
value: string | null | undefined
}) {
return (
<div>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-medium">{value ?? "—"}</p>
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
@@ -69,20 +70,21 @@ export function ElectiveCourseForm({
: null
if (!res) {
toast.error("Invalid form state")
toast.error(t("form.invalidFormState"))
return
}
if (res.success) {
toast.success(res.message)
// 根据 backHref 推断返回列表页路径
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective"
router.push(redirectBase)
router.refresh()
} else {
toast.error(res.message || "Failed to save course")
toast.error(res.message || t("form.saveFailed"))
}
} catch {
toast.error("Failed to save course")
toast.error(t("form.saveFailed"))
} finally {
setIsWorking(false)
}
@@ -92,14 +94,14 @@ export function ElectiveCourseForm({
<Card>
<CardHeader>
<CardTitle>
{mode === "create" ? "New Elective Course" : "Edit Elective Course"}
{mode === "create" ? t("form.createTitle") : t("form.editTitle")}
</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>
<Label htmlFor="name">{t("form.nameLabel")}</Label>
<Input
id="name"
name="name"
@@ -109,10 +111,10 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label>Subject</Label>
<Label>{t("form.subjectLabel")}</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
<SelectValue placeholder={t("form.selectSubjectPlaceholder")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
@@ -126,10 +128,10 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label>Grade</Label>
<Label>{t("form.gradeLabel")}</Label>
<Select value={gradeId} onValueChange={setGradeId}>
<SelectTrigger>
<SelectValue placeholder="Select a grade" />
<SelectValue placeholder={t("form.selectGradePlaceholder")} />
</SelectTrigger>
<SelectContent>
{grades.map((g) => (
@@ -143,15 +145,15 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label>Teacher</Label>
<Label>{t("form.teacherLabel")}</Label>
<Select value={teacherId} onValueChange={setTeacherId}>
<SelectTrigger>
<SelectValue placeholder="Select a teacher" />
<SelectValue placeholder={t("form.selectTeacherPlaceholder")} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
{teachers.map((teacher) => (
<SelectItem key={teacher.id} value={teacher.id}>
{teacher.name}
</SelectItem>
))}
</SelectContent>
@@ -160,7 +162,7 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label htmlFor="capacity">Capacity</Label>
<Label htmlFor="capacity">{t("form.capacityLabel")}</Label>
<Input
id="capacity"
name="capacity"
@@ -172,7 +174,7 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label htmlFor="classroom">Classroom</Label>
<Label htmlFor="classroom">{t("form.classroomLabel")}</Label>
<Input
id="classroom"
name="classroom"
@@ -181,17 +183,17 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label htmlFor="schedule">Schedule</Label>
<Label htmlFor="schedule">{t("form.scheduleLabel")}</Label>
<Input
id="schedule"
name="schedule"
placeholder="e.g. Mon 14:00-15:30"
placeholder={t("form.schedulePlaceholder")}
defaultValue={course?.schedule ?? ""}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="credit">Credit</Label>
<Label htmlFor="credit">{t("form.creditLabel")}</Label>
<Input
id="credit"
name="credit"
@@ -222,7 +224,7 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label htmlFor="startDate">Start Date</Label>
<Label htmlFor="startDate">{t("form.startDateLabel")}</Label>
<Input
id="startDate"
name="startDate"
@@ -232,7 +234,7 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">End Date</Label>
<Label htmlFor="endDate">{t("form.endDateLabel")}</Label>
<Input
id="endDate"
name="endDate"
@@ -242,7 +244,7 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label htmlFor="selectionStartAt">Selection Start</Label>
<Label htmlFor="selectionStartAt">{t("form.selectionStartLabel")}</Label>
<Input
id="selectionStartAt"
name="selectionStartAt"
@@ -256,7 +258,7 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label htmlFor="selectionEndAt">Selection End</Label>
<Label htmlFor="selectionEndAt">{t("form.selectionEndLabel")}</Label>
<Input
id="selectionEndAt"
name="selectionEndAt"
@@ -268,14 +270,32 @@ export function ElectiveCourseForm({
}
/>
</div>
{/* P2-4退课截止时间 */}
<div className="grid gap-2">
<Label htmlFor="dropDeadline">{t("form.dropDeadlineLabel")}</Label>
<Input
id="dropDeadline"
name="dropDeadline"
type="datetime-local"
defaultValue={
course?.dropDeadline
? new Date(course.dropDeadline).toISOString().slice(0, 16)
: ""
}
/>
<p className="text-xs text-muted-foreground">
{t("form.dropDeadlineHint")}
</p>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Label htmlFor="description">{t("form.descriptionLabel")}</Label>
<Textarea
id="description"
name="description"
placeholder="Course description..."
placeholder={t("form.descriptionPlaceholder")}
className="min-h-[80px]"
defaultValue={course?.description ?? ""}
/>
@@ -285,13 +305,12 @@ export function ElectiveCourseForm({
<Button
type="button"
variant="outline"
onClick={() => router.push(backHref ?? "/admin/elective")}
disabled={isWorking}
asChild
>
Cancel
<Link href={backHref ?? "/admin/elective"}>{t("form.cancelButton")}</Link>
</Button>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
{isWorking ? t("form.savingButton") : mode === "create" ? t("form.createButton") : t("form.saveButton")}
</Button>
</CardFooter>
</form>

View File

@@ -2,6 +2,7 @@
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
@@ -89,10 +90,10 @@ export function ElectiveCourseList({
</p>
{manageResolved && createHref ? (
<Button asChild>
<a href={createHref}>
<Link href={createHref}>
<Plus className="mr-2 h-4 w-4" />
{t("actions.create")}
</a>
</Link>
</Button>
) : null}
</div>
@@ -174,10 +175,10 @@ export function ElectiveCourseList({
variant="outline"
size="sm"
>
<a href={`${editBaseHref}/${course.id}/edit`}>
<Link href={`${editBaseHref}/${course.id}/edit`}>
<Pencil className="mr-1 h-3 w-3" />
{t("actions.edit")}
</a>
</Link>
</Button>
) : null}
{course.status === "draft" || course.status === "closed" ? (

View File

@@ -0,0 +1,71 @@
import { BookOpen, Users, Gauge, Shuffle } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import type { ElectiveOverviewStats } from "../data-access-stats"
/**
* 选课模块管理员概览统计卡片网格。
*
* 复用模式:与 attendance-stats-cards 一致的 4 卡片网格。
* 设计原则:
* - 通过 props 注入数据,不直接调用 data-access便于测试与复用
* - 使用 i18n key 解析标题value 由父组件传入已格式化的数据
*/
export function ElectiveStatsCards({ stats }: { stats: ElectiveOverviewStats }) {
const t = useTranslations("elective")
const cards = [
{
title: t("stats.totalCourses"),
value: stats.totalCourses,
icon: BookOpen,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
title: t("stats.totalEnrolled"),
value: stats.totalEnrolled,
icon: Users,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
title: t("stats.avgUtilization"),
value: t("stats.utilizationRate", { rate: stats.avgUtilization }),
icon: Gauge,
color: "text-purple-500",
bgColor: "bg-purple-500/10",
},
{
title: t("stats.pendingLottery"),
value: stats.pendingLottery,
icon: Shuffle,
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
]
return (
<div
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
role="region"
aria-label={t("stats.totalCourses")}
>
{cards.map((card) => (
<Card key={card.title} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md", card.bgColor)}>
<card.icon className={cn("h-4 w-4", card.color)} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{card.value}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { useTranslations } from "next-intl"
import { BookOpen } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
COURSE_SELECTION_STATUS_LABEL_KEYS,
} from "../constants"
import type { CourseSelectionWithDetails } from "../types"
/**
* 家长视角下查看子女选课的只读视图。
*
* 设计原则:
* - 只读:家长不能替子女选/退课,仅展示已选记录
* - 复用:复用 Badge variant 映射 + i18n key与 StudentSelectionView 一致
* - 安全:组件本身不依赖 studentId由父页面注入已过滤的数据
*/
export function ParentSelectionView({
selections,
studentName,
}: {
selections: CourseSelectionWithDetails[]
studentName: string
}) {
const t = useTranslations("elective")
const activeSelections = selections.filter((s) =>
["selected", "enrolled", "waitlist"].includes(s.status)
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold border-b pb-2">{studentName}</h3>
<span className="text-sm text-muted-foreground">
{activeSelections.length}
</span>
</div>
{activeSelections.length === 0 ? (
<EmptyState
title={t("parent.noRecordsTitle")}
description={t("parent.noRecordsDescription")}
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 ?? t("errors.notFound")}
</CardTitle>
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
</Badge>
</CardHeader>
<CardContent className="space-y-2">
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
<p className="text-xs text-muted-foreground">
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
</p>
) : null}
{sel.lotteryRank ? (
<p className="text-xs text-muted-foreground">
#{sel.lotteryRank}
</p>
) : null}
{sel.selectedAt ? (
<p className="text-xs text-muted-foreground">
{t("export.selectedAtHeader")}: {new Date(sel.selectedAt).toLocaleDateString()}
</p>
) : null}
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}

View File

@@ -21,6 +21,8 @@ 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 { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
@@ -35,24 +37,171 @@ import type {
} from "../types"
import { selectCourseAction, dropCourseAction } from "../actions"
export function StudentSelectionView({
availableCourses,
/**
* 已选课程区块(我的选课)
* - 独立 Suspense 边界内的客户端组件
* - 仅负责"退课"操作与展示
*/
export function StudentMySelectionsSection({
mySelections,
}: {
availableCourses: ElectiveCourseWithDetails[]
mySelections: CourseSelectionWithDetails[]
}) {
const router = useRouter()
const t = useTranslations("elective")
const [pendingId, setPendingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
// P2-4退课理由输入按课程 ID 隔离,便于多卡片独立填写)
const [dropReasonMap, setDropReasonMap] = useState<Record<string, string>>({})
const activeSelections = mySelections.filter((s) =>
["selected", "enrolled", "waitlist"].includes(s.status)
)
const selectedCourseIds = new Set(
activeSelections.map((s) => s.courseId)
const handleDrop = (courseId: string) => {
setPendingId(courseId)
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
// P2-4透传退课理由
const reason = dropReasonMap[courseId]
if (reason && reason.trim().length > 0) {
formData.set("dropReason", reason.trim())
}
const res = await dropCourseAction(null, formData)
if (res.success) {
toast.success(res.message || t("student.dropSuccess"))
router.refresh()
// 清空该课程的退课理由
setDropReasonMap((prev) => {
const next = { ...prev }
delete next[courseId]
return next
})
} else {
toast.error(res.message ?? t("errors.unexpected"))
}
setPendingId(null)
})
}
return (
<section className="space-y-4" aria-labelledby="student-my-selections-heading">
<div className="flex items-center justify-between">
<h3
id="student-my-selections-heading"
className="text-lg font-semibold"
>
{t("student.mySelections")}
</h3>
<span className="text-sm text-muted-foreground" aria-live="polite">
{activeSelections.length}
</span>
</div>
{activeSelections.length === 0 ? (
<EmptyState
title={t("list.empty")}
description={t("description.student")}
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 ?? t("errors.notFound")}
</CardTitle>
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
<p className="text-xs text-muted-foreground">
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
</p>
) : null}
{sel.lotteryRank ? (
<p className="text-xs text-muted-foreground">
#{sel.lotteryRank}
</p>
) : null}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isPending && pendingId === sel.courseId}
>
<XCircle className="mr-1 h-3 w-3" />
{t("actions.drop")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("student.confirmDrop")}</AlertDialogTitle>
<AlertDialogDescription>
{t("student.confirmDrop")}
</AlertDialogDescription>
</AlertDialogHeader>
{/* P2-4可选退课理由输入 */}
<div className="grid gap-2 py-2">
<Label htmlFor={`dropReason-${sel.courseId}`}>
{t("fields.dropReason")}
</Label>
<Textarea
id={`dropReason-${sel.courseId}`}
value={dropReasonMap[sel.courseId] ?? ""}
onChange={(e) =>
setDropReasonMap((prev) => ({
...prev,
[sel.courseId]: e.target.value,
}))
}
placeholder={t("student.dropReasonPlaceholder")}
className="min-h-[60px]"
maxLength={255}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDrop(sel.courseId)}
>
{t("actions.drop")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
))}
</div>
)}
</section>
)
}
/**
* 可选课程区块
* - 独立 Suspense 边界内的客户端组件
* - 仅负责"选课"操作与展示
* - 通过 selectedCourseIds prop 接收已选课程 ID 集合,避免与已选区块直接耦合
*/
export function StudentAvailableCoursesSection({
availableCourses,
selectedCourseIds,
}: {
availableCourses: ElectiveCourseWithDetails[]
selectedCourseIds: Set<string>
}) {
const router = useRouter()
const t = useTranslations("elective")
const [pendingId, setPendingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const handleSelect = (courseId: string) => {
setPendingId(courseId)
@@ -70,179 +219,93 @@ export function StudentSelectionView({
})
}
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 || t("student.dropSuccess"))
router.refresh()
} else {
toast.error(res.message ?? t("errors.unexpected"))
}
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">{t("student.mySelections")}</h3>
<span className="text-sm text-muted-foreground">
{activeSelections.length}
</span>
</div>
{activeSelections.length === 0 ? (
<EmptyState
title={t("list.empty")}
description={t("description.student")}
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}>
<section className="space-y-4" aria-labelledby="student-available-courses-heading">
<div className="flex items-center justify-between">
<h3
id="student-available-courses-heading"
className="text-lg font-semibold"
>
{t("student.availableCourses")}
</h3>
<span className="text-sm text-muted-foreground" aria-live="polite">
{availableCourses.length}
</span>
</div>
{availableCourses.length === 0 ? (
<EmptyState
title={t("list.emptyStudent")}
description={t("description.student")}
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" role="article" aria-label={course.name}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<CardTitle className="text-base">
{sel.courseName ?? t("errors.notFound")}
</CardTitle>
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]}>
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
<p className="text-xs text-muted-foreground">
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
<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>{t("fields.credit")}: {course.credit}</span>
<span>· {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}</span>
</div>
{course.description ? (
<p className="line-clamp-2 text-sm text-muted-foreground">
{course.description}
</p>
) : null}
{sel.lotteryRank ? (
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
<span className="font-medium">{course.teacherName ?? "—"}</span>
</div>
<div>
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
<span className="font-medium">
{course.enrolledCount}/{course.capacity}
{isFull ? ` (${t("student.capacityFull")})` : ""}
</span>
</div>
</div>
{course.schedule ? (
<p className="text-xs text-muted-foreground">
#{sel.lotteryRank}
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
</p>
) : null}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isPending && pendingId === sel.courseId}
>
<XCircle className="mr-1 h-3 w-3" />
{t("actions.drop")}
<div className="mt-auto pt-2">
{alreadySelected ? (
<Button variant="secondary" size="sm" disabled>
<CheckCircle2 className="mr-1 h-3 w-3" />
{t("student.selected")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("student.confirmDrop")}</AlertDialogTitle>
<AlertDialogDescription>
{t("student.confirmDrop")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDrop(sel.courseId)}
>
{t("actions.drop")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
size="sm"
disabled={isPendingThis}
onClick={() => handleSelect(course.id)}
>
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t("student.availableCourses")}</h3>
<span className="text-sm text-muted-foreground">
{availableCourses.length}
</span>
)
})}
</div>
{availableCourses.length === 0 ? (
<EmptyState
title={t("list.emptyStudent")}
description={t("description.student")}
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" role="article" aria-label={course.name}>
<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_BADGE_VARIANTS[course.status]}>
{t(ELECTIVE_STATUS_LABEL_KEYS[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>{t("fields.credit")}: {course.credit}</span>
<span>· {t(SELECTION_MODE_LABEL_KEYS[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">{t("fields.teacher")}:</span>{" "}
<span className="font-medium">{course.teacherName ?? "—"}</span>
</div>
<div>
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
<span className="font-medium">
{course.enrolledCount}/{course.capacity}
{isFull ? ` (${t("student.capacityFull")})` : ""}
</span>
</div>
</div>
{course.schedule ? (
<p className="text-xs text-muted-foreground">
<span className="font-medium">{t("fields.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" />
{t("student.selected")}
</Button>
) : (
<Button
size="sm"
disabled={isPendingThis}
onClick={() => handleSelect(course.id)}
>
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</section>
</div>
)}
</section>
)
}