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:
265
src/modules/course-plans/actions.ts
Normal file
265
src/modules/course-plans/actions.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
258
src/modules/course-plans/components/course-plan-detail.tsx
Normal file
258
src/modules/course-plans/components/course-plan-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
284
src/modules/course-plans/components/course-plan-form.tsx
Normal file
284
src/modules/course-plans/components/course-plan-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
src/modules/course-plans/components/course-plan-item-editor.tsx
Normal file
248
src/modules/course-plans/components/course-plan-item-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
src/modules/course-plans/components/course-plan-list.tsx
Normal file
160
src/modules/course-plans/components/course-plan-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/modules/course-plans/components/course-plan-progress.tsx
Normal file
38
src/modules/course-plans/components/course-plan-progress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
320
src/modules/course-plans/data-access.ts
Normal file
320
src/modules/course-plans/data-access.ts
Normal 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 []
|
||||
}
|
||||
})
|
||||
149
src/modules/course-plans/schema.ts
Normal file
149
src/modules/course-plans/schema.ts
Normal 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>
|
||||
60
src/modules/course-plans/types.ts
Normal file
60
src/modules/course-plans/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user