Files
NextEdu/src/modules/elective/components/student-selection-view.tsx
SpecialX 138b6f1b00 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
2026-07-03 10:25:46 +08:00

312 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
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,
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"
import { selectCourseAction, dropCourseAction } from "../actions"
/**
* 已选课程区块(我的选课)
* - 独立 Suspense 边界内的客户端组件
* - 仅负责"退课"操作与展示
*/
export function StudentMySelectionsSection({
mySelections,
}: {
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 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)
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
const res = await selectCourseAction(null, formData)
if (res.success) {
toast.success(res.message || t("student.selectSuccess"))
router.refresh()
} else {
toast.error(res.message ?? t("errors.unexpected"))
}
setPendingId(null)
})
}
return (
<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="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>
)
}