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:
198
src/modules/elective/components/elective-course-detail.tsx
Normal file
198
src/modules/elective/components/elective-course-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
71
src/modules/elective/components/elective-stats-cards.tsx
Normal file
71
src/modules/elective/components/elective-stats-cards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
src/modules/elective/components/parent-selection-view.tsx
Normal file
85
src/modules/elective/components/parent-selection-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user