diff --git a/apps/portal-shell/scripts/check-page-count.ts b/apps/portal-shell/scripts/check-page-count.ts index dae4c7c..134c187 100644 --- a/apps/portal-shell/scripts/check-page-count.ts +++ b/apps/portal-shell/scripts/check-page-count.ts @@ -19,10 +19,10 @@ interface Baseline { categories: Record; } -// Baseline as of P2 B2 (2026-07-22, attendance + classes + students modules added). +// Baseline as of P2 B2 (2026-07-22, course-plans + elective modules added). // Update when adding pages. const BASELINE: Baseline = { - total: 48, + total: 53, categories: { dashboards: { pattern: "shell/{admin,teacher,student,parent}/page.tsx", diff --git a/apps/portal-shell/src/app/shell/teacher/course-plans/[id]/page.tsx b/apps/portal-shell/src/app/shell/teacher/course-plans/[id]/page.tsx new file mode 100644 index 0000000..540ef56 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/course-plans/[id]/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; + +import { CoursePlanDetailClient } from "@/features/teacher/course-plans/course-plan-detail-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 课程计划详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * + * 数据契约:单查 coursePlan(id) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#course-plan-detail + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function CoursePlanDetailPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/course-plans/error.tsx b/apps/portal-shell/src/app/shell/teacher/course-plans/error.tsx new file mode 100644 index 0000000..e791eda --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/course-plans/error.tsx @@ -0,0 +1,38 @@ +"use client"; + +/** + * 课程计划路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。 + * Next.js Route Segment error.tsx,捕获子树未处理异常。 + */ +import { useEffect } from "react"; + +import { Button } from "@/shared/components/ui/button"; +import { useTranslations } from "next-intl"; + +export default function CoursePlansError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}): React.ReactElement { + const t = useTranslations("coursePlans"); + + useEffect(() => { + console.error("[portal-shell] course-plans route error:", error); + }, [error]); + + return ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/course-plans/loading.tsx b/apps/portal-shell/src/app/shell/teacher/course-plans/loading.tsx new file mode 100644 index 0000000..26d3c2e --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/course-plans/loading.tsx @@ -0,0 +1,12 @@ +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 课程计划路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。 + * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。 + * + * 子页面(详情)的 Skeleton 由各自 server page 的 兜底, + * 本文件仅在 /shell/teacher/course-plans 列表/重定向期间显示。 + */ +export default function CoursePlansLoading(): React.ReactElement { + return ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/course-plans/page.tsx b/apps/portal-shell/src/app/shell/teacher/course-plans/page.tsx new file mode 100644 index 0000000..b0c124f --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/course-plans/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { CoursePlanListClient } from "@/features/teacher/course-plans/course-plan-list-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 课程计划列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 CoursePlanListClient(client component)中。 + * + * 数据契约:列表查询 coursePlans(gradeId, subjectId, status, q) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#course-plans-list + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function CoursePlansListPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/elective/[id]/edit/page.tsx b/apps/portal-shell/src/app/shell/teacher/elective/[id]/edit/page.tsx new file mode 100644 index 0000000..a2f411b --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/elective/[id]/edit/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; + +import { ElectiveEditClient } from "@/features/teacher/elective/elective-edit-client"; +import { FormPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 编辑选修课表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * + * 数据契约: + * - 单查 elective(id) ❌ schema 无此字段 → MSW 兜底(@contract-pending,用于预填) + * - mutation updateElective(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#update-elective + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function ElectiveEditPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/elective/create/page.tsx b/apps/portal-shell/src/app/shell/teacher/elective/create/page.tsx new file mode 100644 index 0000000..c7829f2 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/elective/create/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; + +import { ElectiveCreateClient } from "@/features/teacher/elective/elective-create-client"; +import { FormPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 新建选修课表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * + * 数据契约:mutation createElective(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-elective + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function ElectiveCreatePage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/elective/error.tsx b/apps/portal-shell/src/app/shell/teacher/elective/error.tsx new file mode 100644 index 0000000..c65ca26 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/elective/error.tsx @@ -0,0 +1,38 @@ +"use client"; + +/** + * 选修课路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。 + * Next.js Route Segment error.tsx,捕获子树未处理异常。 + */ +import { useEffect } from "react"; + +import { Button } from "@/shared/components/ui/button"; +import { useTranslations } from "next-intl"; + +export default function ElectiveError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}): React.ReactElement { + const t = useTranslations("elective"); + + useEffect(() => { + console.error("[portal-shell] elective route error:", error); + }, [error]); + + return ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/elective/loading.tsx b/apps/portal-shell/src/app/shell/teacher/elective/loading.tsx new file mode 100644 index 0000000..ee05c95 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/elective/loading.tsx @@ -0,0 +1,12 @@ +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 选修课路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。 + * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。 + * + * 子页面(新建/编辑)的 Skeleton 由各自 server page 的 兜底, + * 本文件仅在 /shell/teacher/elective 列表/重定向期间显示。 + */ +export default function ElectiveLoading(): React.ReactElement { + return ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/elective/page.tsx b/apps/portal-shell/src/app/shell/teacher/elective/page.tsx new file mode 100644 index 0000000..ccd5098 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/elective/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { ElectiveListClient } from "@/features/teacher/elective/elective-list-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 选修课列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 ElectiveListClient(client component)中。 + * + * 数据契约:列表查询 electives(status, q) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#electives-list + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function ElectiveListPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/course-plans/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/course-plans/__tests__/transformations.test.ts new file mode 100644 index 0000000..bbd1b44 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/course-plans/__tests__/transformations.test.ts @@ -0,0 +1,354 @@ +/** + * Course Plans 数据变换工具单测(ARCHITECTURE.md §11.3 DoD) + * + * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测" + */ +import { describe, expect, it } from "vitest"; + +import type { CoursePlanDetail } from "@/lib/api"; + +import { + COURSE_PLAN_STATUS_LABEL, + COURSE_PLAN_UNIT_STATUS_LABEL, + calcUnitProgress, + coursePlanStatusToBadgeClass, + coursePlanUnitStatusToBadgeClass, + formatCoursePlanDate, + formatCoursePlanStatus, + formatCoursePlanUnitStatus, + formatProgress, + hasDescription, + hasObjectives, + isCoursePlanArchived, + isCoursePlanEditable, + isCoursePlanInProgress, + isValidCoursePlanStatus, + isValidCoursePlanUnitStatus, + progressToColorClass, + toCoursePlanListItem, +} from "../transformations"; + +const sampleDetail: CoursePlanDetail = { + id: "cp-001", + name: "高一数学第一学期课程计划", + gradeId: "grade-10", + subjectId: "sub-math", + semester: "2026-fall", + status: "IN_PROGRESS", + description: "覆盖集合、函数、三角函数等核心章节", + objectives: "掌握函数概念与基本性质", + units: [ + { + id: "unit-1", + title: "集合与逻辑", + order: 1, + lessonCount: 8, + completedLessonCount: 8, + status: "COMPLETED", + }, + { + id: "unit-2", + title: "函数概念", + order: 2, + lessonCount: 10, + completedLessonCount: 6, + status: "IN_PROGRESS", + }, + { + id: "unit-3", + title: "三角函数", + order: 3, + lessonCount: 12, + completedLessonCount: 0, + status: "NOT_STARTED", + }, + ], + progress: 50, + createdAt: "2026-08-15T00:00:00Z", + updatedAt: "2026-10-01T00:00:00Z", +}; + +describe("formatCoursePlanStatus", () => { + it("maps known statuses to Chinese labels", () => { + expect(formatCoursePlanStatus("DRAFT")).toBe("草稿"); + expect(formatCoursePlanStatus("IN_PROGRESS")).toBe("进行中"); + expect(formatCoursePlanStatus("COMPLETED")).toBe("已完成"); + expect(formatCoursePlanStatus("ARCHIVED")).toBe("已归档"); + }); + + it("returns original value for unknown status", () => { + expect(formatCoursePlanStatus("other")).toBe("other"); + expect(formatCoursePlanStatus("")).toBe(""); + }); + + it("COURSE_PLAN_STATUS_LABEL covers 4 standard statuses", () => { + expect(Object.keys(COURSE_PLAN_STATUS_LABEL)).toHaveLength(4); + }); +}); + +describe("formatCoursePlanUnitStatus", () => { + it("maps known statuses to Chinese labels", () => { + expect(formatCoursePlanUnitStatus("NOT_STARTED")).toBe("未开始"); + expect(formatCoursePlanUnitStatus("IN_PROGRESS")).toBe("进行中"); + expect(formatCoursePlanUnitStatus("COMPLETED")).toBe("已完成"); + }); + + it("returns original value for unknown status", () => { + expect(formatCoursePlanUnitStatus("other")).toBe("other"); + }); + + it("COURSE_PLAN_UNIT_STATUS_LABEL covers 3 standard statuses", () => { + expect(Object.keys(COURSE_PLAN_UNIT_STATUS_LABEL)).toHaveLength(3); + }); +}); + +describe("formatCoursePlanDate", () => { + it("formats valid ISO date string with time", () => { + const result = formatCoursePlanDate("2026-07-22T10:30:00Z"); + expect(result).toContain("2026"); + expect(result).toContain("07"); + }); + + it("returns placeholder for null/undefined/empty", () => { + expect(formatCoursePlanDate(null)).toBe("--"); + expect(formatCoursePlanDate(undefined)).toBe("--"); + expect(formatCoursePlanDate("")).toBe("--"); + }); + + it("returns placeholder for invalid date", () => { + expect(formatCoursePlanDate("not-a-date")).toBe("--"); + }); +}); + +describe("coursePlanStatusToBadgeClass", () => { + it("returns primary class for IN_PROGRESS", () => { + expect(coursePlanStatusToBadgeClass("IN_PROGRESS")).toContain("primary"); + }); + + it("returns emerald class for COMPLETED", () => { + expect(coursePlanStatusToBadgeClass("COMPLETED")).toContain("emerald"); + }); + + it("returns muted for DRAFT/ARCHIVED/unknown", () => { + expect(coursePlanStatusToBadgeClass("DRAFT")).toBe( + "bg-muted text-muted-foreground", + ); + expect(coursePlanStatusToBadgeClass("ARCHIVED")).toBe( + "bg-muted text-muted-foreground", + ); + expect(coursePlanStatusToBadgeClass("unknown")).toBe( + "bg-muted text-muted-foreground", + ); + }); +}); + +describe("coursePlanUnitStatusToBadgeClass", () => { + it("returns primary class for IN_PROGRESS", () => { + expect(coursePlanUnitStatusToBadgeClass("IN_PROGRESS")).toContain( + "primary", + ); + }); + + it("returns emerald class for COMPLETED", () => { + expect(coursePlanUnitStatusToBadgeClass("COMPLETED")).toContain("emerald"); + }); + + it("returns muted for NOT_STARTED/unknown", () => { + expect(coursePlanUnitStatusToBadgeClass("NOT_STARTED")).toBe( + "bg-muted text-muted-foreground", + ); + expect(coursePlanUnitStatusToBadgeClass("unknown")).toBe( + "bg-muted text-muted-foreground", + ); + }); +}); + +describe("formatProgress", () => { + it("formats valid progress as percentage", () => { + expect(formatProgress(0)).toBe("0%"); + expect(formatProgress(50)).toBe("50%"); + expect(formatProgress(100)).toBe("100%"); + expect(formatProgress(33.7)).toBe("34%"); + }); + + it("returns placeholder for invalid input", () => { + expect(formatProgress(-1)).toBe("--"); + expect(formatProgress(101)).toBe("--"); + expect(formatProgress(Number.NaN)).toBe("--"); + }); +}); + +describe("progressToColorClass", () => { + it("returns emerald for >= 75", () => { + expect(progressToColorClass(75)).toContain("emerald"); + expect(progressToColorClass(100)).toContain("emerald"); + }); + + it("returns amber for 50-74", () => { + expect(progressToColorClass(50)).toContain("amber"); + expect(progressToColorClass(74)).toContain("amber"); + }); + + it("returns primary for 25-49", () => { + expect(progressToColorClass(25)).toBe("text-primary"); + expect(progressToColorClass(49)).toBe("text-primary"); + }); + + it("returns destructive for < 25", () => { + expect(progressToColorClass(0)).toContain("destructive"); + expect(progressToColorClass(24)).toContain("destructive"); + }); + + it("returns muted for invalid input", () => { + expect(progressToColorClass(-1)).toBe("text-muted-foreground"); + expect(progressToColorClass(101)).toBe("text-muted-foreground"); + }); +}); + +describe("toCoursePlanListItem", () => { + it("extracts list fields from detail", () => { + const item = toCoursePlanListItem(sampleDetail); + expect(item.id).toBe("cp-001"); + expect(item.name).toBe("高一数学第一学期课程计划"); + expect(item.gradeId).toBe("grade-10"); + expect(item.subjectId).toBe("sub-math"); + expect(item.semester).toBe("2026-fall"); + expect(item.status).toBe("IN_PROGRESS"); + expect(item.description).toBe("覆盖集合、函数、三角函数等核心章节"); + expect(item.createdAt).toBe("2026-08-15T00:00:00Z"); + expect(item.updatedAt).toBe("2026-10-01T00:00:00Z"); + }); + + it("does not include detail-only fields (units/objectives/progress)", () => { + const item = toCoursePlanListItem(sampleDetail); + expect(item).not.toHaveProperty("units"); + expect(item).not.toHaveProperty("objectives"); + expect(item).not.toHaveProperty("progress"); + }); +}); + +describe("calcUnitProgress", () => { + it("calculates percentage for valid input", () => { + expect(calcUnitProgress({ lessonCount: 8, completedLessonCount: 8 })).toBe( + 100, + ); + expect(calcUnitProgress({ lessonCount: 10, completedLessonCount: 6 })).toBe( + 60, + ); + expect(calcUnitProgress({ lessonCount: 12, completedLessonCount: 0 })).toBe( + 0, + ); + }); + + it("returns 0 for zero/negative lessonCount", () => { + expect(calcUnitProgress({ lessonCount: 0, completedLessonCount: 0 })).toBe( + 0, + ); + expect(calcUnitProgress({ lessonCount: -1, completedLessonCount: 0 })).toBe( + 0, + ); + }); + + it("clamps to 100 when completedLessonCount > lessonCount", () => { + expect( + calcUnitProgress({ lessonCount: 10, completedLessonCount: 15 }), + ).toBe(100); + }); + + it("clamps to 0 for negative completedLessonCount", () => { + expect( + calcUnitProgress({ lessonCount: 10, completedLessonCount: -5 }), + ).toBe(0); + }); + + it("returns 0 for invalid input (NaN/Infinity)", () => { + expect( + calcUnitProgress({ + lessonCount: Number.NaN, + completedLessonCount: 1, + }), + ).toBe(0); + expect( + calcUnitProgress({ + lessonCount: Number.POSITIVE_INFINITY, + completedLessonCount: 1, + }), + ).toBe(0); + }); +}); + +describe("isCoursePlanEditable", () => { + it("returns true only for DRAFT", () => { + expect(isCoursePlanEditable("DRAFT")).toBe(true); + expect(isCoursePlanEditable("IN_PROGRESS")).toBe(false); + expect(isCoursePlanEditable("COMPLETED")).toBe(false); + expect(isCoursePlanEditable("ARCHIVED")).toBe(false); + }); +}); + +describe("isCoursePlanArchived", () => { + it("returns true only for ARCHIVED", () => { + expect(isCoursePlanArchived("ARCHIVED")).toBe(true); + expect(isCoursePlanArchived("DRAFT")).toBe(false); + }); +}); + +describe("isCoursePlanInProgress", () => { + it("returns true only for IN_PROGRESS", () => { + expect(isCoursePlanInProgress("IN_PROGRESS")).toBe(true); + expect(isCoursePlanInProgress("DRAFT")).toBe(false); + }); +}); + +describe("isValidCoursePlanStatus", () => { + it("returns true for valid statuses", () => { + expect(isValidCoursePlanStatus("DRAFT")).toBe(true); + expect(isValidCoursePlanStatus("IN_PROGRESS")).toBe(true); + expect(isValidCoursePlanStatus("COMPLETED")).toBe(true); + expect(isValidCoursePlanStatus("ARCHIVED")).toBe(true); + }); + + it("returns false for invalid statuses", () => { + expect(isValidCoursePlanStatus("other")).toBe(false); + expect(isValidCoursePlanStatus("")).toBe(false); + }); +}); + +describe("isValidCoursePlanUnitStatus", () => { + it("returns true for valid unit statuses", () => { + expect(isValidCoursePlanUnitStatus("NOT_STARTED")).toBe(true); + expect(isValidCoursePlanUnitStatus("IN_PROGRESS")).toBe(true); + expect(isValidCoursePlanUnitStatus("COMPLETED")).toBe(true); + }); + + it("returns false for invalid statuses", () => { + expect(isValidCoursePlanUnitStatus("ARCHIVED")).toBe(false); + expect(isValidCoursePlanUnitStatus("")).toBe(false); + }); +}); + +describe("hasDescription", () => { + it("returns true for non-empty string", () => { + expect(hasDescription("课程简介")).toBe(true); + }); + + it("returns false for null/undefined/empty/whitespace", () => { + expect(hasDescription(null)).toBe(false); + expect(hasDescription(undefined)).toBe(false); + expect(hasDescription("")).toBe(false); + expect(hasDescription(" ")).toBe(false); + }); +}); + +describe("hasObjectives", () => { + it("returns true for non-empty string", () => { + expect(hasObjectives("教学目标")).toBe(true); + }); + + it("returns false for null/undefined/empty/whitespace", () => { + expect(hasObjectives(null)).toBe(false); + expect(hasObjectives(undefined)).toBe(false); + expect(hasObjectives("")).toBe(false); + expect(hasObjectives(" ")).toBe(false); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/course-plans/course-plan-detail-client.tsx b/apps/portal-shell/src/features/teacher/course-plans/course-plan-detail-client.tsx new file mode 100644 index 0000000..7ebee64 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/course-plans/course-plan-detail-client.tsx @@ -0,0 +1,242 @@ +"use client"; + +/** + * 课程计划详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * 数据契约(@contract-pending 全 MSW): + * - 单查 coursePlan(id):❌ schema 无此字段 → MSW 兜底 + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#course-plan-detail + * + * 三态规范(§11.3 DoD): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - notFound:data 为 null 时显示空态节点 + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { ClipboardList } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import { useCoursePlan } from "@/lib/api"; +import { EmptyState } from "@/shared/components/ui/empty-state"; +import { + DetailPageShell, + DetailPageSkeleton, + DetailSection, + DetailField, +} from "@/shared/components/page-templates"; +import { + calcUnitProgress, + coursePlanUnitStatusToBadgeClass, + formatCoursePlanDate, + formatCoursePlanStatus, + formatCoursePlanUnitStatus, + formatProgress, + hasDescription, + hasObjectives, + progressToColorClass, +} from "@/features/teacher/course-plans/transformations"; + +/** + * 详情客户端主体。需由 server page 包裹在 中。 + */ +export function CoursePlanDetailClient(): React.ReactElement { + const t = useTranslations("coursePlans"); + const tCommon = useTranslations("common"); + const params = useParams<{ id: string }>(); + const planId = params?.id ?? ""; + + // @contract-pending MSW 兜底 + const { data, loading, error } = useCoursePlan(planId); + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("detail.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = + !loading && !error && !data ? ( + + ) : undefined; + + return ( + } + backHref="/shell/teacher/course-plans" + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={emptyNode} + > + {data ? : null} + {data ? : null} + + ); +} + +/** + * 详情基本信息区(对齐 §7.3 详情页模板)。 + */ +function CoursePlanDetailBody({ + detail, +}: { + detail: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("coursePlans"); + return ( + + + + + + + + {formatProgress(detail.progress)} + + } + /> + + + + + + ); +} + +/** + * 单元列表区(展示各单元进度)。 + */ +function UnitsSection({ + units, +}: { + units: NonNullable["data"]>["units"]; +}): React.ReactElement { + const t = useTranslations("coursePlans"); + return ( + + {units.length === 0 ? ( +

