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
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
"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>
|
||
)
|
||
}
|