feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013

## P1 功能(20 项)
- 站内消息系统、家长仪表盘、学生考勤管理
- Excel 导入导出、用户批量导入、成绩导出
- 排课规则+自动排课+课表调整
- 成绩趋势+对比分析、密码安全策略、速率限制
- 数据变更日志、文件预览+存储策略、全文检索
- 依赖审计集成 CI、数据库定时备份、E2E 测试完善
- 通知偏好管理

## 基础设施修复
- src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求)
- .env: MySQL 端口从 13002 切换至 14013
- scripts/create-db.ts: 新增数据库初始化脚本

## 架构文档同步
- 004_architecture_impact_map.md 和 005_architecture_data.json
  完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View File

@@ -0,0 +1,265 @@
"use server"
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import {
CreateCoursePlanSchema,
UpdateCoursePlanSchema,
CreateCoursePlanItemSchema,
UpdateCoursePlanItemSchema,
} from "./schema"
import {
getCoursePlans,
getCoursePlanById,
createCoursePlan,
updateCoursePlan,
deleteCoursePlan,
createCoursePlanItem,
updateCoursePlanItem,
deleteCoursePlanItem,
} from "./data-access"
import type { CoursePlanWithItems, GetCoursePlansParams, CoursePlanListItem } from "./types"
const handleError = (e: unknown): ActionState<never> => {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
const revalidatePlanPaths = (id?: string) => {
revalidatePath("/admin/course-plans")
revalidatePath("/teacher/course-plans")
if (id) {
revalidatePath(`/admin/course-plans/${id}`)
revalidatePath(`/teacher/course-plans/${id}`)
}
}
export async function createCoursePlanAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
const parsed = CreateCoursePlanSchema.safeParse({
classId: formData.get("classId"),
subjectId: formData.get("subjectId"),
teacherId: formData.get("teacherId"),
academicYearId: formData.get("academicYearId") || undefined,
semester: formData.get("semester") || undefined,
totalHours: formData.get("totalHours") || undefined,
weeklyHours: formData.get("weeklyHours") || undefined,
startDate: formData.get("startDate") || undefined,
endDate: formData.get("endDate") || undefined,
syllabus: formData.get("syllabus") || undefined,
objectives: formData.get("objectives") || undefined,
status: formData.get("status") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await createCoursePlan(parsed.data, ctx.userId)
revalidatePlanPaths(id)
return { success: true, message: "Course plan created", data: id }
} catch (e) {
return handleError(e)
}
}
export async function updateCoursePlanAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
const existing = await getCoursePlanById(id)
if (!existing) return { success: false, message: "Course plan not found" }
const parsed = UpdateCoursePlanSchema.safeParse({
classId: formData.get("classId") || undefined,
subjectId: formData.get("subjectId") || undefined,
teacherId: formData.get("teacherId") || undefined,
academicYearId: formData.get("academicYearId") || undefined,
semester: formData.get("semester") || undefined,
totalHours: formData.get("totalHours") || undefined,
completedHours: formData.get("completedHours") || undefined,
weeklyHours: formData.get("weeklyHours") || undefined,
startDate: formData.get("startDate") || undefined,
endDate: formData.get("endDate") || undefined,
syllabus: formData.get("syllabus") || undefined,
objectives: formData.get("objectives") || undefined,
status: formData.get("status") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
await updateCoursePlan(id, parsed.data)
revalidatePlanPaths(id)
return { success: true, message: "Course plan updated", data: id }
} catch (e) {
return handleError(e)
}
}
export async function deleteCoursePlanAction(
id: string
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
const existing = await getCoursePlanById(id)
if (!existing) return { success: false, message: "Course plan not found" }
await deleteCoursePlan(id)
revalidatePlanPaths()
return { success: true, message: "Course plan deleted" }
} catch (e) {
return handleError(e)
}
}
export async function getCoursePlansAction(
params?: GetCoursePlansParams
): Promise<ActionState<CoursePlanListItem[]>> {
try {
await requirePermission(Permissions.COURSE_PLAN_READ)
const data = await getCoursePlans(params)
return { success: true, data }
} catch (e) {
return handleError(e)
}
}
export async function getCoursePlanAction(
id: string
): Promise<ActionState<CoursePlanWithItems>> {
try {
await requirePermission(Permissions.COURSE_PLAN_READ)
const data = await getCoursePlanById(id)
if (!data) return { success: false, message: "Course plan not found" }
return { success: true, data }
} catch (e) {
return handleError(e)
}
}
export async function createCoursePlanItemAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
const parsed = CreateCoursePlanItemSchema.safeParse({
planId: formData.get("planId"),
week: formData.get("week") || undefined,
topic: formData.get("topic"),
content: formData.get("content") || undefined,
hours: formData.get("hours") || undefined,
textbookChapter: formData.get("textbookChapter") || undefined,
notes: formData.get("notes") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const itemId = await createCoursePlanItem(parsed.data)
revalidatePlanPaths(parsed.data.planId)
return { success: true, message: "Week plan added", data: itemId }
} catch (e) {
return handleError(e)
}
}
export async function updateCoursePlanItemAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
const parsed = UpdateCoursePlanItemSchema.safeParse({
week: formData.get("week") || undefined,
topic: formData.get("topic") || undefined,
content: formData.get("content") || undefined,
hours: formData.get("hours") || undefined,
textbookChapter: formData.get("textbookChapter") || undefined,
notes: formData.get("notes") || undefined,
isCompleted:
formData.get("isCompleted") === "true"
? true
: formData.get("isCompleted") === "false"
? false
: undefined,
completedAt: formData.get("completedAt") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
await updateCoursePlanItem(id, parsed.data)
return { success: true, message: "Week plan updated", data: id }
} catch (e) {
return handleError(e)
}
}
export async function deleteCoursePlanItemAction(
id: string
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
await deleteCoursePlanItem(id)
return { success: true, message: "Week plan deleted" }
} catch (e) {
return handleError(e)
}
}
export async function toggleCoursePlanItemCompletedAction(
id: string,
completed: boolean
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
await updateCoursePlanItem(id, {
isCompleted: completed,
completedAt: completed ? new Date().toISOString().slice(0, 10) : null,
})
return {
success: true,
message: completed ? "Marked as completed" : "Marked as incomplete",
}
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,258 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
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: "Planning",
active: "Active",
completed: "Completed",
paused: "Paused",
}
export function CoursePlanDetail({
plan,
editHref,
backHref,
}: {
plan: CoursePlanWithItems
editHref?: string
backHref?: string
}) {
const router = useRouter()
const { hasPermission } = usePermission()
const canManage = hasPermission(Permissions.COURSE_PLAN_MANAGE)
const [isWorking, setIsWorking] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [editorOpen, setEditorOpen] = useState(false)
const [editingItem, setEditingItem] = useState<CoursePlanWithItems["items"][number] | undefined>()
const completedItems = plan.items.filter((i) => i.isCompleted).length
const handleDelete = async () => {
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)
router.refresh()
} else {
toast.error(res.message || "Failed to delete")
}
} catch {
toast.error("Failed to delete")
} finally {
setIsWorking(false)
setDeleteOpen(false)
}
}
const openCreateEditor = () => {
setEditingItem(undefined)
setEditorOpen(true)
}
const openEditEditor = (item: CoursePlanWithItems["items"][number]) => {
setEditingItem(item)
setEditorOpen(true)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{backHref ? (
<Button asChild variant="ghost" size="icon">
<a href={backHref}>
<ArrowLeft className="h-4 w-4" />
</a>
</Button>
) : null}
<h2 className="text-2xl font-bold tracking-tight">Course Plan</h2>
</div>
{canManage ? (
<div className="flex flex-wrap items-center gap-2">
{editHref ? (
<Button asChild variant="outline">
<a href={editHref}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</a>
</Button>
) : null}
<Button onClick={() => setDeleteOpen(true)} disabled={isWorking} variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
) : null}
</div>
<Card>
<CardHeader className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{plan.className ?? "No class"}</Badge>
<Badge variant="outline">{plan.subjectName ?? "Unknown subject"}</Badge>
<Badge>{STATUS_LABEL[plan.status]}</Badge>
<Badge variant="outline">Semester {plan.semester}</Badge>
</div>
<CardTitle className="text-xl">
{plan.subjectName ?? "Course Plan"} {plan.className ?? "No Class"}
</CardTitle>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>Teacher: {plan.teacherName ?? "Unassigned"}</span>
<span>· Created {formatDate(plan.createdAt)}</span>
{plan.startDate ? <span>· Start {formatDate(plan.startDate)}</span> : null}
{plan.endDate ? <span>· End {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}
/>
{plan.syllabus ? (
<div className="space-y-1">
<h4 className="text-sm font-semibold">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">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>Weekly Plan</CardTitle>
{canManage ? (
<Button onClick={openCreateEditor} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Week
</Button>
) : null}
</CardHeader>
<CardContent>
{plan.items.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No weekly plans yet. {canManage ? "Click \"Add Week\" to create one." : ""}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Week</TableHead>
<TableHead>Topic</TableHead>
<TableHead className="w-20">Hours</TableHead>
<TableHead className="w-32">Chapter</TableHead>
<TableHead className="w-28">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{plan.items.map((item) => (
<TableRow
key={item.id}
className={canManage ? "cursor-pointer" : ""}
onClick={canManage ? () => openEditEditor(item) : undefined}
>
<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">
Note: {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 ? "Done" : "Pending"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<CoursePlanItemEditor
planId={plan.id}
item={editingItem}
mode={editingItem ? "edit" : "create"}
open={editorOpen}
onOpenChange={setEditorOpen}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete course plan</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this course plan and all its weekly plans.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,284 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { createCoursePlanAction, updateCoursePlanAction } from "../actions"
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
type Mode = "create" | "edit"
interface Option {
id: string
name: string
}
export function CoursePlanForm({
mode,
plan,
classes = [],
subjects = [],
teachers = [],
academicYears = [],
backHref,
}: {
mode: Mode
plan?: CoursePlanListItem
classes?: Option[]
subjects?: Option[]
teachers?: Option[]
academicYears?: Option[]
backHref?: string
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [classId, setClassId] = useState(plan?.classId ?? "")
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 [academicYearId, setAcademicYearId] = useState(plan?.academicYearId ?? "")
const handleSubmit = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("classId", classId)
formData.set("subjectId", subjectId)
formData.set("teacherId", teacherId)
formData.set("semester", semester)
formData.set("status", status)
if (academicYearId) formData.set("academicYearId", academicYearId)
const res =
mode === "create"
? await createCoursePlanAction(null, formData)
: plan
? await updateCoursePlanAction(plan.id, null, formData)
: null
if (!res) {
toast.error("Invalid form state")
return
}
if (res.success) {
toast.success(res.message)
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"
router.push(redirectBase)
router.refresh()
} else {
toast.error(res.message || "Failed to save course plan")
}
} catch {
toast.error("Failed to save course plan")
} finally {
setIsWorking(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>
{mode === "create" ? "New Course Plan" : "Edit Course Plan"}
</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>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={classId} />
</div>
<div className="grid gap-2">
<Label>Subject</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="subjectId" value={subjectId} />
</div>
<div className="grid gap-2">
<Label>Teacher</Label>
<Select value={teacherId} onValueChange={setTeacherId}>
<SelectTrigger>
<SelectValue placeholder="Select a teacher" />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={teacherId} />
</div>
<div className="grid gap-2">
<Label>Academic Year</Label>
<Select value={academicYearId} onValueChange={setAcademicYearId}>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent>
{academicYears.map((y) => (
<SelectItem key={y.id} value={y.id}>
{y.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="academicYearId" value={academicYearId} />
</div>
<div className="grid gap-2">
<Label>Semester</Label>
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
<SelectTrigger>
<SelectValue placeholder="Select semester" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</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)}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="planning">Planning</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="status" value={status} />
</div>
<div className="grid gap-2">
<Label htmlFor="totalHours">Total Hours</Label>
<Input
id="totalHours"
name="totalHours"
type="number"
min={0}
defaultValue={plan?.totalHours ?? 0}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="weeklyHours">Weekly Hours</Label>
<Input
id="weeklyHours"
name="weeklyHours"
type="number"
min={0}
defaultValue={plan?.weeklyHours ?? 0}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="startDate">Start Date</Label>
<Input
id="startDate"
name="startDate"
type="date"
defaultValue={plan?.startDate ?? ""}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">End Date</Label>
<Input
id="endDate"
name="endDate"
type="date"
defaultValue={plan?.endDate ?? ""}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="syllabus">Syllabus</Label>
<Textarea
id="syllabus"
name="syllabus"
placeholder="Teaching syllabus..."
className="min-h-[100px]"
defaultValue={plan?.syllabus ?? ""}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="objectives">Objectives</Label>
<Textarea
id="objectives"
name="objectives"
placeholder="Teaching objectives..."
className="min-h-[100px]"
defaultValue={plan?.objectives ?? ""}
/>
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button
type="button"
variant="outline"
onClick={() => router.push(backHref ?? "/admin/course-plans")}
disabled={isWorking}
>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
</Button>
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,248 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Check, Trash2, X } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import {
createCoursePlanItemAction,
updateCoursePlanItemAction,
deleteCoursePlanItemAction,
toggleCoursePlanItemCompletedAction,
} from "../actions"
import type { CoursePlanItem } from "../types"
interface CoursePlanItemEditorProps {
planId: string
item?: CoursePlanItem
mode: "create" | "edit"
open: boolean
onOpenChange: (open: boolean) => void
}
export function CoursePlanItemEditor({
planId,
item,
mode,
open,
onOpenChange,
}: CoursePlanItemEditorProps) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const handleSubmit = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("planId", planId)
const res =
mode === "create"
? await createCoursePlanItemAction(null, formData)
: item
? await updateCoursePlanItemAction(item.id, null, formData)
: null
if (!res) {
toast.error("Invalid form state")
return
}
if (res.success) {
toast.success(res.message)
onOpenChange(false)
router.refresh()
} else {
toast.error(res.message || "Failed to save week plan")
}
} catch {
toast.error("Failed to save week plan")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!item) return
setIsWorking(true)
try {
const res = await deleteCoursePlanItemAction(item.id)
if (res.success) {
toast.success(res.message)
onOpenChange(false)
router.refresh()
} else {
toast.error(res.message || "Failed to delete")
}
} catch {
toast.error("Failed to delete")
} finally {
setIsWorking(false)
}
}
const handleToggleComplete = async () => {
if (!item) return
setIsWorking(true)
try {
const res = await toggleCoursePlanItemCompletedAction(item.id, !item.isCompleted)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to update")
}
} catch {
toast.error("Failed to update")
} finally {
setIsWorking(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Add Week Plan" : "Edit Week Plan"}
</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>
<Input
id="week"
name="week"
type="number"
min={1}
defaultValue={item?.week ?? 1}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="hours">Hours</Label>
<Input
id="hours"
name="hours"
type="number"
min={1}
defaultValue={item?.hours ?? 2}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="topic">Topic</Label>
<Input
id="topic"
name="topic"
placeholder="Week topic"
defaultValue={item?.topic ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
name="content"
placeholder="Teaching content..."
className="min-h-[100px]"
defaultValue={item?.content ?? ""}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="textbookChapter">Textbook Chapter</Label>
<Input
id="textbookChapter"
name="textbookChapter"
placeholder="e.g. Chapter 3"
defaultValue={item?.textbookChapter ?? ""}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="completedAt">Completed At</Label>
<Input
id="completedAt"
name="completedAt"
type="date"
defaultValue={item?.completedAt ?? ""}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
name="notes"
placeholder="Notes..."
defaultValue={item?.notes ?? ""}
/>
</div>
<DialogFooter className="gap-2">
{mode === "edit" && item ? (
<>
<Button
type="button"
variant="outline"
onClick={handleToggleComplete}
disabled={isWorking}
>
{item.isCompleted ? (
<>
<X className="mr-2 h-4 w-4" />
Mark Incomplete
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Mark Complete
</>
)}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isWorking}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</>
) : null}
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isWorking}
>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,160 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Plus, CalendarRange } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
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 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",
completed: "outline",
paused: "outline",
}
type Filter = "all" | CoursePlanStatus
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" },
]
export function CoursePlanList({
plans,
canManage,
createHref,
detailHrefBuilder,
initialStatus,
}: {
plans: CoursePlanListItem[]
canManage?: boolean
createHref?: string
detailHrefBuilder?: (id: string) => string
initialStatus?: Filter
}) {
const router = useRouter()
const { hasPermission } = usePermission()
const canManageResolved = canManage ?? hasPermission(Permissions.COURSE_PLAN_MANAGE)
const [filter, setFilter] = useState<Filter>(initialStatus ?? "all")
const filtered = useMemo(() => {
if (filter === "all") return plans
return plans.filter((p) => p.status === filter)
}, [plans, filter])
const handleFilterChange = (value: string) => {
setFilter(value as Filter)
const params = new URLSearchParams()
if (value !== "all") params.set("status", value)
const qs = params.toString()
router.replace(qs ? `?${qs}` : "?")
}
return (
<div className="space-y-6">
<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" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{canManageResolved && createHref ? (
<Button asChild>
<a href={createHref}>
<Plus className="mr-2 h-4 w-4" />
New Course Plan
</a>
</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."
}
icon={CalendarRange}
className="h-auto border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filtered.map((plan) => {
const href = detailHrefBuilder ? detailHrefBuilder(plan.id) : undefined
const card = (
<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"}
</CardTitle>
<Badge variant={STATUS_VARIANT[plan.status]} className="shrink-0">
{STATUS_LABEL[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>
{plan.teacherName ? <span>· {plan.teacherName}</span> : null}
</div>
<CoursePlanProgress
completedHours={plan.completedHours}
totalHours={plan.totalHours}
showDetails={false}
/>
<p className="text-xs text-muted-foreground">
Created {formatDate(plan.createdAt)}
</p>
</CardContent>
</Card>
)
return href ? (
<a key={plan.id} href={href} className="block h-full">
{card}
</a>
) : (
<div key={plan.id}>{card}</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,38 @@
"use client"
import { Progress } from "@/shared/components/ui/progress"
interface CoursePlanProgressProps {
completedHours: number
totalHours: number
completedItems?: number
totalItems?: number
showDetails?: boolean
}
export function CoursePlanProgress({
completedHours,
totalHours,
completedItems,
totalItems,
showDetails = true,
}: CoursePlanProgressProps) {
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="text-muted-foreground">
{completedHours} / {totalHours} hours ({hoursPercent}%)
</span>
</div>
<Progress value={hoursPercent} className="h-2" />
{showDetails && typeof completedItems === "number" && typeof totalItems === "number" ? (
<p className="text-xs text-muted-foreground">
{completedItems} of {totalItems} week plans completed
</p>
) : null}
</div>
)
}

View File

@@ -0,0 +1,320 @@
import "server-only"
import { cache } from "react"
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 type {
CoursePlan,
CoursePlanItem,
CoursePlanListItem,
CoursePlanStatus,
CoursePlanWithItems,
GetCoursePlansParams,
ReorderCoursePlanItemInput,
} from "./types"
import type {
CreateCoursePlanInput,
CreateCoursePlanItemInput,
UpdateCoursePlanInput,
UpdateCoursePlanItemInput,
} from "./schema"
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString()
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
className: string | null
subjectName: string | null
teacherName: string | null
}
): CoursePlanListItem => ({
id: row.id,
classId: row.classId,
subjectId: row.subjectId,
teacherId: row.teacherId,
academicYearId: row.academicYearId,
semester: row.semester,
totalHours: Number(row.totalHours),
completedHours: Number(row.completedHours),
weeklyHours: Number(row.weeklyHours),
startDate: toIso(row.startDate),
endDate: toIso(row.endDate),
syllabus: row.syllabus,
objectives: row.objectives,
status: row.status,
createdBy: row.createdBy,
createdAt: toIsoRequired(row.createdAt),
updatedAt: toIsoRequired(row.updatedAt),
className: row.className,
subjectName: row.subjectName,
teacherName: row.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 => ({
id: row.id,
planId: row.planId,
week: Number(row.week),
topic: row.topic,
content: row.content,
hours: Number(row.hours),
textbookChapter: row.textbookChapter,
notes: row.notes,
isCompleted: Boolean(row.isCompleted),
completedAt: toIso(row.completedAt),
createdAt: toIsoRequired(row.createdAt),
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,
})
.from(coursePlans)
.leftJoin(classes, eq(classes.id, coursePlans.classId))
.leftJoin(subjects, eq(subjects.id, coursePlans.subjectId))
.leftJoin(users, eq(users.id, coursePlans.teacherId))
export const getCoursePlans = cache(
async (params?: GetCoursePlansParams): Promise<CoursePlanListItem[]> => {
try {
const conditions: SQL[] = []
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 as CoursePlanStatus))
const query = buildPlanSelect()
const rows = await (conditions.length > 0
? query.where(and(...conditions))
: query
).orderBy(desc(coursePlans.createdAt))
return rows.map(mapPlanRow)
} catch {
return []
}
}
)
export const getCoursePlanById = cache(
async (id: string): Promise<CoursePlanWithItems | null> => {
try {
const [planRow] = await buildPlanSelect()
.where(eq(coursePlans.id, id))
.limit(1)
if (!planRow) return null
const itemRows = await db
.select()
.from(coursePlanItems)
.where(eq(coursePlanItems.planId, id))
.orderBy(asc(coursePlanItems.week), asc(coursePlanItems.createdAt))
return {
...mapPlanRow(planRow),
items: itemRows.map(mapItemRow),
}
} catch {
return null
}
}
)
export async function createCoursePlan(
data: CreateCoursePlanInput,
createdBy: string
): Promise<string> {
const id = createId()
await db.insert(coursePlans).values({
id,
classId: data.classId,
subjectId: data.subjectId,
teacherId: data.teacherId,
academicYearId: data.academicYearId,
semester: data.semester,
totalHours: data.totalHours,
completedHours: 0,
weeklyHours: data.weeklyHours,
startDate: data.startDate ? new Date(data.startDate) : null,
endDate: data.endDate ? new Date(data.endDate) : null,
syllabus: data.syllabus,
objectives: data.objectives,
status: data.status,
createdBy,
})
return id
}
export async function updateCoursePlan(
id: string,
data: Partial<UpdateCoursePlanInput>
): Promise<void> {
const update: Partial<typeof coursePlans.$inferSelect> = {}
if (data.classId !== undefined) update.classId = data.classId
if (data.subjectId !== undefined) update.subjectId = data.subjectId
if (data.teacherId !== undefined) update.teacherId = data.teacherId
if (data.academicYearId !== undefined) update.academicYearId = data.academicYearId
if (data.semester !== undefined) update.semester = data.semester
if (data.totalHours !== undefined) update.totalHours = data.totalHours
if (data.completedHours !== undefined) update.completedHours = data.completedHours
if (data.weeklyHours !== undefined) update.weeklyHours = data.weeklyHours
if (data.startDate !== undefined)
update.startDate = data.startDate ? new Date(data.startDate) : null
if (data.endDate !== undefined)
update.endDate = data.endDate ? new Date(data.endDate) : null
if (data.syllabus !== undefined) update.syllabus = data.syllabus
if (data.objectives !== undefined) update.objectives = data.objectives
if (data.status !== undefined) update.status = data.status
if (Object.keys(update).length === 0) return
await db.update(coursePlans).set(update).where(eq(coursePlans.id, id))
}
export async function deleteCoursePlan(id: string): Promise<void> {
await db.delete(coursePlans).where(eq(coursePlans.id, id))
}
export async function createCoursePlanItem(
data: CreateCoursePlanItemInput
): Promise<string> {
const id = createId()
await db.insert(coursePlanItems).values({
id,
planId: data.planId,
week: data.week,
topic: data.topic,
content: data.content,
hours: data.hours,
textbookChapter: data.textbookChapter,
notes: data.notes,
})
return id
}
export async function updateCoursePlanItem(
id: string,
data: Partial<UpdateCoursePlanItemInput>
): Promise<void> {
const update: Partial<typeof coursePlanItems.$inferSelect> = {}
if (data.week !== undefined) update.week = data.week
if (data.topic !== undefined) update.topic = data.topic
if (data.content !== undefined) update.content = data.content
if (data.hours !== undefined) update.hours = data.hours
if (data.textbookChapter !== undefined) update.textbookChapter = data.textbookChapter
if (data.notes !== undefined) update.notes = data.notes
if (data.isCompleted !== undefined) update.isCompleted = data.isCompleted
if (data.completedAt !== undefined)
update.completedAt = data.completedAt ? new Date(data.completedAt) : null
if (Object.keys(update).length === 0) return
await db.update(coursePlanItems).set(update).where(eq(coursePlanItems.id, id))
}
export async function deleteCoursePlanItem(id: string): Promise<void> {
await db.delete(coursePlanItems).where(eq(coursePlanItems.id, id))
}
export async function reorderCoursePlanItems(
planId: string,
items: ReorderCoursePlanItemInput[]
): Promise<void> {
if (items.length === 0) return
const itemIds = items.map((i) => i.id)
const [existing] = await db
.select({ id: coursePlanItems.id })
.from(coursePlanItems)
.where(and(eq(coursePlanItems.planId, planId), inArray(coursePlanItems.id, itemIds)))
.limit(1)
if (!existing) return
for (const item of items) {
await db
.update(coursePlanItems)
.set({ week: item.week })
.where(eq(coursePlanItems.id, item.id))
}
}
export type { CoursePlan, CoursePlanItem, CoursePlanWithItems }
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 {
return []
}
})

View File

@@ -0,0 +1,149 @@
import { z } from "zod"
export const CreateCoursePlanSchema = z
.object({
classId: z.string().trim().min(1),
subjectId: z.string().trim().min(1),
teacherId: z.string().trim().min(1),
academicYearId: z.string().trim().optional().nullable(),
semester: z.enum(["1", "2"]).optional(),
totalHours: z.coerce.number().int().min(0).optional(),
weeklyHours: z.coerce.number().int().min(0).optional(),
startDate: z.string().trim().optional().nullable(),
endDate: z.string().trim().optional().nullable(),
syllabus: z.string().trim().optional().nullable(),
objectives: z.string().trim().optional().nullable(),
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
})
.transform((v) => ({
classId: v.classId,
subjectId: v.subjectId,
teacherId: v.teacherId,
academicYearId: v.academicYearId && v.academicYearId.length > 0 ? v.academicYearId : null,
semester: v.semester ?? "1",
totalHours: v.totalHours ?? 0,
weeklyHours: v.weeklyHours ?? 0,
startDate: v.startDate && v.startDate.length > 0 ? v.startDate : null,
endDate: v.endDate && v.endDate.length > 0 ? v.endDate : null,
syllabus: v.syllabus && v.syllabus.length > 0 ? v.syllabus : null,
objectives: v.objectives && v.objectives.length > 0 ? v.objectives : null,
status: v.status ?? "planning",
}))
export type CreateCoursePlanInput = z.infer<typeof CreateCoursePlanSchema>
export const UpdateCoursePlanSchema = z
.object({
classId: z.string().trim().min(1).optional(),
subjectId: z.string().trim().min(1).optional(),
teacherId: z.string().trim().min(1).optional(),
academicYearId: z.string().trim().optional().nullable(),
semester: z.enum(["1", "2"]).optional(),
totalHours: z.coerce.number().int().min(0).optional(),
completedHours: z.coerce.number().int().min(0).optional(),
weeklyHours: z.coerce.number().int().min(0).optional(),
startDate: z.string().trim().optional().nullable(),
endDate: z.string().trim().optional().nullable(),
syllabus: z.string().trim().optional().nullable(),
objectives: z.string().trim().optional().nullable(),
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
})
.transform((v) => ({
...v,
academicYearId:
v.academicYearId !== undefined
? v.academicYearId && v.academicYearId.length > 0
? v.academicYearId
: null
: undefined,
startDate:
v.startDate !== undefined
? v.startDate && v.startDate.length > 0
? v.startDate
: null
: undefined,
endDate:
v.endDate !== undefined
? v.endDate && v.endDate.length > 0
? v.endDate
: null
: undefined,
syllabus:
v.syllabus !== undefined
? v.syllabus && v.syllabus.length > 0
? v.syllabus
: null
: undefined,
objectives:
v.objectives !== undefined
? v.objectives && v.objectives.length > 0
? v.objectives
: null
: undefined,
}))
export type UpdateCoursePlanInput = z.infer<typeof UpdateCoursePlanSchema>
export const CreateCoursePlanItemSchema = z
.object({
planId: z.string().trim().min(1),
week: z.coerce.number().int().min(1),
topic: z.string().trim().min(1).max(255),
content: z.string().trim().optional().nullable(),
hours: z.coerce.number().int().min(1).optional(),
textbookChapter: z.string().trim().optional().nullable(),
notes: z.string().trim().optional().nullable(),
})
.transform((v) => ({
planId: v.planId,
week: v.week,
topic: v.topic,
content: v.content && v.content.length > 0 ? v.content : null,
hours: v.hours ?? 2,
textbookChapter:
v.textbookChapter && v.textbookChapter.length > 0 ? v.textbookChapter : null,
notes: v.notes && v.notes.length > 0 ? v.notes : null,
}))
export type CreateCoursePlanItemInput = z.infer<typeof CreateCoursePlanItemSchema>
export const UpdateCoursePlanItemSchema = z
.object({
week: z.coerce.number().int().min(1).optional(),
topic: z.string().trim().min(1).max(255).optional(),
content: z.string().trim().optional().nullable(),
hours: z.coerce.number().int().min(1).optional(),
textbookChapter: z.string().trim().optional().nullable(),
notes: z.string().trim().optional().nullable(),
isCompleted: z.boolean().optional(),
completedAt: z.string().trim().optional().nullable(),
})
.transform((v) => ({
...v,
content:
v.content !== undefined
? v.content && v.content.length > 0
? v.content
: null
: undefined,
textbookChapter:
v.textbookChapter !== undefined
? v.textbookChapter && v.textbookChapter.length > 0
? v.textbookChapter
: null
: undefined,
notes:
v.notes !== undefined
? v.notes && v.notes.length > 0
? v.notes
: null
: undefined,
completedAt:
v.completedAt !== undefined
? v.completedAt && v.completedAt.length > 0
? v.completedAt
: null
: undefined,
}))
export type UpdateCoursePlanItemInput = z.infer<typeof UpdateCoursePlanItemSchema>

View File

@@ -0,0 +1,60 @@
export type CoursePlanStatus = "planning" | "active" | "completed" | "paused"
export type CoursePlanSemester = "1" | "2"
export interface CoursePlan {
id: string
classId: string
subjectId: string
teacherId: string
academicYearId: string | null
semester: CoursePlanSemester
totalHours: number
completedHours: number
weeklyHours: number
startDate: string | null
endDate: string | null
syllabus: string | null
objectives: string | null
status: CoursePlanStatus
createdBy: string
createdAt: string
updatedAt: string
}
export interface CoursePlanItem {
id: string
planId: string
week: number
topic: string
content: string | null
hours: number
textbookChapter: string | null
notes: string | null
isCompleted: boolean
completedAt: string | null
createdAt: string
updatedAt: string
}
export interface CoursePlanListItem extends CoursePlan {
className: string | null
subjectName: string | null
teacherName: string | null
}
export interface CoursePlanWithItems extends CoursePlanListItem {
items: CoursePlanItem[]
}
export interface GetCoursePlansParams {
classId?: string
teacherId?: string
subjectId?: string
status?: CoursePlanStatus
}
export interface ReorderCoursePlanItemInput {
id: string
week: number
}