{t("detail.noUnits")}

+ ) : ( +
+ + + + + + + + + + + {units.map((unit) => { + const progress = calcUnitProgress(unit); + return ( + + + + + + + ); + })} + +
+ {t("detail.colUnitOrder")} + + {t("detail.colUnitTitle")} + + {t("detail.colUnitProgress")} + + {t("detail.colUnitStatus")} +
+ {unit.order} + {unit.title} +
+
+ + + {progress}% ({unit.completedLessonCount}/ + {unit.lessonCount}) + +
+
+ +
+
+ )} +
+ ); +} + +/** + * 单元状态徽章。 + */ +function UnitStatusBadge({ status }: { status: string }): React.ReactElement { + const label = formatCoursePlanUnitStatus(status); + const cls = coursePlanUnitStatusToBadgeClass(status); + return ( + + {label} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/course-plans/course-plan-list-client.tsx b/apps/portal-shell/src/features/teacher/course-plans/course-plan-list-client.tsx new file mode 100644 index 0000000..07af736 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/course-plans/course-plan-list-client.tsx @@ -0,0 +1,232 @@ +"use client"; + +/** + * 课程计划列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * 数据契约: + * - 列表查询 coursePlans(gradeId, subjectId, status, q):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#course-plans-list + * + * URL 状态:?grade=xxx &subject=xxx &status=xxx &q=xxx + * + * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState) + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { ClipboardList } from "lucide-react"; +import Link from "next/link"; +import { useSearchParams, useRouter } from "next/navigation"; +import { useMemo, useTransition } from "react"; +import { useTranslations } from "next-intl"; + +import { useCoursePlans, type CoursePlanListItem } from "@/lib/api"; +import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; +import { + ListPageShell, + ListPageSkeleton, +} from "@/shared/components/page-templates"; +import { + coursePlanStatusToBadgeClass, + formatCoursePlanDate, + formatCoursePlanStatus, +} from "@/features/teacher/course-plans/transformations"; + +/** + * 列表客户端主体。需由 server page 包裹在 中 + * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。 + */ +export function CoursePlanListClient(): React.ReactElement { + const t = useTranslations("coursePlans"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const gradeId = searchParams.get("grade") ?? ""; + const subjectId = searchParams.get("subject") ?? ""; + const statusFilter = searchParams.get("status") ?? ""; + const q = searchParams.get("q") ?? ""; + + // @contract-pending:MSW 兜底 + const { data, loading, error } = useCoursePlans({ + gradeId: gradeId || undefined, + subjectId: subjectId || undefined, + status: statusFilter || undefined, + q: q || undefined, + }); + + // 客户端二次筛选(q)—— 后端补齐列表查询后改服务端筛选 + const filteredItems = useMemo(() => { + const items = data?.items ?? []; + return items.filter((item) => { + if (q && !item.name.toLowerCase().includes(q.toLowerCase())) { + return false; + } + return true; + }); + }, [data, q]); + + const updateQuery = (key: string, value: string): void => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + startTransition(() => { + router.push(`/shell/teacher/course-plans?${params.toString()}`); + }); + }; + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("list.mswNotice")} +

+
+ ) : undefined; + + return ( + } + filters={ + <> + updateQuery("q", v)} + /> + updateQuery("grade", e.target.value)} + placeholder={t("list.gradePlaceholder")} + className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm" + aria-label={t("list.gradePlaceholder")} + /> + updateQuery("subject", e.target.value)} + placeholder={t("list.subjectPlaceholder")} + className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm" + aria-label={t("list.subjectPlaceholder")} + /> + + + } + loading={loading} + loadingNode={} + empty={filteredItems.length === 0 && !loading} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} +
+ } + > + +
+ ); +} + +/** + * 课程计划列表表格(纯展示组件,对齐 §8.2 排版规范)。 + */ +function CoursePlanTable({ + items, +}: { + items: CoursePlanListItem[]; +}): React.ReactElement { + const t = useTranslations("coursePlans"); + return ( +
+ + + + + + + + + + + + {items.map((plan) => ( + + + + + + + + ))} + +
{t("list.colName")} + {t("list.colSemester")} + {t("list.colStatus")} + {t("list.colUpdatedAt")} + + {t("list.colActions")} +
+ + {plan.name} + + {plan.description ? ( +

+ {plan.description} +

+ ) : null} +
{plan.semester} + + + {formatCoursePlanDate(plan.updatedAt)} + + + {t("list.viewDetail")} + +
+
+ ); +} + +/** + * 课程计划状态徽章(按状态色阶展示)。 + */ +function CoursePlanStatusBadge({ + status, +}: { + status: string; +}): React.ReactElement { + const label = formatCoursePlanStatus(status); + const cls = coursePlanStatusToBadgeClass(status); + return ( + + {label} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/course-plans/transformations.ts b/apps/portal-shell/src/features/teacher/course-plans/transformations.ts new file mode 100644 index 0000000..83f3178 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/course-plans/transformations.ts @@ -0,0 +1,227 @@ +/** + * Course Plans 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测) + * + * 所有格式化/映射函数均为纯函数,便于 vitest 单测。 + * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测" + */ + +import type { + CoursePlanDetail, + CoursePlanListItem, + CoursePlanStatus, + CoursePlanUnitStatus, +} from "@/lib/api"; + +/** 课程计划状态中文标签映射 */ +export const COURSE_PLAN_STATUS_LABEL: Record = { + DRAFT: "草稿", + IN_PROGRESS: "进行中", + COMPLETED: "已完成", + ARCHIVED: "已归档", +}; + +/** 单元状态中文标签映射 */ +export const COURSE_PLAN_UNIT_STATUS_LABEL: Record = { + NOT_STARTED: "未开始", + IN_PROGRESS: "进行中", + COMPLETED: "已完成", +}; + +/** + * 将课程计划状态枚举值映射为中文标签。 + * 未知状态回退为原始值。 + */ +export function formatCoursePlanStatus(status: string): string { + return COURSE_PLAN_STATUS_LABEL[status] ?? status; +} + +/** + * 将单元状态枚举值映射为中文标签。 + * 未知状态回退为原始值。 + */ +export function formatCoursePlanUnitStatus(status: string): string { + return COURSE_PLAN_UNIT_STATUS_LABEL[status] ?? status; +} + +/** + * 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。 + * 输入无效时返回占位符。 + */ +export function formatCoursePlanDate( + isoDate: string | null | undefined, +): string { + if (!isoDate) return "--"; + const d = new Date(isoDate); + if (Number.isNaN(d.getTime())) return "--"; + return d.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** + * 根据课程计划状态返回 Tailwind 徽章语义类名。 + */ +export function coursePlanStatusToBadgeClass(status: string): string { + switch (status) { + case "DRAFT": + return "bg-muted text-muted-foreground"; + case "IN_PROGRESS": + return "bg-primary/10 text-primary"; + case "COMPLETED": + return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; + case "ARCHIVED": + return "bg-muted text-muted-foreground"; + default: + return "bg-muted text-muted-foreground"; + } +} + +/** + * 根据单元状态返回 Tailwind 徽章语义类名。 + */ +export function coursePlanUnitStatusToBadgeClass(status: string): string { + switch (status) { + case "NOT_STARTED": + return "bg-muted text-muted-foreground"; + case "IN_PROGRESS": + return "bg-primary/10 text-primary"; + case "COMPLETED": + return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; + default: + return "bg-muted text-muted-foreground"; + } +} + +/** + * 格式化进度(0-100)为百分比字符串。 + * 输入无效返回 "--"。 + */ +export function formatProgress(progress: number): string { + if (!Number.isFinite(progress) || progress < 0 || progress > 100) return "--"; + return `${Math.round(progress)}%`; +} + +/** + * 根据进度(0-100)返回 Tailwind 文本语义类名。 + * - >= 75 → emerald(高进度) + * - >= 50 → amber(中进度) + * - >= 25 → primary + * - 其他 → destructive + */ +export function progressToColorClass(progress: number): string { + if (!Number.isFinite(progress) || progress < 0 || progress > 100) { + return "text-muted-foreground"; + } + if (progress >= 75) return "text-emerald-600 dark:text-emerald-400"; + if (progress >= 50) return "text-amber-600 dark:text-amber-400"; + if (progress >= 25) return "text-primary"; + return "text-destructive"; +} + +/** + * 从课程计划详情中提取列表项视图模型(裁剪字段)。 + */ +export function toCoursePlanListItem( + detail: CoursePlanDetail, +): CoursePlanListItem { + return { + id: detail.id, + name: detail.name, + gradeId: detail.gradeId, + subjectId: detail.subjectId, + semester: detail.semester, + status: detail.status, + description: detail.description, + createdAt: detail.createdAt, + updatedAt: detail.updatedAt, + }; +} + +/** + * 计算单元完成进度(completedLessonCount / lessonCount * 100)。 + * lessonCount 为 0 或输入无效时返回 0。 + */ +export function calcUnitProgress(unit: { + lessonCount: number; + completedLessonCount: number; +}): number { + if ( + !Number.isFinite(unit.lessonCount) || + !Number.isFinite(unit.completedLessonCount) || + unit.lessonCount <= 0 + ) { + return 0; + } + const ratio = unit.completedLessonCount / unit.lessonCount; + if (ratio < 0) return 0; + if (ratio > 1) return 100; + return Math.round(ratio * 100); +} + +/** + * 判断课程计划是否可编辑(DRAFT 状态)。 + */ +export function isCoursePlanEditable(status: string): boolean { + return status === "DRAFT"; +} + +/** + * 判断课程计划是否已归档(ARCHIVED)。 + */ +export function isCoursePlanArchived(status: string): boolean { + return status === "ARCHIVED"; +} + +/** + * 判断课程计划是否进行中(IN_PROGRESS)。 + */ +export function isCoursePlanInProgress(status: string): boolean { + return status === "IN_PROGRESS"; +} + +/** + * 将 CoursePlanStatus 类型守卫:判断字符串是否为合法状态。 + */ +export function isValidCoursePlanStatus( + status: string, +): status is CoursePlanStatus { + return ( + status === "DRAFT" || + status === "IN_PROGRESS" || + status === "COMPLETED" || + status === "ARCHIVED" + ); +} + +/** + * 将 CoursePlanUnitStatus 类型守卫:判断字符串是否为合法单元状态。 + */ +export function isValidCoursePlanUnitStatus( + status: string, +): status is CoursePlanUnitStatus { + return ( + status === "NOT_STARTED" || + status === "IN_PROGRESS" || + status === "COMPLETED" + ); +} + +/** + * 判断课程计划是否有描述。 + */ +export function hasDescription( + description: string | null | undefined, +): boolean { + return Boolean(description && description.trim().length > 0); +} + +/** + * 判断课程计划是否有教学目标。 + */ +export function hasObjectives(objectives: string | null | undefined): boolean { + return Boolean(objectives && objectives.trim().length > 0); +} diff --git a/apps/portal-shell/src/features/teacher/elective/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/elective/__tests__/transformations.test.ts new file mode 100644 index 0000000..8549cf5 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/elective/__tests__/transformations.test.ts @@ -0,0 +1,303 @@ +/** + * Elective 数据变换工具单测(ARCHITECTURE.md §11.3 DoD) + * + * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测" + */ +import { describe, expect, it } from "vitest"; + +import type { Elective } from "@/lib/api"; + +import { + ELECTIVE_STATUS_LABEL, + calcAvailableCount, + calcEnrollmentRate, + electiveStatusToBadgeClass, + enrollmentRateToColorClass, + formatElectiveDate, + formatElectiveStatus, + formatEnrollmentCount, + hasAvailableSpot, + hasDescription, + isElectiveArchived, + isElectiveClosed, + isElectiveEditable, + isElectiveOpen, + isValidElectiveStatus, + toElectiveListItem, +} from "../transformations"; + +const sampleElective: Elective = { + id: "ele-001", + name: "高等数学拓展", + description: "为有兴趣深造的学生提供进阶数学内容", + capacity: 30, + enrolledCount: 20, + semester: "2026-fall", + gradeLevel: "grade-11", + subject: "数学", + teacherId: "tch-001", + teacherName: "李老师", + status: "OPEN", + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-09-15T00:00:00Z", +}; + +describe("formatElectiveStatus", () => { + it("maps known statuses to Chinese labels", () => { + expect(formatElectiveStatus("DRAFT")).toBe("草稿"); + expect(formatElectiveStatus("OPEN")).toBe("报名中"); + expect(formatElectiveStatus("CLOSED")).toBe("已关闭"); + expect(formatElectiveStatus("ARCHIVED")).toBe("已归档"); + }); + + it("returns original value for unknown status", () => { + expect(formatElectiveStatus("other")).toBe("other"); + expect(formatElectiveStatus("")).toBe(""); + }); + + it("ELECTIVE_STATUS_LABEL covers 4 standard statuses", () => { + expect(Object.keys(ELECTIVE_STATUS_LABEL)).toHaveLength(4); + }); +}); + +describe("formatElectiveDate", () => { + it("formats valid ISO date string with time", () => { + const result = formatElectiveDate("2026-07-22T10:30:00Z"); + expect(result).toContain("2026"); + expect(result).toContain("07"); + }); + + it("returns placeholder for null/undefined/empty", () => { + expect(formatElectiveDate(null)).toBe("--"); + expect(formatElectiveDate(undefined)).toBe("--"); + expect(formatElectiveDate("")).toBe("--"); + }); + + it("returns placeholder for invalid date", () => { + expect(formatElectiveDate("not-a-date")).toBe("--"); + }); +}); + +describe("electiveStatusToBadgeClass", () => { + it("returns primary class for OPEN", () => { + expect(electiveStatusToBadgeClass("OPEN")).toContain("primary"); + }); + + it("returns amber class for CLOSED", () => { + expect(electiveStatusToBadgeClass("CLOSED")).toContain("amber"); + }); + + it("returns muted for DRAFT/ARCHIVED/unknown", () => { + expect(electiveStatusToBadgeClass("DRAFT")).toBe( + "bg-muted text-muted-foreground", + ); + expect(electiveStatusToBadgeClass("ARCHIVED")).toBe( + "bg-muted text-muted-foreground", + ); + expect(electiveStatusToBadgeClass("unknown")).toBe( + "bg-muted text-muted-foreground", + ); + }); +}); + +describe("calcEnrollmentRate", () => { + it("calculates percentage for valid input", () => { + expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 30 })).toBe(100); + expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 20 })).toBe(67); + expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 0 })).toBe(0); + }); + + it("returns 0 for zero/negative capacity", () => { + expect(calcEnrollmentRate({ capacity: 0, enrolledCount: 0 })).toBe(0); + expect(calcEnrollmentRate({ capacity: -1, enrolledCount: 0 })).toBe(0); + }); + + it("clamps to 100 when enrolledCount > capacity", () => { + expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 35 })).toBe(100); + }); + + it("clamps to 0 for negative enrolledCount", () => { + expect(calcEnrollmentRate({ capacity: 30, enrolledCount: -5 })).toBe(0); + }); + + it("returns 0 for invalid input (NaN/Infinity)", () => { + expect( + calcEnrollmentRate({ + capacity: Number.NaN, + enrolledCount: 1, + }), + ).toBe(0); + expect( + calcEnrollmentRate({ + capacity: Number.POSITIVE_INFINITY, + enrolledCount: 1, + }), + ).toBe(0); + }); +}); + +describe("enrollmentRateToColorClass", () => { + it("returns destructive for >= 90", () => { + expect(enrollmentRateToColorClass(90)).toContain("destructive"); + expect(enrollmentRateToColorClass(100)).toContain("destructive"); + }); + + it("returns amber for 50-89", () => { + expect(enrollmentRateToColorClass(50)).toContain("amber"); + expect(enrollmentRateToColorClass(89)).toContain("amber"); + }); + + it("returns primary for 1-49", () => { + expect(enrollmentRateToColorClass(1)).toBe("text-primary"); + expect(enrollmentRateToColorClass(49)).toBe("text-primary"); + }); + + it("returns muted for 0", () => { + expect(enrollmentRateToColorClass(0)).toBe("text-muted-foreground"); + }); + + it("returns muted for invalid input", () => { + expect(enrollmentRateToColorClass(-1)).toBe("text-muted-foreground"); + expect(enrollmentRateToColorClass(101)).toBe("text-muted-foreground"); + }); +}); + +describe("formatEnrollmentCount", () => { + it("formats count as enrolled/capacity", () => { + expect(formatEnrollmentCount({ capacity: 30, enrolledCount: 20 })).toBe( + "20/30", + ); + expect(formatEnrollmentCount({ capacity: 0, enrolledCount: 0 })).toBe( + "0/0", + ); + }); + + it("returns placeholder for invalid input", () => { + expect( + formatEnrollmentCount({ + capacity: Number.NaN, + enrolledCount: 1, + }), + ).toBe("--"); + }); +}); + +describe("isElectiveEditable", () => { + it("returns true only for DRAFT", () => { + expect(isElectiveEditable("DRAFT")).toBe(true); + expect(isElectiveEditable("OPEN")).toBe(false); + expect(isElectiveEditable("CLOSED")).toBe(false); + expect(isElectiveEditable("ARCHIVED")).toBe(false); + }); +}); + +describe("isElectiveOpen", () => { + it("returns true only for OPEN", () => { + expect(isElectiveOpen("OPEN")).toBe(true); + expect(isElectiveOpen("DRAFT")).toBe(false); + }); +}); + +describe("isElectiveClosed", () => { + it("returns true only for CLOSED", () => { + expect(isElectiveClosed("CLOSED")).toBe(true); + expect(isElectiveClosed("OPEN")).toBe(false); + }); +}); + +describe("isElectiveArchived", () => { + it("returns true only for ARCHIVED", () => { + expect(isElectiveArchived("ARCHIVED")).toBe(true); + expect(isElectiveArchived("OPEN")).toBe(false); + }); +}); + +describe("isValidElectiveStatus", () => { + it("returns true for valid statuses", () => { + expect(isValidElectiveStatus("DRAFT")).toBe(true); + expect(isValidElectiveStatus("OPEN")).toBe(true); + expect(isValidElectiveStatus("CLOSED")).toBe(true); + expect(isValidElectiveStatus("ARCHIVED")).toBe(true); + }); + + it("returns false for invalid statuses", () => { + expect(isValidElectiveStatus("other")).toBe(false); + expect(isValidElectiveStatus("")).toBe(false); + }); +}); + +describe("toElectiveListItem", () => { + it("returns isomorphic shape with all fields", () => { + const item = toElectiveListItem(sampleElective); + expect(item).toEqual(sampleElective); + }); + + it("returns new object (not same reference)", () => { + const item = toElectiveListItem(sampleElective); + expect(item).not.toBe(sampleElective); + }); +}); + +describe("hasDescription", () => { + it("returns true for non-empty string", () => { + expect(hasDescription("选修课简介")).toBe(true); + }); + + it("returns false for null/undefined/empty/whitespace", () => { + expect(hasDescription(null)).toBe(false); + expect(hasDescription(undefined)).toBe(false); + expect(hasDescription("")).toBe(false); + expect(hasDescription(" ")).toBe(false); + }); +}); + +describe("hasAvailableSpot", () => { + it("returns true when enrolledCount < capacity", () => { + expect(hasAvailableSpot({ capacity: 30, enrolledCount: 20 })).toBe(true); + expect(hasAvailableSpot({ capacity: 30, enrolledCount: 29 })).toBe(true); + }); + + it("returns false when enrolledCount >= capacity", () => { + expect(hasAvailableSpot({ capacity: 30, enrolledCount: 30 })).toBe(false); + expect(hasAvailableSpot({ capacity: 30, enrolledCount: 35 })).toBe(false); + }); + + it("returns false for zero/negative capacity", () => { + expect(hasAvailableSpot({ capacity: 0, enrolledCount: 0 })).toBe(false); + expect(hasAvailableSpot({ capacity: -1, enrolledCount: 0 })).toBe(false); + }); + + it("returns false for invalid input", () => { + expect( + hasAvailableSpot({ + capacity: Number.NaN, + enrolledCount: 1, + }), + ).toBe(false); + }); +}); + +describe("calcAvailableCount", () => { + it("calculates remaining spots for valid input", () => { + expect(calcAvailableCount({ capacity: 30, enrolledCount: 20 })).toBe(10); + expect(calcAvailableCount({ capacity: 30, enrolledCount: 30 })).toBe(0); + }); + + it("returns 0 when over capacity", () => { + expect(calcAvailableCount({ capacity: 30, enrolledCount: 35 })).toBe(0); + }); + + it("returns 0 for zero/negative capacity", () => { + expect(calcAvailableCount({ capacity: 0, enrolledCount: 0 })).toBe(0); + expect(calcAvailableCount({ capacity: -1, enrolledCount: 0 })).toBe(0); + }); + + it("returns 0 for invalid input", () => { + expect( + calcAvailableCount({ + capacity: Number.NaN, + enrolledCount: 1, + }), + ).toBe(0); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/elective/elective-create-client.tsx b/apps/portal-shell/src/features/teacher/elective/elective-create-client.tsx new file mode 100644 index 0000000..bda46cf --- /dev/null +++ b/apps/portal-shell/src/features/teacher/elective/elective-create-client.tsx @@ -0,0 +1,235 @@ +"use client"; + +/** + * 新建选修课表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * 数据契约: + * - mutation createElective(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-elective + * + * 三态规范(§11.3 DoD): + * - loading:FormPageSkeleton(初始数据加载,由 server page Suspense 兜底) + * - error:errorSummary 表单级错误 + * - success:notify.success + router.push 回列表 + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { BookMarked } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTransition, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { useCreateElective, type CreateElectiveInput } from "@/lib/api"; +import { FormPageShell } from "@/shared/components/page-templates"; +import { notify } from "@/shared/lib/notify"; + +/** + * 表单客户端主体。需由 server page 包裹在 中。 + */ +export function ElectiveCreateClient(): React.ReactElement { + const t = useTranslations("elective"); + const router = useRouter(); + const [, startTransition] = useTransition(); + + // @contract-pending:MSW 兜底 + const { run: createElective, loading: submitting } = useCreateElective(); + + const handleSubmit = async (input: CreateElectiveInput): Promise => { + try { + await createElective(input); + notify.success(t("create.success")); + startTransition(() => { + router.push("/shell/teacher/elective"); + }); + } catch (err) { + notify.error(`${t("create.error")}: ${String(err)}`); + } + }; + + return ; +} + +/** + * 表单主体(受控表单 + 内联校验)。 + */ +function ElectiveFormInner({ + submitting, + onSubmit, + initial, +}: { + submitting: boolean; + onSubmit: (input: CreateElectiveInput) => Promise; + initial?: Partial; +}): React.ReactElement { + const t = useTranslations("elective"); + const tCommon = useTranslations("common"); + + const [name, setName] = useState(initial?.name ?? ""); + const [description, setDescription] = useState(initial?.description ?? ""); + const [capacity, setCapacity] = useState(String(initial?.capacity ?? "30")); + const [semester, setSemester] = useState(initial?.semester ?? "2026-fall"); + const [gradeLevel, setGradeLevel] = useState(initial?.gradeLevel ?? ""); + const [subject, setSubject] = useState(initial?.subject ?? ""); + const [teacherId, setTeacherId] = useState(initial?.teacherId ?? ""); + const [error, setError] = useState(null); + + const handleFormSubmit = (): void => { + setError(null); + + if (!name.trim()) { + setError(t("create.errorNameRequired")); + return; + } + if (!gradeLevel.trim()) { + setError(t("create.errorGradeLevelRequired")); + return; + } + if (!subject.trim()) { + setError(t("create.errorSubjectRequired")); + return; + } + if (!semester.trim()) { + setError(t("create.errorSemesterRequired")); + return; + } + + const capacityNum = Number(capacity); + if (!Number.isFinite(capacityNum) || capacityNum <= 0) { + setError(t("create.errorCapacityInvalid")); + return; + } + + const input: CreateElectiveInput = { + name: name.trim(), + description: description.trim() || undefined, + capacity: capacityNum, + semester: semester.trim(), + gradeLevel: gradeLevel.trim(), + subject: subject.trim(), + teacherId: teacherId.trim() || undefined, + }; + + void onSubmit(input); + }; + + return ( + } + backHref="/shell/teacher/elective" + onSubmit={handleFormSubmit} + submitting={submitting} + submitLabel={t("create.submit")} + cancelLabel={tCommon("button.cancel")} + errorSummary={ + error ?

{error}

: undefined + } + > + + setName(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder={t("create.namePlaceholder")} + required + /> + + + +