feat(homework,classes,course-plans): add scans, student data, take confirm, error boundaries, dialogs, hooks, calendar
homework: - Add data-access-scans, data-access-student, data-access-utils, data-access-exam-cross - Add excellent-submissions, homework-take-confirm-dialog, homework-take-sidebar components classes: - Add class-delete-dialog, class-error-boundary, class-form-dialog, class-form-utils - Add class-list-table, class-list-toolbar, class-skeleton - Add schedule-create-dialog, schedule-delete-dialog, schedule-edit-dialog, schedule-utils - Add data-access-teacher and hooks directory course-plans: - Add course-plan-calendar, sortable-week-row, template-picker-dialog components - Add lib directory
This commit is contained in:
@@ -5,12 +5,18 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import {
|
||||
CreateCoursePlanSchema,
|
||||
UpdateCoursePlanSchema,
|
||||
CreateCoursePlanItemSchema,
|
||||
UpdateCoursePlanItemSchema,
|
||||
GetCoursePlansParamsSchema,
|
||||
GradeIdSchema,
|
||||
ReorderItemsSchema,
|
||||
BulkToggleSchema,
|
||||
CopyPlanSchema,
|
||||
} from "./schema"
|
||||
import {
|
||||
getCoursePlans,
|
||||
@@ -22,15 +28,78 @@ import {
|
||||
createCoursePlanItem,
|
||||
updateCoursePlanItem,
|
||||
deleteCoursePlanItem,
|
||||
reorderCoursePlanItems,
|
||||
bulkUpdateItemCompleted,
|
||||
copyCoursePlanToClasses,
|
||||
} from "./data-access"
|
||||
import type { CoursePlanWithItems, GetCoursePlansParams, CoursePlanListItem, GradeCoursePlanProgressResult } from "./types"
|
||||
import type {
|
||||
CoursePlanWithItems,
|
||||
GetCoursePlansParams,
|
||||
CoursePlanListItem,
|
||||
CoursePlanQueryScope,
|
||||
GradeCoursePlanProgressResult,
|
||||
ReorderCoursePlanItemInput,
|
||||
} from "./types"
|
||||
|
||||
const revalidatePlanPaths = (id?: string) => {
|
||||
/**
|
||||
* 从 AuthContext 解析课程计划查询范围。
|
||||
* - admin/教务主任 → 全局视图
|
||||
* - teacher → 仅自己负责的计划(class_taught scope)
|
||||
* - parent/student → 仅孩子所在班级的计划
|
||||
*/
|
||||
function resolveScope(
|
||||
ctx: { userId: string; dataScope: DataScope; roles: string[] }
|
||||
): CoursePlanQueryScope {
|
||||
const isAdmin = ctx.roles.some(
|
||||
(r) => r === "admin" || r === "grade_manager" || r === "academic_director"
|
||||
)
|
||||
|
||||
if (isAdmin || ctx.dataScope.type === "all") {
|
||||
return { userId: ctx.userId, isAdmin: true }
|
||||
}
|
||||
|
||||
// 教师:class_taught scope 携带 classIds,同时按 teacherId 过滤
|
||||
if (ctx.dataScope.type === "class_taught") {
|
||||
return {
|
||||
userId: ctx.userId,
|
||||
isAdmin: false,
|
||||
teacherId: ctx.userId,
|
||||
classIds: ctx.dataScope.classIds,
|
||||
}
|
||||
}
|
||||
|
||||
// 学生:class_members scope 携带 classIds
|
||||
if (ctx.dataScope.type === "class_members") {
|
||||
return {
|
||||
userId: ctx.userId,
|
||||
isAdmin: false,
|
||||
classIds: ctx.dataScope.classIds,
|
||||
}
|
||||
}
|
||||
|
||||
// 家长:children scope 需通过孩子 ID 解析班级(调用方已预解析)
|
||||
if (ctx.dataScope.type === "children") {
|
||||
return {
|
||||
userId: ctx.userId,
|
||||
isAdmin: false,
|
||||
classIds: [], // 家长视角需调用方补充 classIds
|
||||
}
|
||||
}
|
||||
|
||||
// grade_managed / owned:仅返回自己创建的
|
||||
return { userId: ctx.userId, isAdmin: false }
|
||||
}
|
||||
|
||||
const revalidatePlanPaths = (id?: string): void => {
|
||||
revalidatePath("/admin/course-plans")
|
||||
revalidatePath("/teacher/course-plans")
|
||||
revalidatePath("/parent/course-plans")
|
||||
revalidatePath("/student/course-plans")
|
||||
if (id) {
|
||||
revalidatePath(`/admin/course-plans/${id}`)
|
||||
revalidatePath(`/teacher/course-plans/${id}`)
|
||||
revalidatePath(`/parent/course-plans/${id}`)
|
||||
revalidatePath(`/student/course-plans/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,9 +147,11 @@ export async function updateCoursePlanAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const existing = await getCoursePlanById(id)
|
||||
// 权限二次校验:非 admin 只能修改自己负责的计划
|
||||
const scope = resolveScope(ctx)
|
||||
const existing = await getCoursePlanById(id, scope)
|
||||
if (!existing) return { success: false, message: "Course plan not found" }
|
||||
|
||||
const parsed = UpdateCoursePlanSchema.safeParse({
|
||||
@@ -119,9 +190,11 @@ export async function deleteCoursePlanAction(
|
||||
id: string
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const existing = await getCoursePlanById(id)
|
||||
// 权限二次校验:非 admin 只能删除自己负责的计划
|
||||
const scope = resolveScope(ctx)
|
||||
const existing = await getCoursePlanById(id, scope)
|
||||
if (!existing) return { success: false, message: "Course plan not found" }
|
||||
|
||||
await deleteCoursePlan(id)
|
||||
@@ -136,8 +209,25 @@ export async function getCoursePlansAction(
|
||||
params?: GetCoursePlansParams
|
||||
): Promise<ActionState<CoursePlanListItem[]>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const data = await getCoursePlans(params)
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
|
||||
// P1-6:Zod 验证入参(避免 union 类型问题,提前返回)
|
||||
if (params) {
|
||||
const parsed = GetCoursePlansParamsSchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid params",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
const scope = resolveScope(ctx)
|
||||
const data = await getCoursePlans(parsed.data, scope)
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
const scope = resolveScope(ctx)
|
||||
const data = await getCoursePlans(undefined, scope)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
@@ -148,8 +238,9 @@ export async function getCoursePlanAction(
|
||||
id: string
|
||||
): Promise<ActionState<CoursePlanWithItems>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const data = await getCoursePlanById(id)
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const scope = resolveScope(ctx)
|
||||
const data = await getCoursePlanById(id, scope)
|
||||
if (!data) return { success: false, message: "Course plan not found" }
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
@@ -162,7 +253,7 @@ export async function createCoursePlanItemAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const parsed = CreateCoursePlanItemSchema.safeParse({
|
||||
planId: formData.get("planId"),
|
||||
@@ -182,6 +273,11 @@ export async function createCoursePlanItemAction(
|
||||
}
|
||||
}
|
||||
|
||||
// 权限二次校验:非 admin 只能操作自己负责的计划
|
||||
const scope = resolveScope(ctx)
|
||||
const existing = await getCoursePlanById(parsed.data.planId, scope)
|
||||
if (!existing) return { success: false, message: "Course plan not found" }
|
||||
|
||||
const itemId = await createCoursePlanItem(parsed.data)
|
||||
revalidatePlanPaths(parsed.data.planId)
|
||||
return { success: true, message: "Week plan added", data: itemId }
|
||||
@@ -263,6 +359,108 @@ export async function toggleCoursePlanItemCompletedAction(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽排序周计划条目(P1-7)。
|
||||
*/
|
||||
export async function reorderCoursePlanItemsAction(
|
||||
input: { planId: string; items: ReorderCoursePlanItemInput[] }
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const parsed = ReorderItemsSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid params", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
// 权限二次校验:非 admin 只能操作自己负责的计划
|
||||
const scope = resolveScope(ctx)
|
||||
const existing = await getCoursePlanById(parsed.data.planId, scope)
|
||||
if (!existing) return { success: false, message: "Course plan not found" }
|
||||
|
||||
await reorderCoursePlanItems(parsed.data.planId, parsed.data.items)
|
||||
revalidatePlanPaths(parsed.data.planId)
|
||||
return { success: true, message: "Order saved", data: parsed.data.planId }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量标记周计划条目完成状态(P2-4)。
|
||||
*/
|
||||
export async function bulkToggleItemsAction(
|
||||
itemIds: string[],
|
||||
completed: boolean
|
||||
): Promise<ActionState<number>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const parsed = BulkToggleSchema.safeParse({ itemIds, completed })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid params", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const count = await bulkUpdateItemCompleted(parsed.data.itemIds, parsed.data.completed)
|
||||
revalidatePlanPaths()
|
||||
return { success: true, message: "Bulk updated", data: count }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制课程计划到其他班级(P2-4)。
|
||||
*/
|
||||
export async function copyCoursePlanAction(
|
||||
sourcePlanId: string,
|
||||
targetClassIds: string[]
|
||||
): Promise<ActionState<string[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const parsed = CopyPlanSchema.safeParse({ sourcePlanId, targetClassIds })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid params", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
// 权限二次校验:非 admin 只能复制自己负责的计划
|
||||
const scope = resolveScope(ctx)
|
||||
const existing = await getCoursePlanById(parsed.data.sourcePlanId, scope)
|
||||
if (!existing) return { success: false, message: "Course plan not found" }
|
||||
|
||||
const ids = await copyCoursePlanToClasses(parsed.data.sourcePlanId, parsed.data.targetClassIds)
|
||||
revalidatePlanPaths()
|
||||
return { success: true, message: "Copied", data: ids }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取课程计划模板候选列表(P2-5 模板库)。
|
||||
*
|
||||
* 复用现有计划作为模板:返回当前用户可见范围内的计划(含 items 数量),
|
||||
* 供 "从模板创建" 选择器使用。不新增 DB 表,避免 schema 迁移。
|
||||
*
|
||||
* @param subjectId 可选,按学科过滤
|
||||
*/
|
||||
export async function getTemplateCandidatesAction(
|
||||
subjectId?: string
|
||||
): Promise<ActionState<CoursePlanListItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const scope = resolveScope(ctx)
|
||||
const data = await getCoursePlans(
|
||||
subjectId ? { subjectId } : undefined,
|
||||
scope
|
||||
)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级仪表盘 - 维度4:获取年级下所有班级的教学计划进度。
|
||||
*/
|
||||
@@ -272,13 +470,27 @@ export async function getGradeCoursePlanProgressAction(
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
|
||||
if (!gradeId || gradeId.trim().length === 0) {
|
||||
const parsed = GradeIdSchema.safeParse({ gradeId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid grade id" }
|
||||
}
|
||||
|
||||
const data = await getGradeCoursePlanProgress({ gradeId })
|
||||
const data = await getGradeCoursePlanProgress({ gradeId: parsed.data.gradeId })
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 监控埋点接口(P2-8)────────────────────────────────────
|
||||
// 预留埋点接口,供前端调用以记录关键操作。
|
||||
// 当前为空实现,后续接入监控 SDK 时只需修改此函数。
|
||||
export function trackCoursePlanEvent(
|
||||
event: string,
|
||||
properties?: Record<string, unknown>
|
||||
): void {
|
||||
// 预留:接入监控 SDK 后实现
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug(`[course-plans] ${event}`, properties)
|
||||
}
|
||||
}
|
||||
|
||||
219
src/modules/course-plans/components/course-plan-calendar.tsx
Normal file
219
src/modules/course-plans/components/course-plan-calendar.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ChevronLeft, ChevronRight, CalendarDays } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import type { CoursePlanWithItems } from "../types"
|
||||
import {
|
||||
planToCalendarEvents,
|
||||
buildMonthGrid,
|
||||
eventsOnDay,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
isSameDay,
|
||||
addMonths,
|
||||
type CalendarEvent,
|
||||
} from "../lib/calendar-utils"
|
||||
|
||||
interface CoursePlanCalendarProps {
|
||||
plan: CoursePlanWithItems
|
||||
}
|
||||
|
||||
/**
|
||||
* 课程计划月历视图(P2-7)。
|
||||
*
|
||||
* - 通过纯函数 `planToCalendarEvents` 将周计划映射到日期范围
|
||||
* - 月历网格采用 6×7 布局,周一为每周起始
|
||||
* - 支持上一月/下一月/今天导航
|
||||
* - 单元格内显示当日事件,按完成状态着色
|
||||
* - 无 `plan.startDate` 时显示空状态提示
|
||||
*
|
||||
* 可访问性:
|
||||
* - 导航按钮带 `aria-label`
|
||||
* - 日期单元格带 `role="gridcell"` 与 `aria-label`
|
||||
* - 今日单元格带 `aria-current="date"`
|
||||
*/
|
||||
export function CoursePlanCalendar({ plan }: CoursePlanCalendarProps): JSX.Element {
|
||||
const t = useTranslations("coursePlans")
|
||||
const [cursor, setCursor] = useState<Date>(() => new Date())
|
||||
|
||||
const events: CalendarEvent[] = useMemo(
|
||||
() => planToCalendarEvents(plan),
|
||||
[plan],
|
||||
)
|
||||
|
||||
const grid: Date[] = useMemo(() => buildMonthGrid(cursor), [cursor])
|
||||
const monthStart = useMemo(() => startOfMonth(cursor), [cursor])
|
||||
const monthEnd = useMemo(() => endOfMonth(cursor), [cursor])
|
||||
const visibleEvents = useMemo(
|
||||
() => events.filter((e) => {
|
||||
const start = new Date(e.startDate)
|
||||
const end = new Date(e.endDate)
|
||||
return start <= monthEnd && end >= monthStart
|
||||
}),
|
||||
[events, monthStart, monthEnd],
|
||||
)
|
||||
|
||||
const today = new Date()
|
||||
const weekLabels = t.raw("calendar.weekShort") as unknown as string[]
|
||||
|
||||
const handlePrev = (): void => setCursor((prev) => addMonths(prev, -1))
|
||||
const handleNext = (): void => setCursor((prev) => addMonths(prev, 1))
|
||||
const handleToday = (): void => setCursor(new Date())
|
||||
|
||||
if (!plan.startDate) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-8 text-center"
|
||||
role="status"
|
||||
>
|
||||
<CalendarDays className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("calendar.noStartDate")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 导航条 */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("calendar.monthTitle", {
|
||||
year: cursor.getFullYear(),
|
||||
month: cursor.getMonth() + 1,
|
||||
})}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handlePrev}
|
||||
aria-label={t("calendar.prevMonth")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleToday}>
|
||||
{t("calendar.today")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleNext}
|
||||
aria-label={t("calendar.nextMonth")}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 周首行 */}
|
||||
<div
|
||||
className="grid grid-cols-7 gap-1 text-center text-xs font-medium text-muted-foreground"
|
||||
role="row"
|
||||
>
|
||||
{weekLabels.map((label) => (
|
||||
<div key={label} className="py-1" role="columnheader">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 日期网格 */}
|
||||
<div
|
||||
className="grid grid-cols-7 gap-1"
|
||||
role="grid"
|
||||
aria-label={t("calendar.title")}
|
||||
>
|
||||
{grid.map((day, index) => {
|
||||
const dayEvents = eventsOnDay(visibleEvents, day)
|
||||
const inMonth = day.getMonth() === cursor.getMonth()
|
||||
const isToday = isSameDay(day, today)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
role="gridcell"
|
||||
aria-label={day.toDateString()}
|
||||
aria-current={isToday ? "date" : undefined}
|
||||
className={cn(
|
||||
"min-h-[80px] rounded-md border p-1 text-left",
|
||||
inMonth ? "bg-card" : "bg-muted/30",
|
||||
isToday && "ring-2 ring-primary",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 text-xs font-medium",
|
||||
inMonth ? "text-foreground" : "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{dayEvents.slice(0, 2).map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
"truncate rounded px-1 py-0.5 text-[10px] leading-tight",
|
||||
event.isCompleted
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
title={`${t("calendar.week", { week: event.week })} · ${event.title}`}
|
||||
>
|
||||
{t("calendar.week", { week: event.week })}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 2 ? (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
+{dayEvents.length - 2}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当月事件列表 */}
|
||||
{visibleEvents.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">{t("calendar.title")}</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{visibleEvents.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md border p-2 text-xs"
|
||||
>
|
||||
<Badge variant={event.isCompleted ? "default" : "secondary"}>
|
||||
{event.isCompleted ? t("calendar.completed") : t("calendar.pending")}
|
||||
</Badge>
|
||||
<span className="font-medium">
|
||||
{t("calendar.week", { week: event.week })}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{event.title}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{event.startDate} ~ {event.endDate}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("calendar.hours", { hours: event.hours })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t("calendar.noPlans")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, Pencil, Plus, Trash2, Download } from "lucide-react"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -12,37 +29,48 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { CoursePlanProgress } from "./course-plan-progress"
|
||||
import { CoursePlanItemEditor } from "./course-plan-item-editor"
|
||||
import { deleteCoursePlanAction } from "../actions"
|
||||
import type { CoursePlanWithItems, CoursePlanStatus } from "../types"
|
||||
|
||||
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
|
||||
planning: "规划中",
|
||||
active: "进行中",
|
||||
completed: "已完成",
|
||||
paused: "已暂停",
|
||||
}
|
||||
import { SortableWeekRow } from "./sortable-week-row"
|
||||
import { CoursePlanCalendar } from "./course-plan-calendar"
|
||||
import {
|
||||
deleteCoursePlanAction,
|
||||
bulkToggleItemsAction,
|
||||
reorderCoursePlanItemsAction,
|
||||
trackCoursePlanEvent,
|
||||
} from "../actions"
|
||||
import { exportCoursePlanReport } from "../lib/export-utils"
|
||||
import type { CoursePlanWithItems, ReorderCoursePlanItemInput } from "../types"
|
||||
|
||||
export function CoursePlanDetail({
|
||||
plan,
|
||||
editHref,
|
||||
backHref,
|
||||
successHref,
|
||||
textbooksHref,
|
||||
homeworkHref,
|
||||
}: {
|
||||
plan: CoursePlanWithItems
|
||||
editHref?: string
|
||||
backHref?: string
|
||||
}) {
|
||||
/** 删除成功后的跳转路径(替代 URL 路径推断) */
|
||||
successHref?: string
|
||||
/** 教材列表页地址(按角色不同);提供时周计划章节文本渲染为可跳转链接(P2-3 数据联动) */
|
||||
textbooksHref?: string
|
||||
/** 作业列表页地址(按角色不同);提供时周计划行尾显示作业跳转按钮(P2-3 数据联动) */
|
||||
homeworkHref?: string
|
||||
}): JSX.Element {
|
||||
const t = useTranslations("coursePlans")
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const canManage = hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
@@ -51,39 +79,134 @@ export function CoursePlanDetail({
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<CoursePlanWithItems["items"][number] | undefined>()
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const completedItems = plan.items.filter((i) => i.isCompleted).length
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await deleteCoursePlanAction(plan.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
const base = backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"
|
||||
router.push(base)
|
||||
toast.success(t("toast.deleted"))
|
||||
trackCoursePlanEvent("plan_deleted", { planId: plan.id })
|
||||
router.push(successHref ?? "/admin/course-plans")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "删除失败")
|
||||
toast.error(res.message || t("toast.deleteFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("删除失败")
|
||||
toast.error(t("toast.deleteFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
setDeleteOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateEditor = () => {
|
||||
const openCreateEditor = (): void => {
|
||||
setEditingItem(undefined)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEditEditor = (item: CoursePlanWithItems["items"][number]) => {
|
||||
const openEditEditor = (item: CoursePlanWithItems["items"][number]): void => {
|
||||
setEditingItem(item)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string): void => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkComplete = async (): Promise<void> => {
|
||||
if (selectedIds.size === 0) return
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await bulkToggleItemsAction(Array.from(selectedIds), true)
|
||||
if (res.success) {
|
||||
toast.success(t("toast.bulkMarked", { count: res.data ?? 0 }))
|
||||
setSelectedIds(new Set())
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(t("toast.bulkFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("toast.bulkFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽排序传感器:指针 + 键盘(a11y)
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const handleReorder = async (event: DragEndEvent): Promise<void> => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = plan.items.findIndex((i) => i.id === active.id)
|
||||
const newIndex = plan.items.findIndex((i) => i.id === over.id)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
// 乐观更新:先计算新顺序(无需本地 state,dnd-kit transform 已处理拖拽视觉)
|
||||
const reordered = arrayMove(plan.items, oldIndex, newIndex)
|
||||
const items: ReorderCoursePlanItemInput[] = reordered.map((item, index) => ({
|
||||
id: item.id,
|
||||
week: index + 1,
|
||||
}))
|
||||
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await reorderCoursePlanItemsAction({ planId: plan.id, items })
|
||||
if (res.success) {
|
||||
toast.success(t("detail.reorderSaved"))
|
||||
trackCoursePlanEvent("items_reordered", { planId: plan.id, count: items.length })
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(t("detail.reorderFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("detail.reorderFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = (): void => {
|
||||
try {
|
||||
const filename = t("export.filename", {
|
||||
subject: plan.subjectName ?? "unknown",
|
||||
className: plan.className ?? "no-class",
|
||||
})
|
||||
exportCoursePlanReport(
|
||||
plan,
|
||||
{
|
||||
week: t("detail.week"),
|
||||
topic: t("detail.topic"),
|
||||
content: t("export.content"),
|
||||
hours: t("detail.hours"),
|
||||
textbookChapter: t("detail.chapter"),
|
||||
status: t("detail.statusCol"),
|
||||
notes: t("export.notes"),
|
||||
completed: t("detail.completed"),
|
||||
pending: t("detail.pending"),
|
||||
},
|
||||
filename,
|
||||
)
|
||||
toast.success(t("export.exported"))
|
||||
trackCoursePlanEvent("plan_exported", { planId: plan.id, format: "csv" })
|
||||
} catch {
|
||||
toast.error(t("export.exportFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -91,136 +214,162 @@ export function CoursePlanDetail({
|
||||
<Button asChild variant="ghost" size="sm" className="w-fit">
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
返回课程计划列表
|
||||
{t("detail.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight">课程计划</h2>
|
||||
{canManage ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{editHref ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={editHref}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</Link>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("detail.heading")}</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={handleExport} variant="outline" disabled={plan.items.length === 0}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t("export.csv")}
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<>
|
||||
{editHref ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={editHref}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("detail.edit")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={() => setDeleteOpen(true)} disabled={isWorking} variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("detail.delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={() => setDeleteOpen(true)} disabled={isWorking} variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{plan.className ?? "无班级"}</Badge>
|
||||
<Badge variant="outline">{plan.subjectName ?? "未知学科"}</Badge>
|
||||
<Badge>{STATUS_LABEL[plan.status]}</Badge>
|
||||
<Badge variant="outline">第 {plan.semester} 学期</Badge>
|
||||
<Badge variant="outline">{plan.className ?? t("detail.noClass")}</Badge>
|
||||
<Badge variant="outline">{plan.subjectName ?? t("detail.unknownSubject")}</Badge>
|
||||
<Badge>{t(`status.${plan.status}`)}</Badge>
|
||||
<Badge variant="outline">{t("detail.semester", { semester: plan.semester })}</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
{plan.subjectName ?? "课程计划"} — {plan.className ?? "无班级"}
|
||||
{plan.subjectName ?? t("detail.unknownSubjectHeading")} — {plan.className ?? t("detail.noClass")}
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>教师:{plan.teacherName ?? "未分配"}</span>
|
||||
<span>· 创建于 {formatDate(plan.createdAt)}</span>
|
||||
{plan.startDate ? <span>· 开始 {formatDate(plan.startDate)}</span> : null}
|
||||
{plan.endDate ? <span>· 结束 {formatDate(plan.endDate)}</span> : null}
|
||||
<span>
|
||||
{plan.teacherName
|
||||
? t("detail.teacher", { name: plan.teacherName })
|
||||
: t("detail.unassigned")}
|
||||
</span>
|
||||
<span>· {t("detail.created", { date: formatDate(plan.createdAt) })}</span>
|
||||
{plan.startDate ? <span>· {t("detail.startDate", { date: formatDate(plan.startDate) })}</span> : null}
|
||||
{plan.endDate ? <span>· {t("detail.endDate", { date: formatDate(plan.endDate) })}</span> : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CoursePlanProgress
|
||||
completedHours={plan.completedHours}
|
||||
totalHours={plan.totalHours}
|
||||
completedItems={completedItems}
|
||||
totalItems={plan.items.length}
|
||||
/>
|
||||
<SectionErrorBoundary namespace="coursePlans">
|
||||
<CoursePlanProgress
|
||||
completedHours={plan.completedHours}
|
||||
totalHours={plan.totalHours}
|
||||
completedItems={completedItems}
|
||||
totalItems={plan.items.length}
|
||||
/>
|
||||
</SectionErrorBoundary>
|
||||
{plan.syllabus ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">教学大纲</h4>
|
||||
<h4 className="text-sm font-semibold">{t("detail.syllabus")}</h4>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.syllabus}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{plan.objectives ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">教学目标</h4>
|
||||
<h4 className="text-sm font-semibold">{t("detail.objectives")}</h4>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.objectives}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>周计划</CardTitle>
|
||||
{canManage ? (
|
||||
<Button onClick={openCreateEditor} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加周计划
|
||||
</Button>
|
||||
) : null}
|
||||
<SectionErrorBoundary namespace="coursePlans">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>{t("detail.weekPlans")}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{canManage && selectedIds.size > 0 ? (
|
||||
<Button onClick={handleBulkComplete} size="sm" disabled={isWorking}>
|
||||
{t("bulk.markComplete")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage ? (
|
||||
<Button onClick={openCreateEditor} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("detail.addWeekPlan")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{plan.items.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
暂无周计划。{canManage ? "点击「添加周计划」创建第一条。" : ""}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">周次</TableHead>
|
||||
<TableHead>主题</TableHead>
|
||||
<TableHead className="w-20">课时</TableHead>
|
||||
<TableHead className="w-32">章节</TableHead>
|
||||
<TableHead className="w-28">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plan.items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={canManage ? "cursor-pointer" : ""}
|
||||
onClick={canManage ? () => openEditEditor(item) : undefined}
|
||||
<CardContent>
|
||||
{plan.items.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("detail.emptyWeekPlans")}
|
||||
{canManage ? t("detail.emptyWeekPlansCta") : ""}
|
||||
</p>
|
||||
) : (
|
||||
<DndContext
|
||||
id="course-plan-weeks-dnd"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleReorder}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{canManage ? <TableHead className="w-8" /> : null}
|
||||
{canManage ? <TableHead className="w-8" /> : null}
|
||||
<TableHead className="w-16">{t("detail.week")}</TableHead>
|
||||
<TableHead>{t("detail.topic")}</TableHead>
|
||||
<TableHead className="w-20">{t("detail.hours")}</TableHead>
|
||||
<TableHead className="w-32">{t("detail.chapter")}</TableHead>
|
||||
<TableHead className="w-28">{t("detail.statusCol")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<SortableContext
|
||||
items={plan.items.map((i) => i.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<TableCell className="font-medium">{item.week}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{item.topic}</p>
|
||||
{item.content ? (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{item.content}
|
||||
</p>
|
||||
) : null}
|
||||
{item.notes ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
备注:{item.notes}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.hours}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.textbookChapter ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.isCompleted ? "default" : "secondary"}>
|
||||
{item.isCompleted ? "已完成" : "待完成"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TableBody>
|
||||
{plan.items.map((item) => (
|
||||
<SortableWeekRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
canManage={canManage}
|
||||
isSelected={selectedIds.has(item.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onEdit={openEditEditor}
|
||||
textbooksHref={textbooksHref}
|
||||
homeworkHref={homeworkHref}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</SortableContext>
|
||||
</Table>
|
||||
</DndContext>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary namespace="coursePlans">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("calendar.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CoursePlanCalendar plan={plan} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<CoursePlanItemEditor
|
||||
planId={plan.id}
|
||||
@@ -233,8 +382,8 @@ export function CoursePlanDetail({
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
title="删除课程计划"
|
||||
description="此操作将永久删除该课程计划及其所有周计划。"
|
||||
title={t("detail.deleteTitle")}
|
||||
description={t("detail.deleteDescription")}
|
||||
onConfirm={handleDelete}
|
||||
isWorking={isWorking}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -16,8 +18,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { FileText } from "lucide-react"
|
||||
|
||||
import { createCoursePlanAction, updateCoursePlanAction } from "../actions"
|
||||
import { TemplatePickerDialog } from "./template-picker-dialog"
|
||||
import { isCoursePlanSemester, isCoursePlanStatus } from "../types"
|
||||
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
||||
|
||||
type Mode = "create" | "edit"
|
||||
@@ -27,6 +32,8 @@ interface Option {
|
||||
name: string
|
||||
}
|
||||
|
||||
const STATUS_VALUES: CoursePlanStatus[] = ["planning", "active", "completed", "paused"]
|
||||
|
||||
export function CoursePlanForm({
|
||||
mode,
|
||||
plan,
|
||||
@@ -35,6 +42,7 @@ export function CoursePlanForm({
|
||||
teachers = [],
|
||||
academicYears = [],
|
||||
backHref,
|
||||
successHref,
|
||||
}: {
|
||||
mode: Mode
|
||||
plan?: CoursePlanListItem
|
||||
@@ -43,7 +51,10 @@ export function CoursePlanForm({
|
||||
teachers?: Option[]
|
||||
academicYears?: Option[]
|
||||
backHref?: string
|
||||
}) {
|
||||
/** 成功后的跳转路径(替代 URL 路径推断) */
|
||||
successHref?: string
|
||||
}): JSX.Element {
|
||||
const t = useTranslations("coursePlans")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
@@ -51,10 +62,19 @@ export function CoursePlanForm({
|
||||
const [subjectId, setSubjectId] = useState(plan?.subjectId ?? "")
|
||||
const [teacherId, setTeacherId] = useState(plan?.teacherId ?? "")
|
||||
const [semester, setSemester] = useState(plan?.semester ?? "1")
|
||||
const [status, setStatus] = useState(plan?.status ?? "planning")
|
||||
const [status, setStatus] = useState<CoursePlanStatus>(plan?.status ?? "planning")
|
||||
const [academicYearId, setAcademicYearId] = useState(plan?.academicYearId ?? "")
|
||||
const [templateOpen, setTemplateOpen] = useState(false)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const handleSemesterChange = (v: string): void => {
|
||||
if (isCoursePlanSemester(v)) setSemester(v)
|
||||
}
|
||||
|
||||
const handleStatusChange = (v: string): void => {
|
||||
if (isCoursePlanStatus(v)) setStatus(v)
|
||||
}
|
||||
|
||||
const handleSubmit = async (formData: FormData): Promise<void> => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("classId", classId)
|
||||
@@ -72,20 +92,19 @@ export function CoursePlanForm({
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error("Invalid form state")
|
||||
toast.error(t("form.invalidState"))
|
||||
return
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"
|
||||
router.push(redirectBase)
|
||||
router.push(successHref ?? "/admin/course-plans")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to save course plan")
|
||||
toast.error(res.message || t("form.saveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save course plan")
|
||||
toast.error(t("form.saveFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -95,17 +114,17 @@ export function CoursePlanForm({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{mode === "create" ? "New Course Plan" : "Edit Course Plan"}
|
||||
{mode === "create" ? t("form.new") : t("form.edit")}
|
||||
</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>Class</Label>
|
||||
<Label>{t("form.class")}</Label>
|
||||
<Select value={classId} onValueChange={setClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
<SelectValue placeholder={t("form.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
@@ -119,10 +138,10 @@ export function CoursePlanForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Subject</Label>
|
||||
<Label>{t("form.subject")}</Label>
|
||||
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a subject" />
|
||||
<SelectValue placeholder={t("form.selectSubject")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
@@ -136,15 +155,15 @@ export function CoursePlanForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Teacher</Label>
|
||||
<Label>{t("form.teacher")}</Label>
|
||||
<Select value={teacherId} onValueChange={setTeacherId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a teacher" />
|
||||
<SelectValue placeholder={t("form.selectTeacher")} />
|
||||
</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>
|
||||
@@ -153,10 +172,10 @@ export function CoursePlanForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Academic Year</Label>
|
||||
<Label>{t("form.academicYear")}</Label>
|
||||
<Select value={academicYearId} onValueChange={setAcademicYearId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Optional" />
|
||||
<SelectValue placeholder={t("form.optional")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{academicYears.map((y) => (
|
||||
@@ -170,37 +189,38 @@ export function CoursePlanForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Semester</Label>
|
||||
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
|
||||
<Label>{t("form.semester")}</Label>
|
||||
<Select value={semester} onValueChange={handleSemesterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select semester" />
|
||||
<SelectValue placeholder={t("form.semester")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Semester 1</SelectItem>
|
||||
<SelectItem value="2">Semester 2</SelectItem>
|
||||
<SelectItem value="1">{t("form.semester1")}</SelectItem>
|
||||
<SelectItem value="2">{t("form.semester2")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="semester" value={semester} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as CoursePlanStatus)}>
|
||||
<Label>{t("form.status")}</Label>
|
||||
<Select value={status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
<SelectValue placeholder={t("form.selectStatus")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="planning">Planning</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
{STATUS_VALUES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{t(`status.${s}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="status" value={status} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalHours">Total Hours</Label>
|
||||
<Label htmlFor="totalHours">{t("form.totalHours")}</Label>
|
||||
<Input
|
||||
id="totalHours"
|
||||
name="totalHours"
|
||||
@@ -211,7 +231,7 @@ export function CoursePlanForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="weeklyHours">Weekly Hours</Label>
|
||||
<Label htmlFor="weeklyHours">{t("form.weeklyHours")}</Label>
|
||||
<Input
|
||||
id="weeklyHours"
|
||||
name="weeklyHours"
|
||||
@@ -222,7 +242,7 @@ export function CoursePlanForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Start Date</Label>
|
||||
<Label htmlFor="startDate">{t("form.startDate")}</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
@@ -232,7 +252,7 @@ export function CoursePlanForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">End Date</Label>
|
||||
<Label htmlFor="endDate">{t("form.endDate")}</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
@@ -243,38 +263,59 @@ export function CoursePlanForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="syllabus">Syllabus</Label>
|
||||
<Label htmlFor="syllabus">{t("form.syllabus")}</Label>
|
||||
<Textarea
|
||||
id="syllabus"
|
||||
name="syllabus"
|
||||
placeholder="Teaching syllabus..."
|
||||
placeholder={t("form.syllabusPlaceholder")}
|
||||
className="min-h-[100px]"
|
||||
defaultValue={plan?.syllabus ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="objectives">Objectives</Label>
|
||||
<Label htmlFor="objectives">{t("form.objectives")}</Label>
|
||||
<Textarea
|
||||
id="objectives"
|
||||
name="objectives"
|
||||
placeholder="Teaching objectives..."
|
||||
placeholder={t("form.objectivesPlaceholder")}
|
||||
className="min-h-[100px]"
|
||||
defaultValue={plan?.objectives ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardFooter className="justify-end gap-2 px-0">
|
||||
{mode === "create" ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setTemplateOpen(true)}
|
||||
disabled={isWorking || !classId}
|
||||
aria-label={t("templates.createFromTemplate")}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("templates.createFromTemplate")}
|
||||
</Button>
|
||||
<TemplatePickerDialog
|
||||
open={templateOpen}
|
||||
onOpenChange={setTemplateOpen}
|
||||
targetClassId={classId}
|
||||
subjectId={subjectId || undefined}
|
||||
successHref={successHref ?? "/admin/course-plans"}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(backHref ?? "/admin/course-plans")}
|
||||
disabled={isWorking}
|
||||
>
|
||||
Cancel
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
|
||||
{isWorking ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Check, Trash2, X } from "lucide-react"
|
||||
|
||||
@@ -39,11 +41,12 @@ export function CoursePlanItemEditor({
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CoursePlanItemEditorProps) {
|
||||
}: CoursePlanItemEditorProps): JSX.Element {
|
||||
const t = useTranslations("coursePlans")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const handleSubmit = async (formData: FormData): Promise<void> => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("planId", planId)
|
||||
@@ -56,7 +59,7 @@ export function CoursePlanItemEditor({
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error("Invalid form state")
|
||||
toast.error(t("item.invalidState"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,16 +68,16 @@ export function CoursePlanItemEditor({
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to save week plan")
|
||||
toast.error(res.message || t("item.saveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save week plan")
|
||||
toast.error(t("item.saveFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
if (!item) return
|
||||
setIsWorking(true)
|
||||
try {
|
||||
@@ -84,16 +87,16 @@ export function CoursePlanItemEditor({
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete")
|
||||
toast.error(res.message || t("item.deleteFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete")
|
||||
toast.error(t("item.deleteFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleComplete = async () => {
|
||||
const handleToggleComplete = async (): Promise<void> => {
|
||||
if (!item) return
|
||||
setIsWorking(true)
|
||||
try {
|
||||
@@ -102,10 +105,10 @@ export function CoursePlanItemEditor({
|
||||
toast.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update")
|
||||
toast.error(res.message || t("item.updateFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update")
|
||||
toast.error(t("item.updateFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -116,13 +119,13 @@ export function CoursePlanItemEditor({
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create" ? "Add Week Plan" : "Edit Week Plan"}
|
||||
{mode === "create" ? t("item.addTitle") : t("item.editTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="week">Week</Label>
|
||||
<Label htmlFor="week">{t("item.week")}</Label>
|
||||
<Input
|
||||
id="week"
|
||||
name="week"
|
||||
@@ -133,7 +136,7 @@ export function CoursePlanItemEditor({
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="hours">Hours</Label>
|
||||
<Label htmlFor="hours">{t("item.hours")}</Label>
|
||||
<Input
|
||||
id="hours"
|
||||
name="hours"
|
||||
@@ -145,22 +148,22 @@ export function CoursePlanItemEditor({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="topic">Topic</Label>
|
||||
<Label htmlFor="topic">{t("item.topic")}</Label>
|
||||
<Input
|
||||
id="topic"
|
||||
name="topic"
|
||||
placeholder="Week topic"
|
||||
placeholder={t("item.topicPlaceholder")}
|
||||
defaultValue={item?.topic ?? ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="content">Content</Label>
|
||||
<Label htmlFor="content">{t("item.content")}</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
name="content"
|
||||
placeholder="Teaching content..."
|
||||
placeholder={t("item.contentPlaceholder")}
|
||||
className="min-h-[100px]"
|
||||
defaultValue={item?.content ?? ""}
|
||||
/>
|
||||
@@ -168,16 +171,16 @@ export function CoursePlanItemEditor({
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="textbookChapter">Textbook Chapter</Label>
|
||||
<Label htmlFor="textbookChapter">{t("item.chapter")}</Label>
|
||||
<Input
|
||||
id="textbookChapter"
|
||||
name="textbookChapter"
|
||||
placeholder="e.g. Chapter 3"
|
||||
placeholder={t("item.chapterPlaceholder")}
|
||||
defaultValue={item?.textbookChapter ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="completedAt">Completed At</Label>
|
||||
<Label htmlFor="completedAt">{t("item.completedAt")}</Label>
|
||||
<Input
|
||||
id="completedAt"
|
||||
name="completedAt"
|
||||
@@ -188,11 +191,11 @@ export function CoursePlanItemEditor({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Label htmlFor="notes">{t("item.notes")}</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
placeholder="Notes..."
|
||||
placeholder={t("item.notesPlaceholder")}
|
||||
defaultValue={item?.notes ?? ""}
|
||||
/>
|
||||
</div>
|
||||
@@ -209,12 +212,12 @@ export function CoursePlanItemEditor({
|
||||
{item.isCompleted ? (
|
||||
<>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Mark Incomplete
|
||||
{t("item.markIncomplete")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Mark Complete
|
||||
{t("item.markComplete")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -225,7 +228,7 @@ export function CoursePlanItemEditor({
|
||||
disabled={isWorking}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
{t("item.delete")}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
@@ -235,10 +238,10 @@ export function CoursePlanItemEditor({
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isWorking}
|
||||
>
|
||||
Cancel
|
||||
{t("item.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : "Save"}
|
||||
{isWorking ? t("item.saving") : t("item.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Plus, CalendarRange } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -20,15 +23,9 @@ import { Permissions } from "@/shared/types/permissions"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { CoursePlanProgress } from "./course-plan-progress"
|
||||
import { isCoursePlanStatus } from "../types"
|
||||
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
||||
|
||||
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
|
||||
planning: "Planning",
|
||||
active: "Active",
|
||||
completed: "Completed",
|
||||
paused: "Paused",
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<CoursePlanStatus, "default" | "secondary" | "outline"> = {
|
||||
planning: "secondary",
|
||||
active: "default",
|
||||
@@ -36,15 +33,9 @@ const STATUS_VARIANT: Record<CoursePlanStatus, "default" | "secondary" | "outlin
|
||||
paused: "outline",
|
||||
}
|
||||
|
||||
type Filter = "all" | CoursePlanStatus
|
||||
const STATUS_VALUES = ["planning", "active", "completed", "paused"] as const
|
||||
|
||||
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "planning", label: "Planning" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
]
|
||||
type Filter = "all" | CoursePlanStatus
|
||||
|
||||
export function CoursePlanList({
|
||||
plans,
|
||||
@@ -58,7 +49,8 @@ export function CoursePlanList({
|
||||
createHref?: string
|
||||
detailBaseHref?: string
|
||||
initialStatus?: Filter
|
||||
}) {
|
||||
}): JSX.Element {
|
||||
const t = useTranslations("coursePlans")
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const canManageResolved = canManage ?? hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
@@ -69,10 +61,11 @@ export function CoursePlanList({
|
||||
return plans.filter((p) => p.status === filter)
|
||||
}, [plans, filter])
|
||||
|
||||
const handleFilterChange = (value: string) => {
|
||||
setFilter(value as Filter)
|
||||
const handleFilterChange = (value: string): void => {
|
||||
const next: Filter = value === "all" || isCoursePlanStatus(value) ? value : "all"
|
||||
setFilter(next)
|
||||
const params = new URLSearchParams()
|
||||
if (value !== "all") params.set("status", value)
|
||||
if (next !== "all") params.set("status", next)
|
||||
const qs = params.toString()
|
||||
router.replace(qs ? `?${qs}` : "?")
|
||||
}
|
||||
@@ -82,34 +75,31 @@ export function CoursePlanList({
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Select value={filter} onValueChange={handleFilterChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
<SelectValue placeholder={t("filter.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
<SelectItem value="all">{t("filter.all")}</SelectItem>
|
||||
{STATUS_VALUES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{t(`status.${s}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{canManageResolved && createHref ? (
|
||||
<Button asChild>
|
||||
<a href={createHref}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Course Plan
|
||||
</a>
|
||||
<Link href={createHref}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("list.new")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No course plans"
|
||||
description={
|
||||
plans.length === 0
|
||||
? "There are no course plans yet."
|
||||
: "No course plans match the current filter."
|
||||
}
|
||||
title={t("list.empty")}
|
||||
description={plans.length === 0 ? t("list.empty") : t("list.emptyFiltered")}
|
||||
icon={CalendarRange}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
@@ -121,16 +111,16 @@ export function CoursePlanList({
|
||||
<Card className="h-full transition-colors hover:bg-accent/50">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">
|
||||
{plan.subjectName ?? "Unknown Subject"}
|
||||
{plan.subjectName ?? t("list.unknownSubject")}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[plan.status]} className="shrink-0">
|
||||
{STATUS_LABEL[plan.status]}
|
||||
{t(`status.${plan.status}`)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{plan.className ?? "No class"}</Badge>
|
||||
<span>Semester {plan.semester}</span>
|
||||
<Badge variant="outline">{plan.className ?? t("list.noClass")}</Badge>
|
||||
<span>{t("list.semester", { semester: plan.semester })}</span>
|
||||
{plan.teacherName ? <span>· {plan.teacherName}</span> : null}
|
||||
</div>
|
||||
<CoursePlanProgress
|
||||
@@ -139,16 +129,16 @@ export function CoursePlanList({
|
||||
showDetails={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created {formatDate(plan.createdAt)}
|
||||
{t("list.created", { date: formatDate(plan.createdAt) })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return href ? (
|
||||
<a key={plan.id} href={href} className="block h-full">
|
||||
<Link key={plan.id} href={href} className="block h-full">
|
||||
{card}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div key={plan.id}>{card}</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
|
||||
interface CoursePlanProgressProps {
|
||||
@@ -16,21 +19,22 @@ export function CoursePlanProgress({
|
||||
completedItems,
|
||||
totalItems,
|
||||
showDetails = true,
|
||||
}: CoursePlanProgressProps) {
|
||||
}: CoursePlanProgressProps): JSX.Element {
|
||||
const t = useTranslations("coursePlans")
|
||||
const hoursPercent = totalHours > 0 ? Math.round((completedHours / totalHours) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">Progress</span>
|
||||
<span className="font-medium">{t("progress.label")}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{completedHours} / {totalHours} hours ({hoursPercent}%)
|
||||
{t("progress.hours", { completed: completedHours, total: totalHours, percent: hoursPercent })}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={hoursPercent} className="h-2" />
|
||||
<Progress value={hoursPercent} className="h-2" aria-valuenow={hoursPercent} aria-valuemin={0} aria-valuemax={100} />
|
||||
{showDetails && typeof completedItems === "number" && typeof totalItems === "number" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{completedItems} of {totalItems} week plans completed
|
||||
{t("progress.weekPlansCompleted", { completed: completedItems, total: totalItems })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
176
src/modules/course-plans/components/sortable-week-row.tsx
Normal file
176
src/modules/course-plans/components/sortable-week-row.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { GripVertical, ExternalLink, BookOpen } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import type { CoursePlanWithItems } from "../types"
|
||||
|
||||
interface SortableWeekRowProps {
|
||||
item: CoursePlanWithItems["items"][number]
|
||||
canManage: boolean
|
||||
isSelected: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onEdit: (item: CoursePlanWithItems["items"][number]) => void
|
||||
/** 教材列表页地址(按角色不同);提供时章节文本渲染为可跳转链接 */
|
||||
textbooksHref?: string
|
||||
/** 作业列表页地址(按角色不同);提供时显示作业跳转按钮 */
|
||||
homeworkHref?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 可拖拽排序的周计划表格行。
|
||||
*
|
||||
* 使用 @dnd-kit/sortable 提供拖拽能力:
|
||||
* - 仅当 `canManage=true` 时启用拖拽(通过 disabled 控制)
|
||||
* - 拖拽手柄单独位于第一列,避免点击/复选交互被误触发
|
||||
* - 行点击(非手柄/复选区域)仍可触发 `onEdit`
|
||||
*
|
||||
* 数据联动(P2-3):
|
||||
* - `textbookChapter` 在提供 `textbooksHref` 时渲染为链接,支持一键跳转教材列表
|
||||
* - 行尾在提供 `homeworkHref` 时显示作业跳转按钮
|
||||
*
|
||||
* 可访问性:
|
||||
* - 手柄带 `aria-label`,键盘可通过 sortableKeyboardCoordinates 排序
|
||||
* - 复选框带 `aria-label` 描述所选周次
|
||||
* - 跳转链接带描述性 `aria-label`
|
||||
*/
|
||||
export function SortableWeekRow({
|
||||
item,
|
||||
canManage,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
onEdit,
|
||||
textbooksHref,
|
||||
homeworkHref,
|
||||
}: SortableWeekRowProps): JSX.Element {
|
||||
const t = useTranslations("coursePlans")
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id, disabled: !canManage })
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
position: "relative",
|
||||
}
|
||||
|
||||
const handleRowClick = canManage ? () => onEdit(item) : undefined
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
canManage && "cursor-pointer",
|
||||
isDragging && "opacity-50"
|
||||
)}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
{canManage ? (
|
||||
<TableCell
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-8"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab rounded p-1 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
|
||||
aria-label={t("detail.dragHandle")}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</TableCell>
|
||||
) : null}
|
||||
{canManage ? (
|
||||
<TableCell
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-8"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(item.id)}
|
||||
className="h-4 w-4"
|
||||
aria-label={t("detail.selectWeekAria", { week: item.week })}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="font-medium">{item.week}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{item.topic}</p>
|
||||
{item.content ? (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{item.content}
|
||||
</p>
|
||||
) : null}
|
||||
{item.notes ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t("detail.notes", { notes: item.notes })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.hours}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.textbookChapter ? (
|
||||
textbooksHref ? (
|
||||
<Link
|
||||
href={textbooksHref}
|
||||
className="inline-flex items-center gap-1 text-primary underline-offset-2 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("detail.viewTextbookAria", { chapter: item.textbookChapter })}
|
||||
>
|
||||
<BookOpen className="h-3 w-3" aria-hidden="true" />
|
||||
{item.textbookChapter}
|
||||
</Link>
|
||||
) : (
|
||||
item.textbookChapter
|
||||
)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={item.isCompleted ? "default" : "secondary"}>
|
||||
{item.isCompleted ? t("detail.completed") : t("detail.pending")}
|
||||
</Badge>
|
||||
{homeworkHref ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
aria-label={t("detail.viewHomeworkAria")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Link href={homeworkHref}>
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
220
src/modules/course-plans/components/template-picker-dialog.tsx
Normal file
220
src/modules/course-plans/components/template-picker-dialog.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { FileText, Loader2, Search } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
getTemplateCandidatesAction,
|
||||
copyCoursePlanAction,
|
||||
} from "../actions"
|
||||
import { trackCoursePlanEvent } from "../actions"
|
||||
import type { CoursePlanListItem } from "../types"
|
||||
|
||||
interface TemplatePickerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** 当前选中的目标班级 ID(复制目标) */
|
||||
targetClassId?: string
|
||||
/** 可选学科过滤 */
|
||||
subjectId?: string
|
||||
/** 复制成功后的跳转基础路径(如 /admin/course-plans) */
|
||||
successHref: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 课程计划模板选择器(P2-5)。
|
||||
*
|
||||
* 复用现有计划作为模板:列出当前用户可见的计划,支持搜索过滤;
|
||||
* 选择后调用 `copyCoursePlanAction` 克隆到目标班级,并跳转到新计划的编辑页。
|
||||
*
|
||||
* 设计:
|
||||
* - 列表加载通过 Server Action,避免页面级数据预取
|
||||
* - 搜索为客户端过滤(数据量可控时性能足够)
|
||||
* - 克隆完成后触发 `plan_created_from_template` 埋点
|
||||
*
|
||||
* 可访问性:
|
||||
* - 对话框带 `role="dialog"`
|
||||
* - 列表项带 `aria-label` 描述
|
||||
* - 加载状态显示 `aria-live="polite"`
|
||||
*/
|
||||
export function TemplatePickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetClassId,
|
||||
subjectId,
|
||||
successHref,
|
||||
}: TemplatePickerDialogProps): JSX.Element {
|
||||
const t = useTranslations("coursePlans")
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [cloning, setCloning] = useState(false)
|
||||
const [candidates, setCandidates] = useState<CoursePlanListItem[]>([])
|
||||
const [query, setQuery] = useState("")
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
getTemplateCandidatesAction(subjectId)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
if (res.success && res.data) {
|
||||
setCandidates(res.data)
|
||||
} else {
|
||||
setCandidates([])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCandidates([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, subjectId])
|
||||
|
||||
const filtered = candidates.filter((c) => {
|
||||
if (!query.trim()) return true
|
||||
const q = query.toLowerCase()
|
||||
return (
|
||||
c.subjectName?.toLowerCase().includes(q) ||
|
||||
c.className?.toLowerCase().includes(q) ||
|
||||
c.teacherName?.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const handleConfirm = async (): Promise<void> => {
|
||||
if (!selectedId || !targetClassId) return
|
||||
setCloning(true)
|
||||
try {
|
||||
const res = await copyCoursePlanAction(selectedId, [targetClassId])
|
||||
if (res.success && res.data && res.data.length > 0) {
|
||||
toast.success(t("templates.cloneSuccess"))
|
||||
trackCoursePlanEvent("plan_created_from_template", {
|
||||
sourcePlanId: selectedId,
|
||||
newPlanId: res.data[0],
|
||||
})
|
||||
onOpenChange(false)
|
||||
router.push(`${successHref}/${res.data[0]}/edit`)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("templates.cloneFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("templates.cloneFailed"))
|
||||
} finally {
|
||||
setCloning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[80vh] max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("templates.title")}</DialogTitle>
|
||||
<DialogDescription>{t("templates.createFromTemplate")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("templates.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
aria-label={t("templates.searchPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[320px] rounded-md border">
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex h-full items-center justify-center gap-2 p-8 text-sm text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
{t("loading.title")}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
|
||||
{t("templates.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y" role="listbox">
|
||||
{filtered.map((plan) => (
|
||||
<li key={plan.id} role="option" aria-selected={selectedId === plan.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(plan.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 p-3 text-left transition-colors hover:bg-muted/50",
|
||||
selectedId === plan.id && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline">{plan.className ?? "—"}</Badge>
|
||||
<Badge variant="outline">{plan.subjectName ?? "—"}</Badge>
|
||||
<Badge>{t(`status.${plan.status}`)}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{plan.teacherName ?? t("detail.unassigned")}
|
||||
{" · "}
|
||||
{t("detail.semester", { semester: plan.semester })}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={cloning}
|
||||
>
|
||||
{t("templates.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedId || !targetClassId || cloning}
|
||||
>
|
||||
{cloning ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
{t("templates.cloning")}
|
||||
</>
|
||||
) : (
|
||||
t("templates.confirm")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,19 +5,13 @@ import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
coursePlanItems,
|
||||
coursePlans,
|
||||
subjects,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { coursePlanItems, coursePlans } from "@/shared/db/schema"
|
||||
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanItem,
|
||||
CoursePlanListItem,
|
||||
CoursePlanStatus,
|
||||
CoursePlanQueryScope,
|
||||
CoursePlanWithItems,
|
||||
GetCoursePlansParams,
|
||||
GradeCoursePlanProgressItem,
|
||||
@@ -36,25 +30,11 @@ const toIso = (d: Date | null | undefined): string | null =>
|
||||
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
type PlanRow = typeof coursePlans.$inferSelect
|
||||
|
||||
const mapPlanRow = (
|
||||
row: {
|
||||
id: string
|
||||
classId: string
|
||||
subjectId: string
|
||||
teacherId: string
|
||||
academicYearId: string | null
|
||||
semester: "1" | "2"
|
||||
totalHours: number
|
||||
completedHours: number
|
||||
weeklyHours: number
|
||||
startDate: Date | null
|
||||
endDate: Date | null
|
||||
syllabus: string | null
|
||||
objectives: string | null
|
||||
status: CoursePlanStatus
|
||||
createdBy: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
row: PlanRow,
|
||||
enrichment: {
|
||||
className: string | null
|
||||
subjectName: string | null
|
||||
teacherName: string | null
|
||||
@@ -77,27 +57,14 @@ const mapPlanRow = (
|
||||
createdBy: row.createdBy,
|
||||
createdAt: toIsoRequired(row.createdAt),
|
||||
updatedAt: toIsoRequired(row.updatedAt),
|
||||
className: row.className,
|
||||
subjectName: row.subjectName,
|
||||
teacherName: row.teacherName,
|
||||
className: enrichment.className,
|
||||
subjectName: enrichment.subjectName,
|
||||
teacherName: enrichment.teacherName,
|
||||
})
|
||||
|
||||
const mapItemRow = (
|
||||
row: {
|
||||
id: string
|
||||
planId: string
|
||||
week: number
|
||||
topic: string
|
||||
content: string | null
|
||||
hours: number
|
||||
textbookChapter: string | null
|
||||
notes: string | null
|
||||
isCompleted: boolean
|
||||
completedAt: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
): CoursePlanItem => ({
|
||||
type ItemRow = typeof coursePlanItems.$inferSelect
|
||||
|
||||
const mapItemRow = (row: ItemRow): CoursePlanItem => ({
|
||||
id: row.id,
|
||||
planId: row.planId,
|
||||
week: Number(row.week),
|
||||
@@ -112,52 +79,91 @@ const mapItemRow = (
|
||||
updatedAt: toIsoRequired(row.updatedAt),
|
||||
})
|
||||
|
||||
const buildPlanSelect = () =>
|
||||
db
|
||||
.select({
|
||||
id: coursePlans.id,
|
||||
classId: coursePlans.classId,
|
||||
subjectId: coursePlans.subjectId,
|
||||
teacherId: coursePlans.teacherId,
|
||||
academicYearId: coursePlans.academicYearId,
|
||||
semester: coursePlans.semester,
|
||||
totalHours: coursePlans.totalHours,
|
||||
completedHours: coursePlans.completedHours,
|
||||
weeklyHours: coursePlans.weeklyHours,
|
||||
startDate: coursePlans.startDate,
|
||||
endDate: coursePlans.endDate,
|
||||
syllabus: coursePlans.syllabus,
|
||||
objectives: coursePlans.objectives,
|
||||
status: coursePlans.status,
|
||||
createdBy: coursePlans.createdBy,
|
||||
createdAt: coursePlans.createdAt,
|
||||
updatedAt: coursePlans.updatedAt,
|
||||
className: classes.name,
|
||||
subjectName: subjects.name,
|
||||
teacherName: users.name,
|
||||
/**
|
||||
* 批量解析班级/科目/教师名称(通过对方 data-access,不直接 JOIN 其他模块表)。
|
||||
* 解耦 P1-1:course-plans 仅查询自己的 course_plans 表,
|
||||
* 名称解析委托给 classes/school/users 模块的 data-access。
|
||||
*/
|
||||
async function enrichPlanRows(
|
||||
rows: PlanRow[]
|
||||
): Promise<CoursePlanListItem[]> {
|
||||
if (rows.length === 0) return []
|
||||
|
||||
const { getClassNamesByIds } = await import("@/modules/classes/data-access")
|
||||
const { getSubjectNameMapByIds } = await import("@/modules/school/data-access")
|
||||
const { getUserNamesByIds } = await import("@/modules/users/data-access")
|
||||
|
||||
const classIds = Array.from(new Set(rows.map((r) => r.classId)))
|
||||
const subjectIds = Array.from(new Set(rows.map((r) => r.subjectId)))
|
||||
const teacherIds = Array.from(new Set(rows.map((r) => r.teacherId)))
|
||||
|
||||
const [classNameMap, subjectNameMap, teacherNameMap] = await Promise.all([
|
||||
getClassNamesByIds(classIds),
|
||||
getSubjectNameMapByIds(subjectIds),
|
||||
getUserNamesByIds(teacherIds),
|
||||
])
|
||||
|
||||
return rows.map((row) =>
|
||||
mapPlanRow(row, {
|
||||
className: classNameMap.get(row.classId) ?? null,
|
||||
subjectName: subjectNameMap.get(row.subjectId) ?? null,
|
||||
teacherName: teacherNameMap.get(row.teacherId)?.name ?? null,
|
||||
})
|
||||
.from(coursePlans)
|
||||
.leftJoin(classes, eq(classes.id, coursePlans.classId))
|
||||
.leftJoin(subjects, eq(subjects.id, coursePlans.subjectId))
|
||||
.leftJoin(users, eq(users.id, coursePlans.teacherId))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条计划的名称解析(用于详情查询)。
|
||||
*/
|
||||
async function enrichPlanRow(row: PlanRow): Promise<CoursePlanListItem> {
|
||||
const enriched = await enrichPlanRows([row])
|
||||
return enriched[0]
|
||||
}
|
||||
|
||||
const buildScopeCondition = (
|
||||
scope?: CoursePlanQueryScope
|
||||
): SQL[] => {
|
||||
const conditions: SQL[] = []
|
||||
if (!scope) return conditions
|
||||
|
||||
// 管理员拥有全局视图,不加过滤
|
||||
if (scope.isAdmin) return conditions
|
||||
|
||||
// 教师视角:仅查看自己负责的计划
|
||||
if (scope.teacherId) {
|
||||
conditions.push(eq(coursePlans.teacherId, scope.teacherId))
|
||||
}
|
||||
|
||||
// 班级范围过滤(学生/家长/年级主任)
|
||||
if (scope.classIds && scope.classIds.length > 0) {
|
||||
conditions.push(inArray(coursePlans.classId, scope.classIds))
|
||||
}
|
||||
|
||||
// 若非 admin 且未提供任何过滤条件,默认仅返回自己创建的
|
||||
if (!scope.isAdmin && !scope.teacherId && (!scope.classIds || scope.classIds.length === 0)) {
|
||||
conditions.push(eq(coursePlans.createdBy, scope.userId))
|
||||
}
|
||||
|
||||
return conditions
|
||||
}
|
||||
|
||||
export const getCoursePlans = cache(
|
||||
async (params?: GetCoursePlansParams): Promise<CoursePlanListItem[]> => {
|
||||
async (params?: GetCoursePlansParams, scope?: CoursePlanQueryScope): Promise<CoursePlanListItem[]> => {
|
||||
try {
|
||||
const conditions: SQL[] = []
|
||||
const conditions: SQL[] = [...buildScopeCondition(scope)]
|
||||
if (params?.classId) conditions.push(eq(coursePlans.classId, params.classId))
|
||||
if (params?.teacherId) conditions.push(eq(coursePlans.teacherId, params.teacherId))
|
||||
if (params?.subjectId) conditions.push(eq(coursePlans.subjectId, params.subjectId))
|
||||
if (params?.status)
|
||||
conditions.push(eq(coursePlans.status, params.status))
|
||||
|
||||
const query = buildPlanSelect()
|
||||
const query = db.select().from(coursePlans)
|
||||
const rows = await (conditions.length > 0
|
||||
? query.where(and(...conditions))
|
||||
: query
|
||||
).orderBy(desc(coursePlans.createdAt))
|
||||
|
||||
return rows.map(mapPlanRow)
|
||||
return await enrichPlanRows(rows)
|
||||
} catch (error) {
|
||||
console.error("getCoursePlans failed:", error)
|
||||
return []
|
||||
@@ -166,14 +172,19 @@ export const getCoursePlans = cache(
|
||||
)
|
||||
|
||||
export const getCoursePlanById = cache(
|
||||
async (id: string): Promise<CoursePlanWithItems | null> => {
|
||||
async (id: string, scope?: CoursePlanQueryScope): Promise<CoursePlanWithItems | null> => {
|
||||
try {
|
||||
const [planRow] = await buildPlanSelect()
|
||||
.where(eq(coursePlans.id, id))
|
||||
const conditions: SQL[] = [eq(coursePlans.id, id), ...buildScopeCondition(scope)]
|
||||
const [planRow] = await db
|
||||
.select()
|
||||
.from(coursePlans)
|
||||
.where(and(...conditions))
|
||||
.limit(1)
|
||||
|
||||
if (!planRow) return null
|
||||
|
||||
const enriched = await enrichPlanRow(planRow)
|
||||
|
||||
const itemRows = await db
|
||||
.select()
|
||||
.from(coursePlanItems)
|
||||
@@ -181,7 +192,7 @@ export const getCoursePlanById = cache(
|
||||
.orderBy(asc(coursePlanItems.week), asc(coursePlanItems.createdAt))
|
||||
|
||||
return {
|
||||
...mapPlanRow(planRow),
|
||||
...enriched,
|
||||
items: itemRows.map(mapItemRow),
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -312,53 +323,119 @@ export async function reorderCoursePlanItems(
|
||||
)
|
||||
}
|
||||
|
||||
export type { CoursePlan, CoursePlanItem, CoursePlanWithItems }
|
||||
/**
|
||||
* 批量标记周计划条目完成状态(P2-4 批量操作)。
|
||||
*/
|
||||
export async function bulkUpdateItemCompleted(
|
||||
itemIds: string[],
|
||||
completed: boolean
|
||||
): Promise<number> {
|
||||
if (itemIds.length === 0) return 0
|
||||
// schema 列为 Date 类型,使用 Date 对象而非字符串
|
||||
const completedAt = completed ? new Date() : null
|
||||
await db
|
||||
.update(coursePlanItems)
|
||||
.set({ isCompleted: completed, completedAt })
|
||||
.where(inArray(coursePlanItems.id, itemIds))
|
||||
return itemIds.length
|
||||
}
|
||||
|
||||
export const getSubjectOptions = cache(async (): Promise<{ id: string; name: string }[]> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.orderBy(asc(subjects.order), asc(subjects.name))
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||
} catch (error) {
|
||||
console.error("getSubjectOptions failed:", error)
|
||||
return []
|
||||
/**
|
||||
* 复制课程计划到其他班级(P2-4 批量操作)。
|
||||
* 复制基本信息(不含 completedHours)和所有周计划条目。
|
||||
*/
|
||||
export async function copyCoursePlanToClasses(
|
||||
sourcePlanId: string,
|
||||
targetClassIds: string[]
|
||||
): Promise<string[]> {
|
||||
if (targetClassIds.length === 0) return []
|
||||
|
||||
const [source] = await db
|
||||
.select()
|
||||
.from(coursePlans)
|
||||
.where(eq(coursePlans.id, sourcePlanId))
|
||||
.limit(1)
|
||||
|
||||
if (!source) return []
|
||||
|
||||
const sourceItems = await db
|
||||
.select()
|
||||
.from(coursePlanItems)
|
||||
.where(eq(coursePlanItems.planId, sourcePlanId))
|
||||
|
||||
const createdIds: string[] = []
|
||||
|
||||
for (const classId of targetClassIds) {
|
||||
const newPlanId = createId()
|
||||
await db.insert(coursePlans).values({
|
||||
id: newPlanId,
|
||||
classId,
|
||||
subjectId: source.subjectId,
|
||||
teacherId: source.teacherId,
|
||||
academicYearId: source.academicYearId,
|
||||
semester: source.semester,
|
||||
totalHours: source.totalHours,
|
||||
completedHours: 0,
|
||||
weeklyHours: source.weeklyHours,
|
||||
startDate: source.startDate,
|
||||
endDate: source.endDate,
|
||||
syllabus: source.syllabus,
|
||||
objectives: source.objectives,
|
||||
status: "planning",
|
||||
createdBy: source.createdBy,
|
||||
})
|
||||
|
||||
for (const item of sourceItems) {
|
||||
const newItemId = createId()
|
||||
await db.insert(coursePlanItems).values({
|
||||
id: newItemId,
|
||||
planId: newPlanId,
|
||||
week: item.week,
|
||||
topic: item.topic,
|
||||
content: item.content,
|
||||
hours: item.hours,
|
||||
textbookChapter: item.textbookChapter,
|
||||
notes: item.notes,
|
||||
})
|
||||
}
|
||||
|
||||
createdIds.push(newPlanId)
|
||||
}
|
||||
})
|
||||
|
||||
return createdIds
|
||||
}
|
||||
|
||||
export type { CoursePlan, CoursePlanItem, CoursePlanWithItems }
|
||||
|
||||
/**
|
||||
* 年级仪表盘 - 维度4:获取年级下所有班级的教学计划进度。
|
||||
* 通过 getClassesByGradeId 获取年级下所有班级,再用 inArray 查询 course_plans,
|
||||
* 关联 course_plan_items 统计条目完成情况。
|
||||
* 名称解析通过 data-access 批量查询,不 JOIN 其他模块表。
|
||||
*/
|
||||
export const getGradeCoursePlanProgress = cache(
|
||||
async (params: { gradeId: string }): Promise<GradeCoursePlanProgressResult> => {
|
||||
const { getClassesByGradeId } = await import("@/modules/classes/data-access")
|
||||
const classRows = await getClassesByGradeId(params.gradeId)
|
||||
|
||||
if (classRows.length === 0) {
|
||||
return {
|
||||
gradeId: params.gradeId,
|
||||
overall: { totalPlans: 0, totalHours: 0, completedHours: 0, progressRate: 0, activePlans: 0, completedPlans: 0 },
|
||||
items: [],
|
||||
}
|
||||
const empty: GradeCoursePlanProgressResult = {
|
||||
gradeId: params.gradeId,
|
||||
overall: { totalPlans: 0, totalHours: 0, completedHours: 0, progressRate: 0, activePlans: 0, completedPlans: 0 },
|
||||
items: [],
|
||||
}
|
||||
|
||||
if (classRows.length === 0) return empty
|
||||
|
||||
const classIds = classRows.map((c) => c.id)
|
||||
|
||||
// 查询年级下所有教学计划(含班级/科目/教师名称)
|
||||
const planRows = await buildPlanSelect()
|
||||
// 查询年级下所有教学计划(仅 course_plans 表)
|
||||
const planRows = await db
|
||||
.select()
|
||||
.from(coursePlans)
|
||||
.where(inArray(coursePlans.classId, classIds))
|
||||
.orderBy(asc(classes.name), asc(subjects.name))
|
||||
.orderBy(desc(coursePlans.createdAt))
|
||||
|
||||
if (planRows.length === 0) {
|
||||
return {
|
||||
gradeId: params.gradeId,
|
||||
overall: { totalPlans: 0, totalHours: 0, completedHours: 0, progressRate: 0, activePlans: 0, completedPlans: 0 },
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
if (planRows.length === 0) return empty
|
||||
|
||||
const planIds = planRows.map((p) => p.id)
|
||||
|
||||
@@ -379,9 +456,14 @@ export const getGradeCoursePlanProgress = cache(
|
||||
itemStatsByPlan.set(it.planId, entry)
|
||||
}
|
||||
|
||||
const items: GradeCoursePlanProgressItem[] = planRows.map((p) => {
|
||||
const totalHours = Number(p.totalHours)
|
||||
const completedHours = Number(p.completedHours)
|
||||
// 批量解析名称
|
||||
const enriched = await enrichPlanRows(planRows)
|
||||
|
||||
const classNameMap = new Map(classRows.map((c) => [c.id, c.name] as const))
|
||||
|
||||
const items: GradeCoursePlanProgressItem[] = enriched.map((p) => {
|
||||
const totalHours = p.totalHours
|
||||
const completedHours = p.completedHours
|
||||
const progressRate = totalHours > 0
|
||||
? Math.round((completedHours / totalHours) * 1000) / 10
|
||||
: 0
|
||||
@@ -389,7 +471,7 @@ export const getGradeCoursePlanProgress = cache(
|
||||
return {
|
||||
planId: p.id,
|
||||
classId: p.classId,
|
||||
className: p.className,
|
||||
className: classNameMap.get(p.classId) ?? p.className,
|
||||
subjectId: p.subjectId,
|
||||
subjectName: p.subjectName,
|
||||
teacherName: p.teacherName,
|
||||
|
||||
169
src/modules/course-plans/lib/calendar-utils.ts
Normal file
169
src/modules/course-plans/lib/calendar-utils.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { CoursePlanWithItems } from "../types"
|
||||
|
||||
/**
|
||||
* course-plans 日历视图工具(纯函数,便于单测)。
|
||||
*
|
||||
* 将周计划项映射到日历日期范围:
|
||||
* - 若 plan.startDate 存在:第 N 周对应 [startDate + (N-1)*7, startDate + N*7 - 1]
|
||||
* - 若 plan.startDate 缺失:返回 null(无法映射到日期)
|
||||
*/
|
||||
|
||||
/** 日历事件(一个周计划项对应一个事件) */
|
||||
export interface CalendarEvent {
|
||||
/** 事件唯一 ID(使用 item.id) */
|
||||
id: string
|
||||
/** 事件标题(使用 item.topic) */
|
||||
title: string
|
||||
/** 起始日期(ISO 字符串,YYYY-MM-DD) */
|
||||
startDate: string
|
||||
/** 结束日期(ISO 字符串,YYYY-MM-DD,含) */
|
||||
endDate: string
|
||||
/** 周次 */
|
||||
week: number
|
||||
/** 课时 */
|
||||
hours: number
|
||||
/** 是否已完成 */
|
||||
isCompleted: boolean
|
||||
/** 教材章节 */
|
||||
textbookChapter: string | null
|
||||
}
|
||||
|
||||
/** 将日期对象格式化为 YYYY-MM-DD(不依赖 date-fns) */
|
||||
export function formatDateISO(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const d = String(date.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
/** 解析 YYYY-MM-DD 字符串为本地日期(避免 UTC 偏移) */
|
||||
export function parseISODate(iso: string): Date {
|
||||
const [y, m, d] = iso.split("-").map(Number)
|
||||
return new Date(y, (m ?? 1) - 1, d ?? 1)
|
||||
}
|
||||
|
||||
/** 计算某日所在周的周日(作为周起始;遵循 ISO 8601 周一为周首) */
|
||||
export function startOfWeek(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
const day = d.getDay() // 0=Sunday, 1=Monday, ...
|
||||
const diff = (day === 0 ? -6 : 1 - day) // 周一为周首
|
||||
d.setDate(d.getDate() + diff)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
/** 计算某日所在月的首日 */
|
||||
export function startOfMonth(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
}
|
||||
|
||||
/** 计算某日所在月的末日 */
|
||||
export function endOfMonth(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
||||
}
|
||||
|
||||
/** 在日期上加天数 */
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + days)
|
||||
return d
|
||||
}
|
||||
|
||||
/** 在日期上加月数 */
|
||||
export function addMonths(date: Date, months: number): Date {
|
||||
const d = new Date(date)
|
||||
d.setMonth(d.getMonth() + months)
|
||||
return d
|
||||
}
|
||||
|
||||
/** 判断两日期是否同一天 */
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
/** 判断日期 a 是否在 [start, end] 区间内(含端点) */
|
||||
export function isWithinRange(date: Date, start: Date, end: Date): boolean {
|
||||
const t = date.getTime()
|
||||
return t >= start.getTime() && t <= end.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* 将课程计划及其周计划项转换为日历事件列表(纯函数)。
|
||||
*
|
||||
* - 需要 `plan.startDate` 才能计算每周的日期范围
|
||||
* - 第 N 周对应日期范围 [startDate + (N-1)*7, startDate + N*7 - 1]
|
||||
*
|
||||
* @returns 事件列表;若无 startDate 则返回空数组
|
||||
*/
|
||||
export function planToCalendarEvents(plan: CoursePlanWithItems): CalendarEvent[] {
|
||||
if (!plan.startDate) return []
|
||||
|
||||
const start = parseISODate(plan.startDate)
|
||||
return plan.items.map((item) => {
|
||||
const weekStart = addDays(start, (item.week - 1) * 7)
|
||||
const weekEnd = addDays(weekStart, 6) // 周一至周日
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.topic,
|
||||
startDate: formatDateISO(weekStart),
|
||||
endDate: formatDateISO(weekEnd),
|
||||
week: item.week,
|
||||
hours: item.hours,
|
||||
isCompleted: item.isCompleted,
|
||||
textbookChapter: item.textbookChapter,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成日历网格:返回覆盖给定月份所需的 6 周 × 7 天 = 42 天的日期数组。
|
||||
*
|
||||
* 网格从月份首日所在周的周一开始,确保整月可见。
|
||||
*
|
||||
* @param monthDate 月份内任意一天
|
||||
* @returns 42 个 Date 对象(6 行 × 7 列)
|
||||
*/
|
||||
export function buildMonthGrid(monthDate: Date): Date[] {
|
||||
const monthStart = startOfMonth(monthDate)
|
||||
const gridStart = startOfWeek(monthStart)
|
||||
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i))
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤在给定日期范围内有重叠的事件。
|
||||
*
|
||||
* @param events 事件列表
|
||||
* @param rangeStart 范围开始
|
||||
* @param rangeEnd 范围结束
|
||||
*/
|
||||
export function filterEventsInRange(
|
||||
events: readonly CalendarEvent[],
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
): CalendarEvent[] {
|
||||
return events.filter((event) => {
|
||||
const eventStart = parseISODate(event.startDate)
|
||||
const eventEnd = parseISODate(event.endDate)
|
||||
// 区间相交判断:eventStart <= rangeEnd && eventEnd >= rangeStart
|
||||
return eventStart.getTime() <= rangeEnd.getTime() &&
|
||||
eventEnd.getTime() >= rangeStart.getTime()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回某一天的事件列表(事件日期范围包含该天)。
|
||||
*/
|
||||
export function eventsOnDay(
|
||||
events: readonly CalendarEvent[],
|
||||
day: Date,
|
||||
): CalendarEvent[] {
|
||||
return events.filter((event) => {
|
||||
const eventStart = parseISODate(event.startDate)
|
||||
const eventEnd = parseISODate(event.endDate)
|
||||
return isWithinRange(day, eventStart, eventEnd)
|
||||
})
|
||||
}
|
||||
90
src/modules/course-plans/lib/export-utils.ts
Normal file
90
src/modules/course-plans/lib/export-utils.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { ExportColumn, ExportRow } from "@/shared/lib/export-utils"
|
||||
import { exportCSV } from "@/shared/lib/export-utils"
|
||||
|
||||
import type { CoursePlanWithItems } from "../types"
|
||||
|
||||
/**
|
||||
* course-plans 模块导出工具(纯函数 + 客户端下载)。
|
||||
*
|
||||
* 设计原则:
|
||||
* - `planToExportRows` 为纯函数,便于单测;不直接依赖 i18n,状态文本由调用方通过 columnLabels 传入
|
||||
* - `exportCoursePlanReport` 执行客户端下载,触发埋点由调用方处理
|
||||
*/
|
||||
|
||||
/** 导出列标识(与 ExportRow 的 key 对应) */
|
||||
export type CoursePlanExportColumnKey =
|
||||
| "week"
|
||||
| "topic"
|
||||
| "content"
|
||||
| "hours"
|
||||
| "textbookChapter"
|
||||
| "status"
|
||||
| "notes"
|
||||
|
||||
/** 列标签映射(由调用方传入已本地化的字符串) */
|
||||
export interface CoursePlanColumnLabels {
|
||||
week: string
|
||||
topic: string
|
||||
content: string
|
||||
hours: string
|
||||
textbookChapter: string
|
||||
status: string
|
||||
notes: string
|
||||
/** 已完成状态文本 */
|
||||
completed: string
|
||||
/** 待完成状态文本 */
|
||||
pending: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建导出列配置(按固定顺序)。
|
||||
*/
|
||||
export function buildExportColumns(labels: CoursePlanColumnLabels): readonly ExportColumn[] {
|
||||
return [
|
||||
{ key: "week", label: labels.week },
|
||||
{ key: "topic", label: labels.topic },
|
||||
{ key: "content", label: labels.content },
|
||||
{ key: "hours", label: labels.hours },
|
||||
{ key: "textbookChapter", label: labels.textbookChapter },
|
||||
{ key: "status", label: labels.status },
|
||||
{ key: "notes", label: labels.notes },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 将课程计划转换为导出行(纯函数)。
|
||||
*
|
||||
* @param plan 课程计划
|
||||
* @param labels 列标签 + 状态文本
|
||||
*/
|
||||
export function planToExportRows(
|
||||
plan: CoursePlanWithItems,
|
||||
labels: CoursePlanColumnLabels,
|
||||
): ExportRow[] {
|
||||
return plan.items.map((item) => ({
|
||||
week: item.week,
|
||||
topic: item.topic,
|
||||
content: item.content ?? "",
|
||||
hours: item.hours,
|
||||
textbookChapter: item.textbookChapter ?? "",
|
||||
status: item.isCompleted ? labels.completed : labels.pending,
|
||||
notes: item.notes ?? "",
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端导出课程计划教学进度报告为 CSV。
|
||||
*
|
||||
* @param plan 课程计划
|
||||
* @param labels 列标签 + 状态文本
|
||||
* @param filename 文件名(不含扩展名)
|
||||
*/
|
||||
export function exportCoursePlanReport(
|
||||
plan: CoursePlanWithItems,
|
||||
labels: CoursePlanColumnLabels,
|
||||
filename: string,
|
||||
): void {
|
||||
const columns = buildExportColumns(labels)
|
||||
const rows = planToExportRows(plan, labels)
|
||||
exportCSV(rows, columns, filename)
|
||||
}
|
||||
@@ -178,3 +178,36 @@ export const UpdateCoursePlanItemSchema = z
|
||||
}))
|
||||
|
||||
export type UpdateCoursePlanItemInput = z.infer<typeof UpdateCoursePlanItemSchema>
|
||||
|
||||
// ── Action 入参验证(P1-6)──────────────────────────────────
|
||||
|
||||
export const GetCoursePlansParamsSchema = z.object({
|
||||
classId: z.string().trim().min(1).optional(),
|
||||
teacherId: z.string().trim().min(1).optional(),
|
||||
subjectId: z.string().trim().min(1).optional(),
|
||||
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
|
||||
})
|
||||
|
||||
export const GradeIdSchema = z.object({
|
||||
gradeId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export const ReorderItemsSchema = z.object({
|
||||
planId: z.string().trim().min(1),
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
week: z.number().int().min(1),
|
||||
})
|
||||
).min(1),
|
||||
})
|
||||
|
||||
export const BulkToggleSchema = z.object({
|
||||
itemIds: z.array(z.string().trim().min(1)).min(1),
|
||||
completed: z.boolean(),
|
||||
})
|
||||
|
||||
export const CopyPlanSchema = z.object({
|
||||
sourcePlanId: z.string().trim().min(1),
|
||||
targetClassIds: z.array(z.string().trim().min(1)).min(1),
|
||||
})
|
||||
|
||||
@@ -59,6 +59,23 @@ export interface ReorderCoursePlanItemInput {
|
||||
week: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据范围上下文,用于在 data-access 层进行权限过滤。
|
||||
* 由调用方(Server Action / 页面)从 AuthContext.dataScope 解析后传入。
|
||||
*/
|
||||
export interface CoursePlanQueryScope {
|
||||
/** 当前用户 ID */
|
||||
userId: string
|
||||
/** 是否管理员(拥有全局视图) */
|
||||
isAdmin: boolean
|
||||
/** 可访问的班级 ID 列表(非 admin 时用于过滤) */
|
||||
classIds?: string[]
|
||||
/** 教师视角:仅查看自己负责的计划 */
|
||||
teacherId?: string
|
||||
/** 学生/家长视角:仅查看特定孩子的班级 */
|
||||
studentId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级仪表盘 - 维度4:年级下各班级/科目的课本进度。
|
||||
*/
|
||||
@@ -95,3 +112,50 @@ export interface GradeCoursePlanProgressResult {
|
||||
/** 按班级 + 科目拆分的进度列表 */
|
||||
items: GradeCoursePlanProgressItem[]
|
||||
}
|
||||
|
||||
// ── 类型守卫 ──────────────────────────────────────────────
|
||||
|
||||
export const isCoursePlanStatus = (v: unknown): v is CoursePlanStatus =>
|
||||
v === "planning" || v === "active" || v === "completed" || v === "paused"
|
||||
|
||||
export const isCoursePlanSemester = (v: unknown): v is CoursePlanSemester =>
|
||||
v === "1" || v === "2"
|
||||
|
||||
// ── 角色配置驱动:决定各角色视图渲染哪些 Widget ────────────
|
||||
|
||||
export type CoursePlanWidgetId =
|
||||
| "list"
|
||||
| "progress"
|
||||
| "calendar"
|
||||
| "bulkActions"
|
||||
| "export"
|
||||
| "templates"
|
||||
|
||||
export interface RoleWidgetConfig {
|
||||
widgets: CoursePlanWidgetId[]
|
||||
canManage: boolean
|
||||
detailBaseHref: string
|
||||
}
|
||||
|
||||
export const ROLE_WIDGET_CONFIG: Record<"admin" | "teacher" | "parent" | "student", RoleWidgetConfig> = {
|
||||
admin: {
|
||||
widgets: ["list", "progress", "calendar", "bulkActions", "export", "templates"],
|
||||
canManage: true,
|
||||
detailBaseHref: "/admin/course-plans",
|
||||
},
|
||||
teacher: {
|
||||
widgets: ["list", "progress", "calendar"],
|
||||
canManage: true,
|
||||
detailBaseHref: "/teacher/course-plans",
|
||||
},
|
||||
parent: {
|
||||
widgets: ["list", "progress"],
|
||||
canManage: false,
|
||||
detailBaseHref: "/parent/course-plans",
|
||||
},
|
||||
student: {
|
||||
widgets: ["list", "progress"],
|
||||
canManage: false,
|
||||
detailBaseHref: "/student/course-plans",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user