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,128 @@
import { Metadata } from "next"
import Link from "next/link"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
export const metadata: Metadata = {
title: "隐私政策 - Next_Edu",
description: "Next_Edu 隐私政策与个人信息保护说明",
}
const SECTION_CLASS = "space-y-2"
const HEADING_CLASS = "text-base font-semibold text-foreground"
const TEXT_CLASS = "text-sm leading-relaxed text-muted-foreground"
const LIST_CLASS = "ml-4 list-disc space-y-1 text-sm leading-relaxed text-muted-foreground"
export default function PrivacyPage() {
return (
<div className="mx-auto w-full max-w-3xl space-y-6 py-8">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
2026 6 16
</p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
Next_Edu K12
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<p className={TEXT_CLASS}>
</p>
<ul className={LIST_CLASS}>
<li></li>
<li>//</li>
<li></li>
<li></li>
<li>访</li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}>使</h2>
<p className={TEXT_CLASS}></p>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li>使 bcrypt AI 使 AES </li>
<li>访RBACDataScope</li>
<li>HTTPS 访</li>
<li>访</li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li></li>
<li>使</li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}>Cookie </h2>
<p className={TEXT_CLASS}>
使 Cookie Cookie
Cookie 广
</p>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li> 14 </li>
<li>14 18 </li>
<li>访</li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<p className={TEXT_CLASS}>
</p>
<ul className={LIST_CLASS}>
<li>privacy@next-edu.example.com</li>
<li>400-000-0000 9:00-18:00</li>
</ul>
</section>
<div className="border-t pt-4 text-center">
<Link
href="/register"
className="text-sm font-medium text-primary underline underline-offset-4 hover:opacity-80"
>
</Link>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -11,12 +11,27 @@ export const metadata: Metadata = {
description: "Create an account",
}
const ADULT_AGE = 18
const normalizeBcryptHash = (value: string) => {
if (value.startsWith("$2")) return value
if (value.startsWith("$")) return `$2b${value}`
return `$2b$${value}`
}
function calcAge(birth: string): number | null {
if (!birth) return null
const birthDate = new Date(birth)
if (Number.isNaN(birthDate.getTime())) return null
const now = new Date()
let age = now.getFullYear() - birthDate.getFullYear()
const monthDiff = now.getMonth() - birthDate.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) {
age -= 1
}
return age >= 0 ? age : null
}
export default function RegisterPage() {
async function registerAction(formData: FormData): Promise<ActionState> {
"use server"
@@ -33,11 +48,23 @@ export default function RegisterPage() {
const name = String(formData.get("name") ?? "").trim()
const email = String(formData.get("email") ?? "").trim().toLowerCase()
const password = String(formData.get("password") ?? "")
const birthDateRaw = String(formData.get("birthDate") ?? "").trim()
const guardianName = String(formData.get("guardianName") ?? "").trim()
const guardianPhone = String(formData.get("guardianPhone") ?? "").trim()
const guardianRelation = String(formData.get("guardianRelation") ?? "").trim()
if (!email) return { success: false, message: "请输入邮箱" }
if (!password) return { success: false, message: "请输入密码" }
if (password.length < 6) return { success: false, message: "密码至少 6 位" }
const age = calcAge(birthDateRaw)
const isMinor = age !== null && age < ADULT_AGE
if (isMinor) {
if (!guardianName) return { success: false, message: "未成年人须填写监护人姓名" }
if (!guardianPhone) return { success: false, message: "未成年人须填写监护人电话" }
if (!guardianRelation) return { success: false, message: "未成年人须选择监护人关系" }
}
const existing = await db.query.users.findFirst({
where: eq(users.email, email),
columns: { id: true },
@@ -51,6 +78,12 @@ export default function RegisterPage() {
name: name.length ? name : null,
email,
password: hashedPassword,
birthDate: birthDateRaw ? new Date(birthDateRaw) : null,
age: age ?? null,
guardianName: guardianName || null,
guardianPhone: guardianPhone || null,
guardianRelation: guardianRelation || null,
consentAcceptedAt: new Date(),
})
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, "student"),

View File

@@ -0,0 +1,116 @@
import { Metadata } from "next"
import Link from "next/link"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
export const metadata: Metadata = {
title: "用户协议 - Next_Edu",
description: "Next_Edu 用户服务协议",
}
const SECTION_CLASS = "space-y-2"
const HEADING_CLASS = "text-base font-semibold text-foreground"
const TEXT_CLASS = "text-sm leading-relaxed text-muted-foreground"
const LIST_CLASS = "ml-4 list-disc space-y-1 text-sm leading-relaxed text-muted-foreground"
export default function TermsPage() {
return (
<div className="mx-auto w-full max-w-3xl space-y-6 py-8">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
2026 6 16
</p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
使 Next_Edu 使
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<p className={TEXT_CLASS}>
K12 AI
</p>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li>使</li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li>访使</li>
<li></li>
<li>使</li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li>AI 使</li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
</ul>
</section>
<div className="border-t pt-4 text-center">
<Link
href="/register"
className="text-sm font-medium text-primary underline underline-offset-4 hover:opacity-80"
>
</Link>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { notFound } from "next/navigation"
import { getAnnouncementById } from "@/modules/announcements/data-access"
import { getGrades } from "@/modules/school/data-access"
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
export const dynamic = "force-dynamic"
export default async function EditAnnouncementPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const [announcement, grades] = await Promise.all([
getAnnouncementById(id),
getGrades(),
])
if (!announcement) notFound()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Edit Announcement</h2>
<p className="text-muted-foreground">Update the announcement details below.</p>
</div>
<AnnouncementForm
mode="edit"
announcement={announcement}
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
/>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { getAnnouncements } from "@/modules/announcements/data-access"
import { getGrades } from "@/modules/school/data-access"
import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
import type { AnnouncementStatus } from "@/modules/announcements/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const isValidStatus = (v?: string): v is AnnouncementStatus =>
v === "draft" || v === "published" || v === "archived"
export default async function AdminAnnouncementsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const statusParam = getParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const [announcements, grades] = await Promise.all([
getAnnouncements({ status }),
getGrades(),
])
return (
<AdminAnnouncementsView
announcements={announcements}
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
initialStatus={status}
/>
)
}

View File

@@ -0,0 +1,71 @@
import Link from "next/link"
import { BarChart3, ClipboardList } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getAdminClasses } from "@/modules/classes/data-access"
import { getAttendanceRecords } from "@/modules/attendance/data-access"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function AdminAttendancePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const ctx = await getAuthContext()
const classId = getParam(sp, "classId")
const status = getParam(sp, "status")
const date = getParam(sp, "date")
const classes = await getAdminClasses()
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const result = await getAttendanceRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
status: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined,
date: date && date.length > 0 ? date : undefined,
})
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Attendance Overview</h2>
<p className="text-muted-foreground">View all attendance records across the school.</p>
</div>
<Button asChild variant="outline">
<Link href="/teacher/attendance/stats">
<BarChart3 className="mr-2 h-4 w-4" />
Statistics
</Link>
</Button>
</div>
<AttendanceFilters classes={classOptions} />
{result.items.length === 0 && !classId && !status && !date ? (
<EmptyState
title="No attendance records"
description="There are no attendance records yet."
icon={ClipboardList}
/>
) : (
<AttendanceRecordList records={result.items} />
)}
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import {
getDataChangeLogs,
getDataChangeStats,
getDataChangeTableOptions,
} from "@/modules/audit/data-access"
import { DataChangeLogTable } from "@/modules/audit/components/data-change-log-table"
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
import type { DataChangeAction } from "@/modules/audit/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function DataChangeLogsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
await requirePermission(Permissions.AUDIT_LOG_READ)
const params = await searchParams
const page = Number(getParam(params, "page") ?? "1") || 1
const tableName = getParam(params, "tableName") ?? undefined
const action = (getParam(params, "action") as DataChangeAction | undefined) ?? undefined
const startDate = getParam(params, "startDate") ?? undefined
const endDate = getParam(params, "endDate") ?? undefined
const [result, tableOptions, stats] = await Promise.all([
getDataChangeLogs({ page, tableName, action, startDate, endDate }),
getDataChangeTableOptions(),
getDataChangeStats(),
])
const exportParams: Record<string, string> = {}
if (tableName) exportParams.tableName = tableName
if (action) exportParams.action = action
if (startDate) exportParams.startDate = startDate
if (endDate) exportParams.endDate = endDate
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Data Change Logs</h2>
<p className="text-muted-foreground">
Track all data mutations (create/update/delete) across system tables for compliance.
</p>
</div>
<AuditLogExportButton exportType="dataChange" params={exportParams} />
</div>
<DataChangeLogTable
items={result.items}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
tableOptions={tableOptions}
stats={stats}
/>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getLoginLogs } from "@/modules/audit/data-access"
import { LoginLogView } from "@/modules/audit/components/login-log-view"
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
import type { LoginLogAction, LoginLogStatus } from "@/modules/audit/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function LoginLogsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
await requirePermission(Permissions.AUDIT_LOG_READ)
const params = await searchParams
const page = Number(getParam(params, "page") ?? "1") || 1
const action = (getParam(params, "action") as LoginLogAction | undefined) ?? undefined
const status = (getParam(params, "status") as LoginLogStatus | undefined) ?? undefined
const startDate = getParam(params, "startDate") ?? undefined
const endDate = getParam(params, "endDate") ?? undefined
const result = await getLoginLogs({ page, action, status, startDate, endDate })
const exportParams: Record<string, string> = {}
if (action) exportParams.action = action
if (status) exportParams.status = status
if (startDate) exportParams.startDate = startDate
if (endDate) exportParams.endDate = endDate
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Login Logs</h2>
<p className="text-muted-foreground">
Monitor all authentication events including sign in, sign out, and sign up.
</p>
</div>
<AuditLogExportButton exportType="login" params={exportParams} />
</div>
<LoginLogView
items={result.items}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
/>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAuditLogs, getAuditModuleOptions } from "@/modules/audit/data-access"
import { AuditLogView } from "@/modules/audit/components/audit-log-view"
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
import type { AuditLogStatus } from "@/modules/audit/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function AuditLogsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
await requirePermission(Permissions.AUDIT_LOG_READ)
const params = await searchParams
const page = Number(getParam(params, "page") ?? "1") || 1
const moduleFilter = getParam(params, "module") ?? undefined
const action = getParam(params, "action") ?? undefined
const status = (getParam(params, "status") as AuditLogStatus | undefined) ?? undefined
const startDate = getParam(params, "startDate") ?? undefined
const endDate = getParam(params, "endDate") ?? undefined
const [result, moduleOptions] = await Promise.all([
getAuditLogs({ page, module: moduleFilter, action, status, startDate, endDate }),
getAuditModuleOptions(),
])
const exportParams: Record<string, string> = {}
if (moduleFilter) exportParams.module = moduleFilter
if (action) exportParams.action = action
if (status) exportParams.status = status
if (startDate) exportParams.startDate = startDate
if (endDate) exportParams.endDate = endDate
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Audit Logs</h2>
<p className="text-muted-foreground">
Track all user operations across the system for security and compliance.
</p>
</div>
<AuditLogExportButton exportType="audit" params={exportParams} />
</div>
<AuditLogView
items={result.items}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
moduleOptions={moduleOptions}
/>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { notFound } from "next/navigation"
import { getCoursePlanById } from "@/modules/course-plans/data-access"
import { getSubjectOptions } from "@/modules/course-plans/data-access"
import { getAdminClasses } from "@/modules/classes/data-access"
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
export const dynamic = "force-dynamic"
export default async function EditCoursePlanPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const [plan, classes, subjects, teachers, academicYears] = await Promise.all([
getCoursePlanById(id),
getAdminClasses(),
getSubjectOptions(),
getStaffOptions(),
getAcademicYears(),
])
if (!plan) notFound()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Edit Course Plan</h2>
<p className="text-muted-foreground">Update the course plan details below.</p>
</div>
<CoursePlanForm
mode="edit"
plan={plan}
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
subjects={subjects}
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))}
backHref={`/admin/course-plans/${plan.id}`}
/>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { notFound } from "next/navigation"
import { getCoursePlanById } from "@/modules/course-plans/data-access"
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
export const dynamic = "force-dynamic"
export default async function CoursePlanDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const plan = await getCoursePlanById(id)
if (!plan) notFound()
return (
<div className="flex h-full flex-col space-y-6 p-8">
<CoursePlanDetail
plan={plan}
editHref={`/admin/course-plans/${plan.id}/edit`}
backHref="/admin/course-plans"
/>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { getAdminClasses } from "@/modules/classes/data-access"
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
import { getSubjectOptions } from "@/modules/course-plans/data-access"
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
export const dynamic = "force-dynamic"
export default async function CreateCoursePlanPage() {
const [classes, subjects, teachers, academicYears] = await Promise.all([
getAdminClasses(),
getSubjectOptions(),
getStaffOptions(),
getAcademicYears(),
])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">New Course Plan</h2>
<p className="text-muted-foreground">Create a new course teaching plan.</p>
</div>
<CoursePlanForm
mode="create"
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
subjects={subjects}
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))}
backHref="/admin/course-plans"
/>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { getCoursePlans } from "@/modules/course-plans/data-access"
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
import type { CoursePlanStatus } from "@/modules/course-plans/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const isValidStatus = (v?: string): v is CoursePlanStatus =>
v === "planning" || v === "active" || v === "completed" || v === "paused"
export default async function AdminCoursePlansPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const statusParam = getParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const plans = await getCoursePlans({ status })
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Course Plans</h2>
<p className="text-muted-foreground">
Manage course teaching plans and weekly schedules.
</p>
</div>
<CoursePlanList
plans={plans}
canManage
createHref="/admin/course-plans/create"
detailHrefBuilder={(id) => `/admin/course-plans/${id}`}
initialStatus={status}
/>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import {
getFileAttachmentsWithFilters,
getFileStats,
} from "@/modules/files/data-access"
import { AdminFilesView } from "@/modules/files/components/admin-files-view"
export const dynamic = "force-dynamic"
export default async function AdminFilesPage() {
await requirePermission(Permissions.FILE_READ)
const [files, stats] = await Promise.all([
getFileAttachmentsWithFilters({ limit: 200 }),
getFileStats(),
])
return <AdminFilesView files={files} stats={stats} />
}

View File

@@ -0,0 +1,51 @@
import Link from "next/link"
import { CalendarClock, ClipboardList, Settings2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAdminClassesForScheduling } from "@/modules/scheduling/actions"
import { AutoSchedulePanel } from "@/modules/scheduling/components/auto-schedule-panel"
export const dynamic = "force-dynamic"
export default async function AdminSchedulingAutoPage() {
const classes = await getAdminClassesForScheduling()
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Auto Schedule</h2>
<p className="text-muted-foreground">
Generate a weekly schedule automatically based on configured rules and subject
assignments.
</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/scheduling/rules">
<Settings2 className="mr-2 h-4 w-4" />
Configure Rules
</Link>
</Button>
</div>
{classOptions.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="No classes available"
description="Please create classes before running auto scheduling."
/>
) : (
<AutoSchedulePanel classes={classOptions} />
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<CalendarClock className="h-4 w-4" />
<span>
Applying a new schedule will replace the existing schedule for the selected class.
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import Link from "next/link"
import { PlusCircle, ClipboardList } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
getAdminClassesForScheduling,
getScheduleChanges,
} from "@/modules/scheduling/actions"
import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list"
import { ScheduleConflictsView } from "@/modules/scheduling/components/schedule-conflicts-view"
import type { ScheduleChangeStatus } from "@/modules/scheduling/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const isValidStatus = (v?: string): v is ScheduleChangeStatus =>
v === "pending" || v === "approved" || v === "rejected" || v === "completed"
export default async function AdminSchedulingChangesPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const statusParam = getParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const classIdParam = getParam(sp, "classId")
const classId = classIdParam && classIdParam !== "all" ? classIdParam : undefined
const [classes, items] = await Promise.all([
getAdminClassesForScheduling(),
getScheduleChanges({ status, classId }),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Schedule Change Requests</h2>
<p className="text-muted-foreground">
Review, approve, or reject schedule change and substitute teacher requests.
</p>
</div>
<Button asChild>
<Link href="/teacher/schedule-changes">
<PlusCircle className="mr-2 h-4 w-4" />
New Request
</Link>
</Button>
</div>
{items.length === 0 && !status && !classId ? (
<EmptyState
icon={ClipboardList}
title="No schedule change requests"
description="There are no schedule change requests yet."
action={{
label: "New Request",
href: "/teacher/schedule-changes",
}}
/>
) : (
<ScheduleChangeList items={items} canApprove />
)}
<div className="space-y-2">
<h3 className="text-lg font-semibold">Conflict Detection</h3>
<p className="text-sm text-muted-foreground">
Detect time overlaps in an existing class schedule.
</p>
{classOptions.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="No classes available"
description="Please create classes before checking conflicts."
/>
) : (
<ScheduleConflictsView classes={classOptions} />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { CalendarCog, ClipboardList } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
getAdminClassesForScheduling,
getSchedulingRules,
} from "@/modules/scheduling/actions"
import { SchedulingRulesForm } from "@/modules/scheduling/components/scheduling-rules-form"
export const dynamic = "force-dynamic"
export default async function AdminSchedulingRulesPage() {
const [classes, existingRules] = await Promise.all([
getAdminClassesForScheduling(),
getSchedulingRules(),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Scheduling Rules</h2>
<p className="text-muted-foreground">
Configure daily hour limits, break windows, and balancing preferences for each class.
</p>
</div>
{classOptions.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="No classes available"
description="Please create classes before configuring scheduling rules."
/>
) : (
<SchedulingRulesForm classes={classOptions} existingRules={existingRules} />
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<CalendarCog className="h-4 w-4" />
<span>
Tip: rules saved without selecting a specific class become the global default.
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,134 @@
import { Metadata } from "next"
import Link from "next/link"
import { ArrowLeft, Users, FileSpreadsheet, Info } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { UserImportDialog } from "@/modules/users/components/user-import-dialog"
export const metadata: Metadata = {
title: "批量导入用户 - Next_Edu",
description: "通过 Excel 批量导入用户",
}
export default function UserImportPage() {
return (
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm">
<Link href="/admin/dashboard">
<ArrowLeft className="mr-1 h-4 w-4" />
</Link>
</Button>
<h2 className="text-2xl font-bold tracking-tight"></h2>
</div>
<p className="text-muted-foreground mt-1">
Excel
</p>
</div>
<UserImportDialog />
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5 text-primary" />
<CardTitle className="text-base"></CardTitle>
</div>
<CardDescription>使 Excel </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">1</span>
<p></p>
</div>
<div className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">2</span>
<p></p>
</div>
<div className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">3</span>
<p> Excel </p>
</div>
<div className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">4</span>
<p></p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Info className="h-5 w-5 text-amber-500" />
<CardTitle className="text-base"></CardTitle>
</div>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p> <code className="rounded bg-muted px-1 py-0.5 text-xs">123456</code></p>
<p> </p>
<p> admin / teacher / student / parent / grade_head / teaching_head</p>
<p> student </p>
<p> 10MB 500 </p>
<p> </p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<CardTitle className="text-base"></CardTitle>
</div>
<CardDescription>Excel </CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 pr-4 text-left font-medium"></th>
<th className="py-2 pr-4 text-left font-medium"></th>
<th className="py-2 pr-4 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
<tr>
<td className="py-2 pr-4 font-medium"></td>
<td className="py-2 pr-4"></td>
<td className="py-2 pr-4 text-muted-foreground"></td>
</tr>
<tr>
<td className="py-2 pr-4 font-medium"></td>
<td className="py-2 pr-4"></td>
<td className="py-2 pr-4 text-muted-foreground"></td>
</tr>
<tr>
<td className="py-2 pr-4 font-medium"></td>
<td className="py-2 pr-4"></td>
<td className="py-2 pr-4 text-muted-foreground">admin / teacher / student / parent / grade_head / teaching_head</td>
</tr>
<tr>
<td className="py-2 pr-4 font-medium"></td>
<td className="py-2 pr-4"></td>
<td className="py-2 pr-4 text-muted-foreground"></td>
</tr>
<tr>
<td className="py-2 pr-4 font-medium"></td>
<td className="py-2 pr-4"></td>
<td className="py-2 pr-4 text-muted-foreground"> student 6 </td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { getAnnouncements } from "@/modules/announcements/data-access"
import { AnnouncementList } from "@/modules/announcements/components/announcement-list"
export const dynamic = "force-dynamic"
export default async function AnnouncementsPage() {
const announcements = await getAnnouncements({ status: "published" })
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Announcements</h2>
<p className="text-muted-foreground">
Stay up to date with the latest school announcements.
</p>
</div>
<AnnouncementList announcements={announcements} />
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { notFound } from "next/navigation"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getMessageById, markMessageAsRead } from "@/modules/messaging/data-access"
import { MessageDetail } from "@/modules/messaging/components/message-detail"
export const dynamic = "force-dynamic"
export default async function MessageDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const { id } = await params
const message = await getMessageById(id, ctx.userId)
if (!message) notFound()
// Auto-mark as read when viewed by the receiver
if (!message.isRead && message.receiverId === ctx.userId) {
await markMessageAsRead(id, ctx.userId)
}
return (
<div className="flex h-full flex-col p-8">
<MessageDetail message={message} currentUserId={ctx.userId} />
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getRecipients } from "@/modules/messaging/data-access"
import { MessageCompose } from "@/modules/messaging/components/message-compose"
export const dynamic = "force-dynamic"
export default async function ComposeMessagePage({
searchParams,
}: {
searchParams: Promise<{ parentId?: string; receiverId?: string; subject?: string }>
}) {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const sp = await searchParams
const recipients = await getRecipients(ctx.userId, ctx.dataScope)
return (
<div className="flex h-full flex-col p-8">
<div className="mx-auto w-full max-w-3xl space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Compose Message</h2>
<p className="text-muted-foreground">Send a message to another user.</p>
</div>
<MessageCompose
recipients={recipients}
parentMessageId={sp.parentId}
defaultReceiverId={sp.receiverId}
defaultSubject={sp.subject}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getMessages, getNotifications } from "@/modules/messaging/data-access"
import { MessageList } from "@/modules/messaging/components/message-list"
import { NotificationList } from "@/modules/messaging/components/notification-list"
export const dynamic = "force-dynamic"
export default async function MessagesPage() {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const [messagesResult, notificationsResult] = await Promise.all([
getMessages({ userId: ctx.userId, type: "all", page: 1, pageSize: 50 }),
getNotifications(ctx.userId, { page: 1, pageSize: 20 }),
])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Messages</h2>
<p className="text-muted-foreground">
Manage your inbox and stay updated with notifications.
</p>
</div>
<MessageList messages={messagesResult.items} currentUserId={ctx.userId} />
<NotificationList notifications={notificationsResult.items} />
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { CalendarCheck } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function ParentAttendancePage() {
const ctx = await getAuthContext()
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Children Attendance</h2>
<p className="text-muted-foreground">View your children&apos;s attendance records.</p>
</div>
<EmptyState
title="No children linked"
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
icon={CalendarCheck}
className="border-none shadow-none"
/>
</div>
)
}
const summaries = await Promise.all(
ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id))
)
const validSummaries = summaries.filter((s): s is NonNullable<typeof s> => s !== null)
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Children Attendance</h2>
<p className="text-muted-foreground">View your children&apos;s attendance records.</p>
</div>
{validSummaries.length === 0 ? (
<EmptyState
title="No attendance records"
description="Your children don&apos;t have any attendance records yet."
icon={CalendarCheck}
className="border-none shadow-none"
/>
) : (
<div className="space-y-8">
{validSummaries.map((summary) => (
<div key={summary.studentId} className="space-y-4">
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
<StudentAttendanceView summary={summary} />
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { notFound } from "next/navigation"
import { eq } from "drizzle-orm"
import { requireAuth } from "@/shared/lib/auth-guard"
import { db } from "@/shared/db"
import { parentStudentRelations } from "@/shared/db/schema"
import { getChildDashboardData } from "@/modules/parent/data-access"
import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header"
import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ShieldAlert } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function ChildDetailPage({
params,
}: {
params: Promise<{ studentId: string }>
}) {
const { studentId } = await params
const ctx = await requireAuth()
// Verify the student is linked to the current parent
const [relation] = await db
.select({
id: parentStudentRelations.id,
relation: parentStudentRelations.relation,
})
.from(parentStudentRelations)
.where(eq(parentStudentRelations.studentId, studentId))
.limit(1)
if (!relation) {
return (
<div className="p-6 md:p-8">
<EmptyState
icon={ShieldAlert}
title="Access denied"
description="This student is not linked to your account. Please contact the school administrator if you believe this is an error."
className="border-none shadow-none"
/>
</div>
)
}
// Double-check the parent owns this relation
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
return (
<div className="p-6 md:p-8">
<EmptyState
icon={ShieldAlert}
title="Access denied"
description="You do not have permission to view this student's data."
className="border-none shadow-none"
/>
</div>
)
}
const child = await getChildDashboardData(studentId, relation.relation)
if (!child) {
notFound()
}
return (
<div className="p-6 md:p-8 space-y-6">
<ChildDetailHeader child={child} />
<ChildDetailPanel child={child} />
</div>
)
}

View File

@@ -1,8 +1,16 @@
export default function ParentDashboardPage() {
import { requireAuth } from "@/shared/lib/auth-guard"
import { getParentDashboardData } from "@/modules/parent/data-access"
import { ParentDashboard } from "@/modules/parent/components/parent-dashboard"
export const dynamic = "force-dynamic"
export default async function ParentDashboardPage() {
const ctx = await requireAuth()
const data = await getParentDashboardData(ctx.userId)
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Parent Dashboard</h1>
<p className="text-muted-foreground">Welcome, Parent!</p>
<div className="p-6 md:p-8">
<ParentDashboard data={data} />
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getStudentGradeSummary } from "@/modules/grades/data-access"
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { GraduationCap } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function ParentGradesPage() {
const ctx = await getAuthContext()
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Children Grades</h2>
<p className="text-muted-foreground">View your children&apos;s grade records.</p>
</div>
<EmptyState
title="No children linked"
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
icon={GraduationCap}
className="border-none shadow-none"
/>
</div>
)
}
const summaries = await Promise.all(
ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id))
)
const validSummaries = summaries.filter((s): s is NonNullable<typeof s> => s !== null)
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Children Grades</h2>
<p className="text-muted-foreground">View your children&apos;s grade records.</p>
</div>
{validSummaries.length === 0 ? (
<EmptyState
title="No grade records"
description="Your children don&apos;t have any grade records yet."
icon={GraduationCap}
className="border-none shadow-none"
/>
) : (
<div className="space-y-8">
{validSummaries.map((summary) => (
<div key={summary.studentId} className="space-y-4">
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
<StudentGradeSummary summary={summary} />
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { AdminSettingsView } from "@/modules/settings/components/admin-settings-
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
import { getUserProfile } from "@/modules/users/data-access"
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
import { Permissions } from "@/shared/types/permissions"
export const dynamic = "force-dynamic"
@@ -19,8 +20,13 @@ export default async function SettingsPage() {
if (!userProfile) redirect("/login")
const permissions = session.user.permissions ?? []
const notificationPrefs = await getNotificationPreferences(userId)
if (permissions.includes(Permissions.SETTINGS_ADMIN)) return <AdminSettingsView user={userProfile} />
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) return <StudentSettingsView user={userProfile} />
return <TeacherSettingsView user={userProfile} />
if (permissions.includes(Permissions.SETTINGS_ADMIN)) {
return <AdminSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
}
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) {
return <StudentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
}
return <TeacherSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
}

View File

@@ -0,0 +1,50 @@
import { redirect } from "next/navigation"
import { Lock } from "lucide-react"
import { auth } from "@/auth"
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export const dynamic = "force-dynamic"
export const metadata = {
title: "Security Settings",
}
export default async function SecuritySettingsPage() {
const session = await auth()
if (!session?.user) redirect("/login")
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Lock className="h-7 w-7 text-muted-foreground" />
<h1 className="text-3xl font-bold tracking-tight">Security</h1>
</div>
<div className="text-sm text-muted-foreground">
Manage your password and account security settings.
</div>
</div>
<div className="max-w-2xl space-y-6">
<PasswordChangeForm />
<Card>
<CardHeader>
<CardTitle>Security Tips</CardTitle>
<CardDescription>Best practices to keep your account safe.</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>Use a unique password that you don&apos;t reuse across other sites.</li>
<li>Avoid common words, names, or sequential patterns.</li>
<li>Change your password periodically.</li>
<li>Your account will be temporarily locked after multiple failed login attempts.</li>
</ul>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { CalendarCheck } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function StudentAttendancePage() {
const ctx = await getAuthContext()
const summary = await getStudentAttendanceSummary(ctx.userId)
if (!summary) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Attendance</h2>
<p className="text-muted-foreground">View your attendance records.</p>
</div>
<EmptyState
title="No user found"
description="Unable to load your student profile."
icon={CalendarCheck}
className="border-none shadow-none"
/>
</div>
)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Attendance</h2>
<p className="text-muted-foreground">View your attendance records and statistics.</p>
</div>
<StudentAttendanceView summary={summary} />
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getStudentGradeSummary } from "@/modules/grades/data-access"
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { GraduationCap } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function StudentGradesPage() {
const ctx = await getAuthContext()
const summary = await getStudentGradeSummary(ctx.userId)
if (!summary) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
<p className="text-muted-foreground">View your grade records.</p>
</div>
<EmptyState
title="No user found"
description="Unable to load your student profile."
icon={GraduationCap}
className="border-none shadow-none"
/>
</div>
)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
<p className="text-muted-foreground">View your grade records.</p>
</div>
<StudentGradeSummary summary={summary} />
</div>
)
}

View File

@@ -0,0 +1,83 @@
import Link from "next/link"
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getAttendanceRecords } from "@/modules/attendance/data-access"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function TeacherAttendancePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const ctx = await getAuthContext()
const classId = getParam(sp, "classId")
const status = getParam(sp, "status")
const date = getParam(sp, "date")
const classes = await getTeacherClasses()
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const result = await getAttendanceRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
status: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined,
date: date && date.length > 0 ? date : undefined,
})
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Attendance</h2>
<p className="text-muted-foreground">Manage student attendance records.</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/attendance/stats">
<BarChart3 className="mr-2 h-4 w-4" />
Statistics
</Link>
</Button>
<Button asChild>
<Link href="/teacher/attendance/sheet">
<PlusCircle className="mr-2 h-4 w-4" />
Take Attendance
</Link>
</Button>
</div>
</div>
<AttendanceFilters classes={classOptions} />
{result.items.length === 0 && !classId && !status && !date ? (
<EmptyState
title="No attendance records"
description="Start by taking attendance for your classes."
icon={ClipboardList}
action={{
label: "Take Attendance",
href: "/teacher/attendance/sheet",
}}
/>
) : (
<AttendanceRecordList records={result.items} />
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassStudentsForAttendance } from "@/modules/attendance/data-access"
import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function AttendanceSheetPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const defaultClassId = getParam(sp, "classId")
const defaultDate = getParam(sp, "date")
const classes = await getTeacherClasses()
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
let students: Array<{ id: string; name: string; email: string }> = []
if (defaultClassId) {
students = await getClassStudentsForAttendance(defaultClassId)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Take Attendance</h2>
<p className="text-muted-foreground">
Select a class and date, then mark attendance for each student.
</p>
</div>
<AttendanceSheet
classes={classOptions}
students={students}
defaultClassId={defaultClassId}
defaultDate={defaultDate}
/>
</div>
)
}

View File

@@ -0,0 +1,120 @@
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { BarChart3 } from "lucide-react"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function AttendanceStatsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const classId = getParam(sp, "classId")
const startDate = getParam(sp, "startDate")
const endDate = getParam(sp, "endDate")
const classes = await getTeacherClasses()
if (classes.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
<p className="text-muted-foreground">View class attendance statistics.</p>
</div>
<EmptyState
title="No classes"
description="You don't have any classes yet."
icon={BarChart3}
className="border-none shadow-none"
/>
</div>
)
}
const targetClassId = classId ?? classes[0].id
const summary = await getClassAttendanceStats(
targetClassId,
startDate,
endDate
)
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
<p className="text-muted-foreground">View class attendance statistics and trends.</p>
</div>
<StatsClassSelector
classes={classOptions}
currentClassId={targetClassId}
startDate={startDate ?? ""}
endDate={endDate ?? ""}
/>
{summary ? (
<>
<AttendanceStatsCard stats={summary.stats} />
<div>
<h3 className="mb-4 text-lg font-semibold">Student Records</h3>
<AttendanceRecordList records={summary.studentRecords} />
</div>
</>
) : (
<EmptyState
title="No data"
description="No attendance data available for this class."
icon={BarChart3}
className="border-none shadow-none"
/>
)}
</div>
)
}
function StatsClassSelector({
classes,
currentClassId,
startDate,
endDate,
}: {
classes: Array<{ id: string; name: string }>
currentClassId: string
startDate: string
endDate: string
}) {
const dateParams = `${startDate ? `&startDate=${startDate}` : ""}${endDate ? `&endDate=${endDate}` : ""}`
return (
<div className="flex flex-wrap gap-2">
{classes.map((c) => (
<a
key={c.id}
href={`/teacher/attendance/stats?classId=${c.id}${dateParams}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
c.id === currentClassId
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
{c.name}
</a>
))}
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { notFound } from "next/navigation"
import { getCoursePlanById } from "@/modules/course-plans/data-access"
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
export const dynamic = "force-dynamic"
export default async function TeacherCoursePlanDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const plan = await getCoursePlanById(id)
if (!plan) notFound()
return (
<div className="flex h-full flex-col space-y-6 p-8">
<CoursePlanDetail
plan={plan}
backHref="/teacher/course-plans"
/>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { auth } from "@/auth"
import { getCoursePlans } from "@/modules/course-plans/data-access"
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
import type { CoursePlanStatus } from "@/modules/course-plans/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const isValidStatus = (v?: string): v is CoursePlanStatus =>
v === "planning" || v === "active" || v === "completed" || v === "paused"
export default async function TeacherCoursePlansPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const session = await auth()
const teacherId = String(session?.user?.id ?? "")
const sp = await searchParams
const statusParam = getParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const plans = teacherId
? await getCoursePlans({ teacherId, status })
: []
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">My Course Plans</h2>
<p className="text-muted-foreground">
View your course teaching plans and weekly schedules.
</p>
</div>
<CoursePlanList
plans={plans}
detailHrefBuilder={(id) => `/teacher/course-plans/${id}`}
initialStatus={status}
/>
</div>
)
}

View File

@@ -0,0 +1,259 @@
import Link from "next/link"
import { BarChart3, ArrowLeft } from "lucide-react"
import { asc } from "drizzle-orm"
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGrades } from "@/modules/school/data-access"
import {
getClassComparison,
getGradeDistribution,
getGradeTrend,
getSubjectComparison,
} from "@/modules/grades/data-access-analytics"
import { GradeTrendChart } from "@/modules/grades/components/grade-trend-chart"
import { ClassComparisonChart } from "@/modules/grades/components/class-comparison-chart"
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function GradeAnalyticsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const ctx = await getAuthContext()
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")
const gradeId = getParam(sp, "gradeId")
const [classes, allGrades, allSubjects] = await Promise.all([
getTeacherClasses(),
getGrades(),
db.query.subjects.findMany({
orderBy: [asc(subjects.order), asc(subjects.name)],
}),
])
if (classes.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
<p className="text-muted-foreground">
Trend analysis, class comparisons, and score distributions.
</p>
</div>
<EmptyState
title="No classes"
description="You don't have any classes yet."
icon={BarChart3}
className="border-none shadow-none"
/>
</div>
)
}
const targetClassId = classId ?? classes[0].id
const targetSubjectId =
subjectId && subjectId !== "all" ? subjectId : undefined
const targetGradeId = gradeId ?? allGrades[0]?.id
// Run analytics queries in parallel
const [trend, distribution, subjectComparison, classComparison] =
await Promise.all([
getGradeTrend({
classId: targetClassId,
subjectId: targetSubjectId,
scope: ctx.dataScope,
currentUserId: ctx.userId,
}),
getGradeDistribution({
classId: targetClassId,
subjectId: targetSubjectId,
scope: ctx.dataScope,
currentUserId: ctx.userId,
}),
getSubjectComparison({
classId: targetClassId,
scope: ctx.dataScope,
}),
targetGradeId
? getClassComparison({
gradeId: targetGradeId,
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
scope: ctx.dataScope,
})
: Promise.resolve([]),
])
return (
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
<p className="text-muted-foreground">
Trend analysis, class comparisons, and score distributions.
</p>
</div>
<Button asChild variant="outline">
<Link href="/teacher/grades">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Grades
</Link>
</Button>
</div>
<AnalyticsFilters
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
currentClassId={targetClassId}
currentSubjectId={subjectId ?? "all"}
currentGradeId={targetGradeId ?? ""}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<GradeTrendChart data={trend} />
<GradeDistributionChart data={distribution} />
<SubjectComparisonChart data={subjectComparison} />
<ClassComparisonChart data={classComparison} />
</div>
</div>
)
}
interface AnalyticsFiltersProps {
classes: Array<{ id: string; name: string }>
grades: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
currentClassId: string
currentSubjectId: string
currentGradeId: string
}
function AnalyticsFilters({
classes,
grades,
subjects,
currentClassId,
currentSubjectId,
currentGradeId,
}: AnalyticsFiltersProps) {
const buildHref = (overrides: {
classId?: string
subjectId?: string
gradeId?: string
}) => {
const params = new URLSearchParams()
params.set(
"classId",
overrides.classId !== undefined ? overrides.classId : currentClassId
)
params.set(
"subjectId",
overrides.subjectId !== undefined ? overrides.subjectId : currentSubjectId
)
if (
overrides.gradeId !== undefined
? overrides.gradeId
: currentGradeId
) {
params.set(
"gradeId",
overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId
)
}
return `/teacher/grades/analytics?${params.toString()}`
}
return (
<div className="rounded-lg border bg-card p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Class</div>
<div className="flex flex-wrap gap-1.5">
{classes.map((c) => (
<a
key={c.id}
href={buildHref({ classId: c.id })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
c.id === currentClassId
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
{c.name}
</a>
))}
</div>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Subject</div>
<div className="flex flex-wrap gap-1.5">
<a
href={buildHref({ subjectId: "all" })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
currentSubjectId === "all"
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
All
</a>
{subjects.map((s) => (
<a
key={s.id}
href={buildHref({ subjectId: s.id })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
s.id === currentSubjectId
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
{s.name}
</a>
))}
</div>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Grade (for class comparison)
</div>
<div className="flex flex-wrap gap-1.5">
{grades.map((g) => (
<a
key={g.id}
href={buildHref({ gradeId: g.id })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
g.id === currentGradeId
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
{g.name}
</a>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { asc } from "drizzle-orm"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassStudentsForEntry } from "@/modules/grades/data-access"
import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function BatchEntryPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const defaultClassId = getParam(sp, "classId")
const defaultSubjectId = getParam(sp, "subjectId")
const [classes, allSubjects] = await Promise.all([
getTeacherClasses(),
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
let students: Array<{ id: string; name: string; email: string }> = []
if (defaultClassId) {
students = await getClassStudentsForEntry(defaultClassId)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h2>
<p className="text-muted-foreground">Enter grades for all students in a class at once.</p>
</div>
<BatchGradeEntry
classes={classOptions}
subjects={subjectOptions}
students={students}
defaultClassId={defaultClassId}
defaultSubjectId={defaultSubjectId}
/>
</div>
)
}

View File

@@ -0,0 +1,101 @@
import Link from "next/link"
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { asc } from "drizzle-orm"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGradeRecords } from "@/modules/grades/data-access"
import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters"
import { GradeRecordList } from "@/modules/grades/components/grade-record-list"
import { ExportButton } from "@/modules/grades/components/export-button"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function TeacherGradesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const ctx = await getAuthContext()
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")
const type = getParam(sp, "type")
const semester = getParam(sp, "semester")
const [classes, allSubjects] = await Promise.all([
getTeacherClasses(),
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
])
const records = await getGradeRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
type: type && type !== "all" ? (type as "exam" | "quiz" | "homework" | "other") : undefined,
semester: semester && semester !== "all" ? (semester as "1" | "2") : undefined,
})
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grades</h2>
<p className="text-muted-foreground">Manage student grade records.</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/grades/stats">
<BarChart3 className="mr-2 h-4 w-4" />
Statistics
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/teacher/grades/entry">
<ClipboardList className="mr-2 h-4 w-4" />
Batch Entry
</Link>
</Button>
<ExportButton
classId={classId && classId !== "all" ? classId : ""}
subjectId={subjectId && subjectId !== "all" ? subjectId : undefined}
variant="outline"
/>
<Button asChild>
<Link href="/teacher/grades/entry">
<PlusCircle className="mr-2 h-4 w-4" />
Record Grades
</Link>
</Button>
</div>
</div>
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
{records.length === 0 && !classId && !subjectId ? (
<EmptyState
title="No grade records"
description="Start by recording grades for your classes."
icon={ClipboardList}
action={{
label: "Record Grades",
href: "/teacher/grades/entry",
}}
/>
) : (
<GradeRecordList records={records} />
)}
</div>
)
}

View File

@@ -0,0 +1,139 @@
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { asc } from "drizzle-orm"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassGradeStatsWithMeta, getClassRanking } from "@/modules/grades/data-access"
import { ClassGradeReport } from "@/modules/grades/components/class-grade-report"
import { ExportButton } from "@/modules/grades/components/export-button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { BarChart3 } from "lucide-react"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function StatsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")
const [classes, allSubjects] = await Promise.all([
getTeacherClasses(),
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
])
if (classes.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
</div>
<EmptyState
title="No classes"
description="You don't have any classes yet."
icon={BarChart3}
className="border-none shadow-none"
/>
</div>
)
}
const targetClassId = classId ?? classes[0].id
const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined
const [stats, ranking] = await Promise.all([
getClassGradeStatsWithMeta(targetClassId, targetSubjectId),
getClassRanking(targetClassId, targetSubjectId),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
</div>
<ExportButton
classId={targetClassId}
subjectId={targetSubjectId}
variant="outline"
label="导出成绩"
/>
</div>
<StatsClassSelector
classes={classOptions}
subjects={subjectOptions}
currentClassId={targetClassId}
currentSubjectId={subjectId ?? "all"}
/>
<ClassGradeReport stats={stats} ranking={ranking} />
</div>
)
}
function StatsClassSelector({
classes,
subjects,
currentClassId,
currentSubjectId,
}: {
classes: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
currentClassId: string
currentSubjectId: string
}) {
return (
<div className="flex flex-wrap gap-2">
{classes.map((c) => (
<a
key={c.id}
href={`/teacher/grades/stats?classId=${c.id}${currentSubjectId !== "all" ? `&subjectId=${currentSubjectId}` : ""}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
c.id === currentClassId
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
{c.name}
</a>
))}
<div className="ml-auto flex flex-wrap gap-2">
<a
href={`/teacher/grades/stats?classId=${currentClassId}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
currentSubjectId === "all"
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
All Subjects
</a>
{subjects.map((s) => (
<a
key={s.id}
href={`/teacher/grades/stats?classId=${currentClassId}&subjectId=${s.id}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
s.id === currentSubjectId
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
{s.name}
</a>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { ClipboardList } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import {
getAdminClassesForScheduling,
getScheduleChanges,
getTeachersForScheduling,
} from "@/modules/scheduling/actions"
import { ScheduleChangeForm } from "@/modules/scheduling/components/schedule-change-form"
import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list"
export const dynamic = "force-dynamic"
export default async function TeacherScheduleChangesPage() {
const ctx = await getAuthContext()
// Teachers see only their own requests; admins landing here see all.
const requesterId = ctx.roles.includes("admin") ? undefined : ctx.userId
const [classes, teachers, items] = await Promise.all([
getAdminClassesForScheduling(),
getTeachersForScheduling(),
getScheduleChanges({ requesterId }),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
const teacherOptions = teachers.map((t) => ({
id: t.id,
name: t.name ?? "Unknown",
email: t.email ?? "",
}))
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Schedule Change Requests</h2>
<p className="text-muted-foreground">
Submit a schedule change or substitute teacher request, and track its status.
</p>
</div>
{classOptions.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="No classes available"
description="There are no classes available to request schedule changes for."
/>
) : (
<div className="space-y-8">
<ScheduleChangeForm classes={classOptions} teachers={teacherOptions} />
<div className="space-y-2">
<h3 className="text-lg font-semibold">My Requests</h3>
{items.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="No requests yet"
description="Your submitted schedule change requests will appear here."
/>
) : (
<ScheduleChangeList items={items} canApprove={false} />
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai"
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
export const dynamic = "force-dynamic"
@@ -16,11 +17,24 @@ export async function POST(req: Request) {
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
// Rate limit AI chat per user
const limitKey = rateLimitKey("ai-chat", userId)
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT })
if (!limit.success) {
return NextResponse.json(
{ success: false, message: "Rate limit exceeded. Please slow down." },
{ status: 429, headers: rateLimitHeaders(limit) }
)
}
try {
const body = await req.json().catch(() => null)
const input = parseAiChatPayload(body)
const result = await createAiChatCompletion(input)
return NextResponse.json({ success: true, content: result.content, usage: result.usage })
return NextResponse.json(
{ success: true, content: result.content, usage: result.usage },
{ headers: rateLimitHeaders(limit) }
)
} catch (e) {
const message = getAiErrorMessage(e)
return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) })

135
src/app/api/export/route.ts Normal file
View File

@@ -0,0 +1,135 @@
import { NextResponse } from "next/server"
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { exportUsersToExcel } from "@/modules/users/import-export"
import { exportGradeRecordsToExcel } from "@/modules/grades/export"
import {
exportAuditLogsAction,
exportDataChangeLogsAction,
exportLoginLogsAction,
} from "@/modules/audit/actions"
export const dynamic = "force-dynamic"
type ExportType = "grades" | "users" | "attendance" | "audit" | "login" | "dataChange"
const isExportType = (v: string): v is ExportType =>
v === "grades" || v === "users" || v === "attendance" || v === "audit" || v === "login" || v === "dataChange"
export async function POST(req: Request) {
try {
const ctx = await requireAuth()
const body = (await req.json()) as {
type?: string
params?: Record<string, unknown>
}
const type = body.type ?? ""
if (!isExportType(type)) {
return NextResponse.json(
{ success: false, message: "Invalid export type" },
{ status: 400 }
)
}
const params = body.params ?? {}
let buffer: Buffer
let filename: string
if (type === "grades") {
buffer = await exportGradeRecordsToExcel({
classId: String(params.classId ?? ""),
subjectId: params.subjectId ? String(params.subjectId) : undefined,
examId: params.examId ? String(params.examId) : undefined,
scope: ctx.dataScope,
})
filename = `grades_export_${formatDateForFile()}.xlsx`
} else if (type === "users") {
buffer = await exportUsersToExcel({
scope: ctx.dataScope,
role: params.role ? String(params.role) : undefined,
})
filename = `users_export_${formatDateForFile()}.xlsx`
} else if (type === "audit" || type === "login" || type === "dataChange") {
// Audit-related exports require AUDIT_LOG_READ permission
try {
await requirePermission(Permissions.AUDIT_LOG_READ)
} catch (e) {
if (e instanceof PermissionDeniedError) {
return NextResponse.json(
{ success: false, message: e.message },
{ status: 403 }
)
}
throw e
}
const stringParams = Object.fromEntries(
Object.entries(params).filter(([, v]) => typeof v === "string")
) as Record<string, string>
if (type === "audit") {
const result = await exportAuditLogsAction(stringParams)
if (!result.success || !result.data) {
return NextResponse.json(
{ success: false, message: result.message ?? "Export failed" },
{ status: 500 }
)
}
buffer = result.data.buffer
filename = result.data.filename
} else if (type === "login") {
const result = await exportLoginLogsAction(stringParams)
if (!result.success || !result.data) {
return NextResponse.json(
{ success: false, message: result.message ?? "Export failed" },
{ status: 500 }
)
}
buffer = result.data.buffer
filename = result.data.filename
} else {
const result = await exportDataChangeLogsAction(stringParams)
if (!result.success || !result.data) {
return NextResponse.json(
{ success: false, message: result.message ?? "Export failed" },
{ status: 500 }
)
}
buffer = result.data.buffer
filename = result.data.filename
}
} else {
return NextResponse.json(
{ success: false, message: "Attendance export not implemented" },
{ status: 400 }
)
}
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
"Content-Type":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(buffer.length),
},
})
} catch (e) {
const message = e instanceof Error ? e.message : "Export failed"
const status = message.includes("Permission denied") || message.includes("auth_required")
? 401
: 500
return NextResponse.json({ success: false, message }, { status })
}
}
function formatDateForFile(): string {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, "0")
const d = String(now.getDate()).padStart(2, "0")
return `${y}-${m}-${d}`
}

View File

@@ -0,0 +1,87 @@
import { NextResponse } from "next/server"
import { unlink } from "fs/promises"
import path from "path"
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import {
deleteFileAttachment,
getFileAttachment,
} from "@/modules/files/data-access"
export const dynamic = "force-dynamic"
interface RouteContext {
params: Promise<{ id: string }>
}
/**
* GET /api/files/[id]
* 获取文件信息(需要登录)
*/
export async function GET(_req: Request, ctx: RouteContext) {
try {
await requireAuth()
const { id } = await ctx.params
const file = await getFileAttachment(id)
if (!file) {
return NextResponse.json(
{ success: false, message: "File not found" },
{ status: 404 }
)
}
return NextResponse.json({ success: true, file })
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to fetch file"
const status = message.includes("auth_required") ? 401 : 500
return NextResponse.json({ success: false, message }, { status })
}
}
/**
* DELETE /api/files/[id]
* 删除文件(需要 FILE_DELETE 权限)
*/
export async function DELETE(_req: Request, ctx: RouteContext) {
try {
await requirePermission(Permissions.FILE_DELETE)
const { id } = await ctx.params
const file = await getFileAttachment(id)
if (!file) {
return NextResponse.json(
{ success: false, message: "File not found" },
{ status: 404 }
)
}
// 删除磁盘文件(静默失败,记录仍会被删除)
const absolutePath = path.join(process.cwd(), "public", file.storagePath)
try {
await unlink(absolutePath)
} catch {
// 文件可能已不存在,忽略错误
}
const ok = await deleteFileAttachment(id)
if (!ok) {
return NextResponse.json(
{ success: false, message: "Failed to delete file record" },
{ status: 500 }
)
}
return NextResponse.json({ success: true, message: "File deleted" })
} catch (e) {
if (e instanceof PermissionDeniedError) {
return NextResponse.json(
{ success: false, message: e.message },
{ status: 403 }
)
}
const message = e instanceof Error ? e.message : "Failed to delete file"
return NextResponse.json({ success: false, message }, { status: 500 })
}
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from "next/server"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { storageProvider } from "@/shared/lib/storage-provider"
import {
deleteFileAttachments,
getFileAttachmentsByIds,
} from "@/modules/files/data-access"
export const dynamic = "force-dynamic"
/**
* POST /api/files/batch-delete
* 批量删除文件(需要 FILE_DELETE 权限)
* Body: { ids: string[] }
*/
export async function POST(req: Request) {
try {
await requirePermission(Permissions.FILE_DELETE)
const body = (await req.json().catch(() => null)) as { ids?: unknown } | null
const ids = Array.isArray(body?.ids) ? body!.ids.filter((x): x is string => typeof x === "string") : []
if (ids.length === 0) {
return NextResponse.json(
{ success: false, message: "No file ids provided" },
{ status: 400 }
)
}
// 先查出文件记录,以便删除磁盘文件
const files = await getFileAttachmentsByIds(ids)
// 删除磁盘文件(静默失败)
await Promise.all(
files.map((f) => storageProvider.delete(f.storagePath).catch(() => undefined))
)
const result = await deleteFileAttachments(ids)
return NextResponse.json({
success: result.success,
message: `Deleted ${result.deletedCount} file(s)`,
deletedCount: result.deletedCount,
failedIds: result.failedIds,
})
} catch (e) {
if (e instanceof PermissionDeniedError) {
return NextResponse.json(
{ success: false, message: e.message },
{ status: 403 }
)
}
const message = e instanceof Error ? e.message : "Failed to batch delete files"
return NextResponse.json({ success: false, message }, { status: 500 })
}
}

View File

@@ -0,0 +1,62 @@
import { NextResponse } from "next/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { parseExcel } from "@/shared/lib/excel"
export const dynamic = "force-dynamic"
const MAX_FILE_SIZE = 10 * 1024 * 1024
export async function POST(req: Request) {
try {
await requirePermission(Permissions.USER_MANAGE)
const formData = await req.formData()
const file = formData.get("file")
if (!(file instanceof File)) {
return NextResponse.json(
{ success: false, message: "No file provided" },
{ status: 400 }
)
}
if (file.size === 0) {
return NextResponse.json(
{ success: false, message: "File is empty" },
{ status: 400 }
)
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ success: false, message: "File size exceeds 10MB limit" },
{ status: 413 }
)
}
const lowerName = file.name.toLowerCase()
if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) {
return NextResponse.json(
{ success: false, message: "Only .xlsx and .xls files are supported" },
{ status: 415 }
)
}
const bytes = Buffer.from(await file.arrayBuffer())
const sheets = await parseExcel(bytes)
return NextResponse.json({
success: true,
sheets,
fileName: file.name,
})
} catch (e) {
const message = e instanceof Error ? e.message : "Import failed"
const status = message.includes("Permission denied") || message.includes("auth_required")
? 401
: 500
return NextResponse.json({ success: false, message }, { status })
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
export const dynamic = "force-dynamic"
/**
* Test endpoint for the in-memory rate limiter.
* Applies a strict 5-requests-per-minute limit per user so you can
* observe 429 responses quickly during manual testing.
*
* Usage:
* curl -X GET http://localhost:3000/api/rate-limit-test \
* -H "Cookie: next-auth.session-token=<token>"
*/
export async function GET() {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) {
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
}
const limitKey = rateLimitKey("test", userId)
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE })
if (!limit.success) {
return NextResponse.json(
{
success: false,
message: "Rate limit exceeded",
remaining: limit.remaining,
retryAfterMs: limit.retryAfterMs,
},
{ status: 429, headers: rateLimitHeaders(limit) }
)
}
return NextResponse.json(
{
success: true,
message: "Request allowed",
remaining: limit.remaining,
resetTime: new Date(limit.resetTime).toISOString(),
},
{ headers: rateLimitHeaders(limit) }
)
}

275
src/app/api/search/route.ts Normal file
View File

@@ -0,0 +1,275 @@
import { NextResponse } from "next/server"
import { and, desc, eq, like, or, sql } from "drizzle-orm"
import { requireAuth } from "@/shared/lib/auth-guard"
import { db } from "@/shared/db"
import {
announcements,
exams,
questions,
textbooks,
} from "@/shared/db/schema"
export const dynamic = "force-dynamic"
type SearchType = "all" | "question" | "textbook" | "exam" | "announcement"
const isSearchType = (v: string): v is SearchType =>
v === "all" || v === "question" || v === "textbook" || v === "exam" || v === "announcement"
const DEFAULT_PAGE_SIZE = 10
interface SearchResultItem {
id: string
title: string
snippet: string
type: "question" | "textbook" | "exam" | "announcement"
href: string
createdAt: string
}
interface SearchResponse {
success: boolean
query: string
type: SearchType
results: SearchResultItem[]
total: number
page: number
pageSize: number
}
/**
* GET /api/search?q=keyword&type=all&page=1
* 全文检索questions / textbooks / exams / announcements
*/
export async function GET(req: Request) {
try {
await requireAuth()
const { searchParams } = new URL(req.url)
const q = (searchParams.get("q") ?? "").trim()
const typeRaw = (searchParams.get("type") ?? "all").trim()
const type: SearchType = isSearchType(typeRaw) ? typeRaw : "all"
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1)
const pageSize = Math.min(
50,
Math.max(1, Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)) || DEFAULT_PAGE_SIZE)
)
if (!q) {
return NextResponse.json<SearchResponse>({
success: true,
query: q,
type,
results: [],
total: 0,
page,
pageSize,
})
}
const kw = `%${q}%`
const offset = (page - 1) * pageSize
const results: SearchResultItem[] = []
// 并行查询各类型
const tasks: Promise<SearchResultItem[]>[] = []
if (type === "all" || type === "question") {
tasks.push(searchQuestions(kw, pageSize))
}
if (type === "all" || type === "textbook") {
tasks.push(searchTextbooks(kw, pageSize))
}
if (type === "all" || type === "exam") {
tasks.push(searchExams(kw, pageSize))
}
if (type === "all" || type === "announcement") {
tasks.push(searchAnnouncements(kw, pageSize))
}
const grouped = await Promise.all(tasks)
for (const group of grouped) {
results.push(...group)
}
// 按 createdAt 降序排序后分页
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
const total = results.length
const paged = results.slice(offset, offset + pageSize)
return NextResponse.json<SearchResponse>({
success: true,
query: q,
type,
results: paged,
total,
page,
pageSize,
})
} catch (e) {
const message = e instanceof Error ? e.message : "Search failed"
const status = message.includes("auth_required") ? 401 : 500
return NextResponse.json({ success: false, message }, { status })
}
}
async function searchQuestions(kw: string, limit: number): Promise<SearchResultItem[]> {
try {
const rows = await db
.select({
id: questions.id,
content: questions.content,
type: questions.type,
createdAt: questions.createdAt,
})
.from(questions)
.where(
or(
// JSON 内容字段转换为文本进行模糊匹配
sql`CAST(${questions.content} AS CHAR) LIKE ${kw}`,
like(sql`CAST(${questions.content} AS CHAR)`, kw)
)!
)
.orderBy(desc(questions.createdAt))
.limit(limit)
return rows.map((r) => {
const text = extractTextFromJson(r.content)
return {
id: r.id,
title: truncate(text, 80) || `Question (${r.type})`,
snippet: truncate(text, 200),
type: "question" as const,
href: `/admin/questions?id=${r.id}`,
createdAt: r.createdAt.toISOString(),
}
})
} catch {
return []
}
}
async function searchTextbooks(kw: string, limit: number): Promise<SearchResultItem[]> {
try {
const rows = await db
.select({
id: textbooks.id,
title: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
publisher: textbooks.publisher,
createdAt: textbooks.createdAt,
})
.from(textbooks)
.where(
or(
like(textbooks.title, kw),
like(textbooks.subject, kw),
like(textbooks.publisher, kw)
)!
)
.orderBy(desc(textbooks.createdAt))
.limit(limit)
return rows.map((r) => ({
id: r.id,
title: r.title,
snippet: [r.subject, r.grade, r.publisher].filter(Boolean).join(" · "),
type: "textbook" as const,
href: `/admin/textbooks?id=${r.id}`,
createdAt: r.createdAt.toISOString(),
}))
} catch {
return []
}
}
async function searchExams(kw: string, limit: number): Promise<SearchResultItem[]> {
try {
const rows = await db
.select({
id: exams.id,
title: exams.title,
description: exams.description,
status: exams.status,
createdAt: exams.createdAt,
})
.from(exams)
.where(
or(
like(exams.title, kw),
like(exams.description, kw)
)!
)
.orderBy(desc(exams.createdAt))
.limit(limit)
return rows.map((r) => ({
id: r.id,
title: r.title,
snippet: r.description ?? `Status: ${r.status ?? "draft"}`,
type: "exam" as const,
href: `/admin/exams?id=${r.id}`,
createdAt: r.createdAt.toISOString(),
}))
} catch {
return []
}
}
async function searchAnnouncements(kw: string, limit: number): Promise<SearchResultItem[]> {
try {
const rows = await db
.select({
id: announcements.id,
title: announcements.title,
content: announcements.content,
type: announcements.type,
status: announcements.status,
createdAt: announcements.createdAt,
})
.from(announcements)
.where(
and(
eq(announcements.status, "published"),
or(
like(announcements.title, kw),
like(announcements.content, kw)
)!
)
)
.orderBy(desc(announcements.createdAt))
.limit(limit)
return rows.map((r) => ({
id: r.id,
title: r.title,
snippet: truncate(stripHtml(r.content), 200),
type: "announcement" as const,
href: `/announcements/${r.id}`,
createdAt: r.createdAt.toISOString(),
}))
} catch {
return []
}
}
function extractTextFromJson(content: unknown): string {
if (typeof content === "string") return content
try {
return JSON.stringify(content)
} catch {
return ""
}
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
}
function truncate(s: string, max: number): string {
const t = s.trim()
if (t.length <= max) return t
return t.slice(0, max) + "..."
}

128
src/app/api/upload/route.ts Normal file
View File

@@ -0,0 +1,128 @@
import { NextResponse } from "next/server"
import { createId } from "@paralleldrive/cuid2"
import { mkdir, writeFile } from "fs/promises"
import path from "path"
import { requireAuth } from "@/shared/lib/auth-guard"
import {
generateStoragePath,
isAllowedMimeType,
MAX_FILE_SIZE,
} from "@/shared/lib/file-storage"
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
import { createFileAttachment } from "@/modules/files/data-access"
import type { FileTargetType } from "@/modules/files/types"
export const dynamic = "force-dynamic"
const VALID_TARGET_TYPES: FileTargetType[] = [
"exam",
"textbook",
"question",
"announcement",
]
const isTargetType = (v: string | null): v is FileTargetType =>
v !== null && (VALID_TARGET_TYPES as readonly string[]).includes(v)
export async function POST(req: Request) {
try {
const ctx = await requireAuth()
const uploaderId = ctx.userId
// Rate limit uploads per user
const limitKey = rateLimitKey("upload", uploaderId)
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.UPLOAD })
if (!limit.success) {
return NextResponse.json(
{ success: false, message: "Upload rate limit exceeded. Please try again later." },
{ status: 429, headers: rateLimitHeaders(limit) }
)
}
const formData = await req.formData()
const file = formData.get("file")
const targetType = formData.get("targetType")
const targetId = formData.get("targetId")
if (!(file instanceof File)) {
return NextResponse.json(
{ success: false, message: "No file provided" },
{ status: 400 }
)
}
if (file.size === 0) {
return NextResponse.json(
{ success: false, message: "File is empty" },
{ status: 400 }
)
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ success: false, message: "File size exceeds 10MB limit" },
{ status: 413 }
)
}
const mimeType = file.type || "application/octet-stream"
if (!isAllowedMimeType(mimeType)) {
return NextResponse.json(
{ success: false, message: `File type ${mimeType} is not allowed` },
{ status: 415 }
)
}
const originalName = file.name || "unnamed"
const storagePath = generateStoragePath(originalName)
const absoluteDir = path.join(process.cwd(), "public", path.dirname(storagePath))
await mkdir(absoluteDir, { recursive: true })
const bytes = Buffer.from(await file.arrayBuffer())
const absolutePath = path.join(process.cwd(), "public", storagePath)
await writeFile(absolutePath, bytes)
const url = `/${storagePath}`
const id = createId()
const filename = path.basename(storagePath)
const created = await createFileAttachment({
id,
filename,
originalName,
mimeType,
size: file.size,
storagePath,
url,
uploaderId,
targetType: isTargetType(typeof targetType === "string" ? targetType : null)
? (targetType as string)
: null,
targetId: typeof targetId === "string" && targetId.length > 0 ? targetId : null,
})
if (!created) {
return NextResponse.json(
{ success: false, message: "Failed to persist file record" },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
id: created.id,
url: created.url,
filename: created.filename,
originalName: created.originalName,
size: created.size,
mimeType: created.mimeType,
})
} catch (e) {
const message = e instanceof Error ? e.message : "Upload failed"
const status = message.includes("Permission denied") || message.includes("auth_required")
? 401
: 500
return NextResponse.json({ success: false, message }, { status })
}
}

View File

@@ -1,7 +1,14 @@
import { compare } from "bcryptjs"
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { eq } from "drizzle-orm"
import { resolvePermissions } from "@/shared/lib/permissions"
import { logLoginEvent } from "@/shared/lib/login-logger"
import {
PASSWORD_RULES,
isAccountLocked,
} from "@/shared/lib/password-policy"
import { RATE_LIMIT_RULES, rateLimit, rateLimitKey, resetRateLimit } from "@/shared/lib/rate-limit"
const normalizeRole = (value: unknown) => {
const role = String(value ?? "").trim().toLowerCase()
@@ -25,6 +32,103 @@ const normalizeBcryptHash = (value: string) => {
return `$2b$${value}`
}
/**
* Resolve the client IP from request headers (best-effort, used for
* rate-limit keying only — not stored).
*/
const resolveClientIp = async (): Promise<string> => {
try {
const { headers } = await import("next/headers")
const headerList = await headers()
return (
headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
headerList.get("x-real-ip") ??
"unknown"
)
} catch {
return "unknown"
}
}
/**
* Get or create a password_security row for a user.
*/
const getOrCreatePasswordSecurity = async (
db: typeof import("@/shared/db")["db"],
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
userId: string
) => {
const [existing] = await db
.select()
.from(passwordSecurity)
.where(eq(passwordSecurity.userId, userId))
.limit(1)
if (existing) return existing
const { createId } = await import("@paralleldrive/cuid2")
const id = createId()
await db.insert(passwordSecurity).values({
id,
userId,
failedLoginAttempts: 0,
})
const [created] = await db
.select()
.from(passwordSecurity)
.where(eq(passwordSecurity.userId, userId))
.limit(1)
return created
}
/**
* Increment failed login attempts and lock the account if the threshold
* is reached.
*/
const recordFailedLogin = async (
db: typeof import("@/shared/db")["db"],
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
userId: string
): Promise<{ locked: boolean; lockedUntil: Date | null }> => {
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
const nextAttempts = current.failedLoginAttempts + 1
const shouldLock = nextAttempts >= PASSWORD_RULES.maxLoginAttempts
const lockedUntil = shouldLock
? new Date(Date.now() + PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000)
: null
await db
.update(passwordSecurity)
.set({
failedLoginAttempts: nextAttempts,
lockedUntil,
updatedAt: new Date(),
})
.where(eq(passwordSecurity.userId, userId))
return { locked: shouldLock, lockedUntil }
}
/**
* Reset failed login attempts on successful login.
*/
const resetFailedLogin = async (
db: typeof import("@/shared/db")["db"],
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
userId: string
): Promise<void> => {
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
if (current.failedLoginAttempts === 0 && !current.lockedUntil) return
await db
.update(passwordSecurity)
.set({
failedLoginAttempts: 0,
lockedUntil: null,
updatedAt: new Date(),
})
.where(eq(passwordSecurity.userId, userId))
}
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
secret: process.env.NEXTAUTH_SECRET,
@@ -41,8 +145,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const password = String(credentials?.password ?? "")
if (!email || !password) return null
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
import("drizzle-orm"),
// Rate limit by IP + email to slow brute-force attempts
const clientIp = await resolveClientIp()
const loginLimitKey = rateLimitKey("login", `${clientIp}:${email}`)
const limit = rateLimit({
key: loginLimitKey,
...RATE_LIMIT_RULES.LOGIN,
})
if (!limit.success) {
await logLoginEvent({
userEmail: email,
action: "signin",
status: "failure",
errorMessage: "Rate limit exceeded",
})
return null
}
const [{ db }, { users, roles, usersToRoles, passwordSecurity }] = await Promise.all([
import("@/shared/db"),
import("@/shared/db/schema"),
])
@@ -52,12 +172,43 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
})
if (!user) return null
// Account lockout check
const security = await getOrCreatePasswordSecurity(db, passwordSecurity, user.id)
const lastFailedAt = security.lockedUntil
? new Date(security.lockedUntil.getTime() - PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000)
: null
if (isAccountLocked(security.failedLoginAttempts, lastFailedAt)) {
await logLoginEvent({
userId: user.id,
userEmail: email,
action: "signin",
status: "failure",
errorMessage: "Account locked",
})
return null
}
const storedPassword = user.password ?? null
if (!storedPassword) return null
const normalizedPassword = normalizeBcryptHash(storedPassword)
if (!normalizedPassword.startsWith("$2")) return null
const ok = await compare(password, normalizedPassword)
if (!ok) return null
if (!ok) {
await recordFailedLogin(db, passwordSecurity, user.id)
await logLoginEvent({
userId: user.id,
userEmail: email,
action: "signin",
status: "failure",
errorMessage: "Invalid credentials",
})
return null
}
// Successful login: reset counters and rate limit
await resetFailedLogin(db, passwordSecurity, user.id)
resetRateLimit(loginLimitKey)
const roleRows = await db
.select({ name: roles.name })
@@ -136,4 +287,33 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return session
},
},
events: {
async signIn({ user }) {
await logLoginEvent({
userId: user.id,
userEmail: user.email ?? "",
action: "signin",
status: "success",
})
},
async signOut(message) {
// NextAuth v5 signOut event receives the session/token info
const userId =
(message as { userId?: string })?.userId ??
(message as { token?: { id?: string } })?.token?.id ??
""
const userEmail =
(message as { token?: { email?: string } })?.token?.email ??
(message as { session?: { user?: { email?: string } } })?.session?.user?.email ??
""
if (userEmail) {
await logLoginEvent({
userId: userId || undefined,
userEmail,
action: "signout",
status: "success",
})
}
},
},
})

View File

@@ -0,0 +1,242 @@
"use server"
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { eq } from "drizzle-orm"
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import { announcements } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema"
import { getAnnouncements, getAnnouncementById } from "./data-access"
import type { GetAnnouncementsParams, Announcement } from "./types"
export async function createAnnouncementAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const parsed = CreateAnnouncementSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
type: formData.get("type") || undefined,
status: formData.get("status") || undefined,
targetGradeId: formData.get("targetGradeId") || undefined,
targetClassId: formData.get("targetClassId") || undefined,
publishedAt: formData.get("publishedAt") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const input = parsed.data
const isPublished = input.status === "published"
const publishedAt = isPublished
? input.publishedAt
? new Date(input.publishedAt)
: new Date()
: input.publishedAt
? new Date(input.publishedAt)
: null
const id = createId()
await db.insert(announcements).values({
id,
title: input.title,
content: input.content,
type: input.type,
status: input.status,
targetGradeId: input.targetGradeId,
targetClassId: input.targetClassId,
authorId: ctx.userId,
publishedAt,
})
revalidatePath("/admin/announcements")
revalidatePath("/announcements")
return { success: true, message: "Announcement created", data: id }
} catch (e) {
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" }
}
}
export async function updateAnnouncementAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: "Announcement not found" }
const parsed = UpdateAnnouncementSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
type: formData.get("type") || undefined,
status: formData.get("status") || undefined,
targetGradeId: formData.get("targetGradeId") || undefined,
targetClassId: formData.get("targetClassId") || undefined,
publishedAt: formData.get("publishedAt") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const input = parsed.data
const wasPublished = existing.status === "published"
const isPublished = input.status === "published"
const publishedAt = isPublished
? existing.publishedAt
? new Date(existing.publishedAt)
: new Date()
: input.publishedAt
? new Date(input.publishedAt)
: null
await db
.update(announcements)
.set({
title: input.title,
content: input.content,
type: input.type,
status: input.status,
targetGradeId: input.targetGradeId,
targetClassId: input.targetClassId,
publishedAt,
updatedAt: new Date(),
})
.where(eq(announcements.id, id))
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
void wasPublished
return { success: true, message: "Announcement updated", data: id }
} catch (e) {
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" }
}
}
export async function deleteAnnouncementAction(id: string): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: "Announcement not found" }
await db.delete(announcements).where(eq(announcements.id, id))
revalidatePath("/admin/announcements")
revalidatePath("/announcements")
return { success: true, message: "Announcement deleted" }
} catch (e) {
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" }
}
}
export async function publishAnnouncementAction(id: string): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: "Announcement not found" }
await db
.update(announcements)
.set({
status: "published",
publishedAt: existing.publishedAt ? new Date(existing.publishedAt) : new Date(),
updatedAt: new Date(),
})
.where(eq(announcements.id, id))
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
return { success: true, message: "Announcement published" }
} catch (e) {
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" }
}
}
export async function archiveAnnouncementAction(id: string): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: "Announcement not found" }
await db
.update(announcements)
.set({
status: "archived",
updatedAt: new Date(),
})
.where(eq(announcements.id, id))
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
return { success: true, message: "Announcement archived" }
} catch (e) {
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" }
}
}
export async function getAnnouncementsAction(
params?: GetAnnouncementsParams
): Promise<ActionState<Announcement[]>> {
try {
await requireAuth()
const data = await getAnnouncements(params)
return { success: true, data }
} catch (e) {
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" }
}
}

View File

@@ -0,0 +1,65 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { PlusCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { AnnouncementForm } from "./announcement-form"
import { AnnouncementList } from "./announcement-list"
import type { Announcement, AnnouncementStatus } from "../types"
export function AdminAnnouncementsView({
announcements,
grades = [],
classes = [],
initialStatus,
}: {
announcements: Announcement[]
grades?: { id: string; name: string }[]
classes?: { id: string; name: string }[]
initialStatus?: AnnouncementStatus
}) {
const router = useRouter()
const [createOpen, setCreateOpen] = useState(false)
const handleOpenChange = (open: boolean) => {
setCreateOpen(open)
if (!open) router.refresh()
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Announcements</h2>
<p className="text-muted-foreground">
Create and manage school-wide announcements.
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<PlusCircle className="mr-2 h-4 w-4" />
New Announcement
</Button>
</div>
<AnnouncementList
announcements={announcements}
canManage
initialStatus={initialStatus}
detailHrefBuilder={(id) => `/admin/announcements/${id}`}
/>
<Dialog open={createOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>New Announcement</DialogTitle>
</DialogHeader>
<AnnouncementForm mode="create" grades={grades} classes={classes} />
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import Link from "next/link"
import { useMemo } from "react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
import type { Announcement } from "../types"
const STATUS_LABEL: Record<Announcement["status"], string> = {
draft: "Draft",
published: "Published",
archived: "Archived",
}
const STATUS_VARIANT: Record<
Announcement["status"],
"default" | "secondary" | "outline"
> = {
draft: "secondary",
published: "default",
archived: "outline",
}
const TYPE_LABEL: Record<Announcement["type"], string> = {
school: "School",
grade: "Grade",
class: "Class",
}
export function AnnouncementCard({
announcement,
href,
}: {
announcement: Announcement
href?: string
}) {
const card = useMemo(
() => (
<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">{announcement.title}</CardTitle>
<Badge variant={STATUS_VARIANT[announcement.status]} className="shrink-0">
{STATUS_LABEL[announcement.status]}
</Badge>
</CardHeader>
<CardContent className="space-y-2">
<p className="line-clamp-3 text-sm text-muted-foreground whitespace-pre-wrap">
{announcement.content}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="capitalize">
{TYPE_LABEL[announcement.type]}
</Badge>
<span>
{announcement.publishedAt
? `Published ${formatDate(announcement.publishedAt)}`
: `Updated ${formatDate(announcement.updatedAt)}`}
</span>
{announcement.authorName ? (
<span className="ml-auto">by {announcement.authorName}</span>
) : null}
</div>
</CardContent>
</Card>
),
[announcement]
)
if (href) {
return (
<Link href={href} className="block h-full">
{card}
</Link>
)
}
return card
}

View File

@@ -0,0 +1,206 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
Archive,
ArrowLeft,
Megaphone,
Pencil,
Send,
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { formatDate } from "@/shared/lib/utils"
import {
archiveAnnouncementAction,
deleteAnnouncementAction,
publishAnnouncementAction,
} from "../actions"
import type { Announcement } from "../types"
const STATUS_LABEL: Record<Announcement["status"], string> = {
draft: "Draft",
published: "Published",
archived: "Archived",
}
const TYPE_LABEL: Record<Announcement["type"], string> = {
school: "School",
grade: "Grade",
class: "Class",
}
export function AnnouncementDetail({
announcement,
canManage,
editHref,
backHref,
}: {
announcement: Announcement
canManage?: boolean
editHref?: string
backHref?: string
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const handlePublish = async () => {
setIsWorking(true)
try {
const res = await publishAnnouncementAction(announcement.id)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to publish")
}
} catch {
toast.error("Failed to publish")
} finally {
setIsWorking(false)
}
}
const handleArchive = async () => {
setIsWorking(true)
try {
const res = await archiveAnnouncementAction(announcement.id)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to archive")
}
} catch {
toast.error("Failed to archive")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
setIsWorking(true)
try {
const res = await deleteAnnouncementAction(announcement.id)
if (res.success) {
toast.success(res.message)
router.push("/admin/announcements")
router.refresh()
} else {
toast.error(res.message || "Failed to delete")
}
} catch {
toast.error("Failed to delete")
} finally {
setIsWorking(false)
setDeleteOpen(false)
}
}
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">Announcement</h2>
</div>
{canManage ? (
<div className="flex flex-wrap items-center gap-2">
{announcement.status !== "published" ? (
<Button onClick={handlePublish} disabled={isWorking} variant="outline">
<Send className="mr-2 h-4 w-4" />
Publish
</Button>
) : null}
{announcement.status !== "archived" ? (
<Button onClick={handleArchive} disabled={isWorking} variant="outline">
<Archive className="mr-2 h-4 w-4" />
Archive
</Button>
) : null}
{editHref ? (
<Button asChild>
<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" className="capitalize">
{TYPE_LABEL[announcement.type]}
</Badge>
<Badge className="capitalize">{STATUS_LABEL[announcement.status]}</Badge>
</div>
<CardTitle className="text-2xl">{announcement.title}</CardTitle>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Megaphone className="h-3 w-3" />
<span>
{announcement.publishedAt
? `Published ${formatDate(announcement.publishedAt)}`
: `Created ${formatDate(announcement.createdAt)}`}
</span>
{announcement.authorName ? <span>by {announcement.authorName}</span> : null}
</div>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{announcement.content}</p>
</CardContent>
</Card>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete announcement</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{announcement.title}&quot;.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,201 @@
"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 { createAnnouncementAction, updateAnnouncementAction } from "../actions"
import type { Announcement } from "../types"
type Mode = "create" | "edit"
export function AnnouncementForm({
mode,
announcement,
grades = [],
classes = [],
}: {
mode: Mode
announcement?: Announcement
grades?: { id: string; name: string }[]
classes?: { id: string; name: string }[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [type, setType] = useState<string>(announcement?.type ?? "school")
const [status, setStatus] = useState<string>(announcement?.status ?? "draft")
const [targetGradeId, setTargetGradeId] = useState<string>(announcement?.targetGradeId ?? "")
const [targetClassId, setTargetClassId] = useState<string>(announcement?.targetClassId ?? "")
const handleSubmit = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("type", type)
formData.set("status", status)
if (type === "grade" && targetGradeId) {
formData.set("targetGradeId", targetGradeId)
}
if (type === "class" && targetClassId) {
formData.set("targetClassId", targetClassId)
}
const res =
mode === "create"
? await createAnnouncementAction(null, formData)
: announcement
? await updateAnnouncementAction(announcement.id, null, formData)
: null
if (!res) {
toast.error("Invalid form state")
return
}
if (res.success) {
toast.success(res.message)
router.push("/admin/announcements")
router.refresh()
} else {
toast.error(res.message || "Failed to save announcement")
}
} catch {
toast.error("Failed to save announcement")
} finally {
setIsWorking(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>
{mode === "create" ? "New Announcement" : "Edit Announcement"}
</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
name="title"
placeholder="Announcement title"
defaultValue={announcement?.title ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
name="content"
placeholder="Write the announcement content..."
className="min-h-[160px]"
defaultValue={announcement?.content ?? ""}
required
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="school">School</SelectItem>
<SelectItem value="grade">Grade</SelectItem>
<SelectItem value="class">Class</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="type" value={type} />
</div>
<div className="grid gap-2">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="status" value={status} />
</div>
{type === "grade" ? (
<div className="grid gap-2 md:col-span-2">
<Label>Target Grade</Label>
<Select value={targetGradeId} onValueChange={setTargetGradeId}>
<SelectTrigger>
<SelectValue placeholder="Select a grade (optional)" />
</SelectTrigger>
<SelectContent>
{grades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="targetGradeId" value={targetGradeId} />
</div>
) : null}
{type === "class" ? (
<div className="grid gap-2 md:col-span-2">
<Label>Target Class</Label>
<Select value={targetClassId} onValueChange={setTargetClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class (optional)" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="targetClassId" value={targetClassId} />
</div>
) : null}
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button
type="button"
variant="outline"
onClick={() => router.push("/admin/announcements")}
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,108 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Megaphone } from "lucide-react"
import { AnnouncementCard } from "./announcement-card"
import type { Announcement, AnnouncementStatus } from "../types"
type Filter = "all" | AnnouncementStatus
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "published", label: "Published" },
{ value: "draft", label: "Draft" },
{ value: "archived", label: "Archived" },
]
export function AnnouncementList({
announcements,
canManage,
createHref,
detailHrefBuilder,
initialStatus,
}: {
announcements: Announcement[]
canManage?: boolean
createHref?: string
detailHrefBuilder?: (id: string) => string
initialStatus?: Filter
}) {
const router = useRouter()
const [filter, setFilter] = useState<Filter>(initialStatus ?? "all")
const filtered = useMemo(() => {
if (filter === "all") return announcements
return announcements.filter((a) => a.status === filter)
}, [announcements, 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>
{canManage && createHref ? (
<Button asChild>
<a href={createHref}>
<Plus className="mr-2 h-4 w-4" />
New Announcement
</a>
</Button>
) : null}
</div>
{filtered.length === 0 ? (
<EmptyState
title="No announcements"
description={
announcements.length === 0
? "There are no announcements yet."
: "No announcements match the current filter."
}
icon={Megaphone}
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((a) => (
<AnnouncementCard
key={a.id}
announcement={a}
href={detailHrefBuilder ? detailHrefBuilder(a.id) : undefined}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,120 @@
import "server-only"
import { cache } from "react"
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { announcements, users } from "@/shared/db/schema"
import type {
Announcement,
AnnouncementStatus,
AnnouncementType,
GetAnnouncementsParams,
} from "./types"
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
const mapRow = (
row: {
id: string
title: string
content: string
type: "school" | "grade" | "class"
status: "draft" | "published" | "archived"
targetGradeId: string | null
targetClassId: string | null
authorId: string
authorName: string | null
publishedAt: Date | null
createdAt: Date
updatedAt: Date
}
): Announcement => ({
id: row.id,
title: row.title,
content: row.content,
type: row.type,
status: row.status,
targetGradeId: row.targetGradeId,
targetClassId: row.targetClassId,
authorId: row.authorId,
authorName: row.authorName,
publishedAt: toIso(row.publishedAt),
createdAt: toIso(row.createdAt) as string,
updatedAt: toIso(row.updatedAt) as string,
})
export const getAnnouncements = cache(
async (params?: GetAnnouncementsParams): Promise<Announcement[]> => {
try {
const page = Math.max(1, params?.page ?? 1)
const pageSize = Math.max(1, params?.pageSize ?? 20)
const offset = (page - 1) * pageSize
const conditions = []
if (params?.status) {
conditions.push(eq(announcements.status, params.status as AnnouncementStatus))
}
if (params?.type) {
conditions.push(eq(announcements.type, params.type as AnnouncementType))
}
const rows = await db
.select({
id: announcements.id,
title: announcements.title,
content: announcements.content,
type: announcements.type,
status: announcements.status,
targetGradeId: announcements.targetGradeId,
targetClassId: announcements.targetClassId,
authorId: announcements.authorId,
authorName: users.name,
publishedAt: announcements.publishedAt,
createdAt: announcements.createdAt,
updatedAt: announcements.updatedAt,
})
.from(announcements)
.leftJoin(users, eq(users.id, announcements.authorId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(announcements.createdAt))
.limit(pageSize)
.offset(offset)
return rows.map(mapRow)
} catch {
return []
}
}
)
export const getAnnouncementById = cache(
async (id: string): Promise<Announcement | null> => {
try {
const [row] = await db
.select({
id: announcements.id,
title: announcements.title,
content: announcements.content,
type: announcements.type,
status: announcements.status,
targetGradeId: announcements.targetGradeId,
targetClassId: announcements.targetClassId,
authorId: announcements.authorId,
authorName: users.name,
publishedAt: announcements.publishedAt,
createdAt: announcements.createdAt,
updatedAt: announcements.updatedAt,
})
.from(announcements)
.leftJoin(users, eq(users.id, announcements.authorId))
.where(eq(announcements.id, id))
.limit(1)
return row ? mapRow(row) : null
} catch {
return null
}
}
)

View File

@@ -0,0 +1,45 @@
import { z } from "zod"
export const CreateAnnouncementSchema = z
.object({
title: z.string().trim().min(1).max(255),
content: z.string().trim().min(1),
type: z.enum(["school", "grade", "class"]).optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
targetGradeId: z.string().trim().optional().nullable(),
targetClassId: z.string().trim().optional().nullable(),
publishedAt: z.string().optional().nullable(),
})
.transform((v) => ({
title: v.title,
content: v.content,
type: v.type ?? "school",
status: v.status ?? "draft",
targetGradeId: v.targetGradeId && v.targetGradeId.length > 0 ? v.targetGradeId : null,
targetClassId: v.targetClassId && v.targetClassId.length > 0 ? v.targetClassId : null,
publishedAt: v.publishedAt && v.publishedAt.length > 0 ? v.publishedAt : null,
}))
export type CreateAnnouncementInput = z.infer<typeof CreateAnnouncementSchema>
export const UpdateAnnouncementSchema = z
.object({
title: z.string().trim().min(1).max(255),
content: z.string().trim().min(1),
type: z.enum(["school", "grade", "class"]).optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
targetGradeId: z.string().trim().optional().nullable(),
targetClassId: z.string().trim().optional().nullable(),
publishedAt: z.string().optional().nullable(),
})
.transform((v) => ({
title: v.title,
content: v.content,
type: v.type ?? "school",
status: v.status ?? "draft",
targetGradeId: v.targetGradeId && v.targetGradeId.length > 0 ? v.targetGradeId : null,
targetClassId: v.targetClassId && v.targetClassId.length > 0 ? v.targetClassId : null,
publishedAt: v.publishedAt && v.publishedAt.length > 0 ? v.publishedAt : null,
}))
export type UpdateAnnouncementInput = z.infer<typeof UpdateAnnouncementSchema>

View File

@@ -0,0 +1,27 @@
export type AnnouncementStatus = "draft" | "published" | "archived"
export type AnnouncementType = "school" | "grade" | "class"
export interface Announcement {
id: string
title: string
content: string
type: AnnouncementType
status: AnnouncementStatus
targetGradeId: string | null
targetClassId: string | null
authorId: string
authorName: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
export type AnnouncementListItem = Announcement
export type GetAnnouncementsParams = {
status?: AnnouncementStatus
type?: AnnouncementType
page?: number
pageSize?: number
}

View File

@@ -0,0 +1,271 @@
"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 {
RecordAttendanceSchema,
BatchRecordAttendanceSchema,
UpdateAttendanceSchema,
AttendanceRuleSchema,
} from "./schema"
import {
createAttendanceRecord,
batchCreateAttendanceRecords,
updateAttendanceRecord,
deleteAttendanceRecord,
getAttendanceRecords,
getClassAttendanceForDate,
getAttendanceRules,
upsertAttendanceRules,
} from "./data-access"
import {
getStudentAttendanceSummary,
getClassAttendanceStats,
} from "./data-access-stats"
import type { AttendanceQueryParams, AttendanceListItem } from "./types"
export async function recordAttendanceAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const parsed = RecordAttendanceSchema.safeParse({
studentId: formData.get("studentId"),
classId: formData.get("classId"),
date: formData.get("date"),
status: formData.get("status"),
remark: formData.get("remark") || undefined,
scheduleId: formData.get("scheduleId") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await createAttendanceRecord(parsed.data, ctx.userId)
revalidatePath("/teacher/attendance")
return { success: true, message: "Attendance recorded", data: id }
} catch (e) {
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" }
}
}
export async function batchRecordAttendanceAction(
prevState: ActionState<number> | null,
formData: FormData
): Promise<ActionState<number>> {
try {
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const recordsJson = formData.get("recordsJson")
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
return { success: false, message: "Missing records data" }
}
const parsed = BatchRecordAttendanceSchema.safeParse({
records: JSON.parse(recordsJson),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const count = await batchCreateAttendanceRecords(parsed.data, ctx.userId)
revalidatePath("/teacher/attendance")
return { success: true, message: `Recorded attendance for ${count} students`, data: count }
} catch (e) {
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" }
}
}
export async function updateAttendanceAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ATTENDANCE_MANAGE)
const parsed = UpdateAttendanceSchema.safeParse({
status: formData.get("status") || undefined,
remark: formData.get("remark") || undefined,
scheduleId: formData.get("scheduleId") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
await updateAttendanceRecord(id, parsed.data)
revalidatePath("/teacher/attendance")
return { success: true, message: "Attendance updated" }
} catch (e) {
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" }
}
}
export async function deleteAttendanceAction(
id: string
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ATTENDANCE_MANAGE)
await deleteAttendanceRecord(id)
revalidatePath("/teacher/attendance")
return { success: true, message: "Attendance record deleted" }
} catch (e) {
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" }
}
}
export async function getAttendanceAction(
params: AttendanceQueryParams
): Promise<ActionState<{ items: AttendanceListItem[]; total: number; page: number; pageSize: number; totalPages: number }>> {
try {
const ctx = await requirePermission(Permissions.ATTENDANCE_READ)
const result = await getAttendanceRecords({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
return {
success: true,
data: {
items: result.items,
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages,
},
}
} catch (e) {
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" }
}
}
export async function getStudentAttendanceAction(
studentId: string,
startDate?: string,
endDate?: string
): Promise<ActionState<Awaited<ReturnType<typeof getStudentAttendanceSummary>>>> {
try {
const ctx = await requirePermission(Permissions.ATTENDANCE_READ)
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
return { success: false, message: "Can only view your own attendance" }
}
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
return { success: false, message: "Can only view your children's attendance" }
}
const summary = await getStudentAttendanceSummary(studentId, startDate, endDate)
return { success: true, data: summary }
} catch (e) {
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" }
}
}
export async function getClassAttendanceStatsAction(
classId: string,
startDate?: string,
endDate?: string
): Promise<ActionState<Awaited<ReturnType<typeof getClassAttendanceStats>>>> {
try {
await requirePermission(Permissions.ATTENDANCE_READ)
const result = await getClassAttendanceStats(classId, startDate, endDate)
return { success: true, data: result }
} catch (e) {
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" }
}
}
export async function getClassAttendanceForDateAction(
classId: string,
date: string
): Promise<ActionState<AttendanceListItem[]>> {
try {
await requirePermission(Permissions.ATTENDANCE_READ)
const records = await getClassAttendanceForDate(classId, date)
return { success: true, data: records }
} catch (e) {
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" }
}
}
export async function saveAttendanceRulesAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ATTENDANCE_MANAGE)
const parsed = AttendanceRuleSchema.safeParse({
classId: formData.get("classId"),
lateThresholdMinutes: formData.get("lateThresholdMinutes") || undefined,
earlyLeaveThresholdMinutes: formData.get("earlyLeaveThresholdMinutes") || undefined,
enableAutoMark: formData.get("enableAutoMark") === "true",
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await upsertAttendanceRules(parsed.data)
revalidatePath("/teacher/attendance")
return { success: true, message: "Attendance rules saved", data: id }
} catch (e) {
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" }
}
}
export async function getAttendanceRulesAction(
classId?: string
): Promise<ActionState<Awaited<ReturnType<typeof getAttendanceRules>>>> {
try {
await requirePermission(Permissions.ATTENDANCE_READ)
const rules = await getAttendanceRules(classId)
return { success: true, data: rules }
} catch (e) {
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" }
}
}

View File

@@ -0,0 +1,97 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Input } from "@/shared/components/ui/input"
type Option = { id: string; name: string }
interface AttendanceFiltersProps {
classes: Option[]
}
const STATUS_OPTIONS = [
{ value: "present", label: "Present" },
{ value: "absent", label: "Absent" },
{ value: "late", label: "Late" },
{ value: "early_leave", label: "Early Leave" },
{ value: "excused", label: "Excused" },
]
export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
const router = useRouter()
const searchParams = useSearchParams()
const updateParam = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value && value !== "all") {
params.set(key, value)
} else {
params.delete(key)
}
router.push(`?${params.toString()}`)
},
[router, searchParams]
)
const classId = searchParams.get("classId") ?? "all"
const status = searchParams.get("status") ?? "all"
const date = searchParams.get("date") ?? ""
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-3">
<div className="grid gap-2">
<Label className="text-xs">Class</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All classes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Status</Label>
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Date</Label>
<Input
type="date"
value={date}
onChange={(e) => updateParam("date", e.target.value)}
className="h-9"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,130 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Trash2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { formatDate } from "@/shared/lib/utils"
import { deleteAttendanceAction } from "../actions"
import {
ATTENDANCE_STATUS_COLORS,
ATTENDANCE_STATUS_LABELS,
type AttendanceListItem,
} from "../types"
export function AttendanceRecordList({ records }: { records: AttendanceListItem[] }) {
const router = useRouter()
const [deleteId, setDeleteId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
if (!deleteId) return
setIsDeleting(true)
const result = await deleteAttendanceAction(deleteId)
setIsDeleting(false)
if (result.success) {
toast.success(result.message)
setDeleteId(null)
router.refresh()
} else {
toast.error(result.message || "Failed to delete")
}
}
if (records.length === 0) {
return (
<div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
No attendance records found.
</div>
)
}
return (
<>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Class</TableHead>
<TableHead>Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Remark</TableHead>
<TableHead>Recorded By</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.date}</TableCell>
<TableCell>
<Badge variant={ATTENDANCE_STATUS_COLORS[r.status]} className="capitalize">
{ATTENDANCE_STATUS_LABELS[r.status]}
</Badge>
</TableCell>
<TableCell className="max-w-[200px] truncate text-muted-foreground">
{r.remark ?? "-"}
</TableCell>
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Attendance Record</DialogTitle>
<DialogDescription>
Are you sure you want to delete this attendance record? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { saveAttendanceRulesAction } from "../actions"
import type { AttendanceRule } from "../types"
type Option = { id: string; name: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Rules"}
</Button>
)
}
export function AttendanceRulesForm({
classes,
existingRules,
}: {
classes: Option[]
existingRules: AttendanceRule[]
}) {
const router = useRouter()
const [classId, setClassId] = useState(classes[0]?.id ?? "")
const [lateThreshold, setLateThreshold] = useState("15")
const [earlyLeaveThreshold, setEarlyLeaveThreshold] = useState("15")
const [enableAutoMark, setEnableAutoMark] = useState(false)
const handleClassChange = (id: string) => {
setClassId(id)
const rule = existingRules.find((r) => r.classId === id)
if (rule) {
setLateThreshold(String(rule.lateThresholdMinutes ?? 15))
setEarlyLeaveThreshold(String(rule.earlyLeaveThresholdMinutes ?? 15))
setEnableAutoMark(rule.enableAutoMark ?? false)
} else {
setLateThreshold("15")
setEarlyLeaveThreshold("15")
setEnableAutoMark(false)
}
}
const handleSubmit = async (formData: FormData) => {
if (!classId) {
toast.error("Please select a class")
return
}
formData.set("classId", classId)
formData.set("lateThresholdMinutes", lateThreshold)
formData.set("earlyLeaveThresholdMinutes", earlyLeaveThreshold)
formData.set("enableAutoMark", enableAutoMark ? "true" : "false")
const result = await saveAttendanceRulesAction(null, formData)
if (result.success) {
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || "Failed to save rules")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Attendance Rules</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid gap-2">
<Label>Class</Label>
<Select value={classId} onValueChange={handleClassChange}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="lateThresholdMinutes">Late Threshold (minutes)</Label>
<Input
id="lateThresholdMinutes"
type="number"
min="0"
value={lateThreshold}
onChange={(e) => setLateThreshold(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="earlyLeaveThresholdMinutes">Early Leave Threshold (minutes)</Label>
<Input
id="earlyLeaveThresholdMinutes"
type="number"
min="0"
value={earlyLeaveThreshold}
onChange={(e) => setEarlyLeaveThreshold(e.target.value)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="enableAutoMark"
checked={enableAutoMark}
onCheckedChange={(v) => setEnableAutoMark(v === true)}
/>
<Label htmlFor="enableAutoMark" className="cursor-pointer">
Enable auto-marking (mark present automatically when student checks in on time)
</Label>
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,215 @@
"use client"
import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { CalendarDays } from "lucide-react"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { batchRecordAttendanceAction } from "../actions"
import {
ATTENDANCE_STATUS_LABELS,
type AttendanceStatus,
} from "../types"
type Option = { id: string; name: string }
type Student = { id: string; name: string; email: string }
const STATUS_OPTIONS: AttendanceStatus[] = [
"present",
"absent",
"late",
"early_leave",
"excused",
]
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Attendance"}
</Button>
)
}
export function AttendanceSheet({
classes,
students,
defaultClassId,
defaultDate,
}: {
classes: Option[]
students: Student[]
defaultClassId?: string
defaultDate?: string
}) {
const router = useRouter()
const today = new Date().toISOString().slice(0, 10)
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [date, setDate] = useState(defaultDate ?? today)
const [statuses, setStatuses] = useState<Record<string, AttendanceStatus>>({})
const handleStatusChange = (studentId: string, status: AttendanceStatus) => {
setStatuses((prev) => ({ ...prev, [studentId]: status }))
}
const markAllPresent = () => {
const all: Record<string, AttendanceStatus> = {}
for (const s of students) all[s.id] = "present"
setStatuses(all)
}
const handleSubmit = async (formData: FormData) => {
if (!classId || !date) {
toast.error("Please select class and date")
return
}
const records = students.map((s) => ({
studentId: s.id,
classId,
date,
status: statuses[s.id] ?? "present",
}))
if (records.length === 0) {
toast.error("No students to record attendance for")
return
}
formData.set("recordsJson", JSON.stringify(records))
const result = await batchRecordAttendanceAction(null, formData)
if (result.success) {
toast.success(result.message)
router.push("/teacher/attendance")
router.refresh()
} else {
toast.error(result.message || "Failed to save attendance")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Attendance Sheet</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>
</div>
<div className="grid gap-2">
<Label htmlFor="date">Date</Label>
<div className="relative">
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="pl-9"
required
/>
</div>
</div>
</div>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground">
No students in this class. Select a class to load students.
</p>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{students.length} students
</p>
<Button type="button" variant="outline" size="sm" onClick={markAllPresent}>
Mark All Present
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-48">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>
<Select
value={statuses[s.id] ?? "present"}
onValueChange={(v) => handleStatusChange(s.id, v as AttendanceStatus)}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((st) => (
<SelectItem key={st} value={st}>
{ATTENDANCE_STATUS_LABELS[st]}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,100 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
Users,
CheckCircle2,
XCircle,
Clock,
LogOut,
FileText,
TrendingUp,
} from "lucide-react"
import type { AttendanceStats } from "../types"
interface StatItemProps {
label: string
value: string | number
icon: React.ReactNode
hint?: string
}
function StatItem({ label, value, icon, hint }: StatItemProps) {
return (
<div className="flex flex-col gap-1 rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className="text-muted-foreground">{icon}</span>
</div>
<span className="text-2xl font-bold">{value}</span>
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
</div>
)
}
export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) {
if (!stats || stats.total === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Attendance Statistics</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No attendance data available.</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Attendance Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label="Total Records"
value={stats.total}
icon={<Users className="h-4 w-4" />}
/>
<StatItem
label="Present"
value={stats.present}
icon={<CheckCircle2 className="h-4 w-4" />}
/>
<StatItem
label="Absent"
value={stats.absent}
icon={<XCircle className="h-4 w-4" />}
/>
<StatItem
label="Late"
value={stats.late}
icon={<Clock className="h-4 w-4" />}
/>
<StatItem
label="Early Leave"
value={stats.earlyLeave}
icon={<LogOut className="h-4 w-4" />}
/>
<StatItem
label="Excused"
value={stats.excused}
icon={<FileText className="h-4 w-4" />}
/>
<StatItem
label="Present Rate"
value={`${stats.presentRate.toFixed(1)}%`}
icon={<TrendingUp className="h-4 w-4" />}
hint="Present / Total"
/>
<StatItem
label="Late Rate"
value={`${stats.lateRate.toFixed(1)}%`}
icon={<Clock className="h-4 w-4" />}
hint="Late / Total"
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,104 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { CalendarCheck } from "lucide-react"
import { AttendanceStatsCard } from "./attendance-stats-card"
import {
ATTENDANCE_STATUS_COLORS,
ATTENDANCE_STATUS_LABELS,
type StudentAttendanceSummary,
} from "../types"
export function StudentAttendanceView({
summary,
}: {
summary: StudentAttendanceSummary | null
}) {
if (!summary) {
return (
<EmptyState
title="No data"
description="Student attendance summary is not available."
icon={CalendarCheck}
className="border-none shadow-none"
/>
)
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentName}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Records</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.stats.total}</p>
</CardContent>
</Card>
</div>
<AttendanceStatsCard stats={summary.stats} />
{summary.recentRecords.length === 0 ? (
<EmptyState
title="No attendance records"
description="There are no attendance records for this student yet."
icon={CalendarCheck}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Recent Attendance</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Class</TableHead>
<TableHead>Status</TableHead>
<TableHead>Remark</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.recentRecords.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.date}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>
<Badge variant={ATTENDANCE_STATUS_COLORS[r.status]} className="capitalize">
{ATTENDANCE_STATUS_LABELS[r.status]}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{r.remark ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,145 @@
import "server-only"
import { and, asc, desc, eq, gte, lte } from "drizzle-orm"
import { db } from "@/shared/db"
import { attendanceRecords, classes, users } from "@/shared/db/schema"
import type {
AttendanceListItem,
AttendanceStats,
ClassAttendanceSummary,
StudentAttendanceSummary,
} from "./types"
const EMPTY_STATS: AttendanceStats = {
total: 0,
present: 0,
absent: 0,
late: 0,
earlyLeave: 0,
excused: 0,
presentRate: 0,
lateRate: 0,
}
const computeStats = (rows: { status: string }[]): AttendanceStats => {
if (rows.length === 0) return EMPTY_STATS
const stats: AttendanceStats = { ...EMPTY_STATS, total: rows.length }
for (const r of rows) {
if (r.status === "present") stats.present += 1
else if (r.status === "absent") stats.absent += 1
else if (r.status === "late") stats.late += 1
else if (r.status === "early_leave") stats.earlyLeave += 1
else if (r.status === "excused") stats.excused += 1
}
stats.presentRate = Math.round((stats.present / stats.total) * 10000) / 100
stats.lateRate = Math.round((stats.late / stats.total) * 10000) / 100
return stats
}
const serializeDate = (d: Date | string | null): string =>
d ? new Date(d).toISOString().slice(0, 10) : ""
export async function getStudentAttendanceSummary(
studentId: string,
startDate?: string,
endDate?: string
): Promise<StudentAttendanceSummary | null> {
const [student] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, studentId))
.limit(1)
if (!student) return null
const conditions = [eq(attendanceRecords.studentId, studentId)]
if (startDate) conditions.push(gte(attendanceRecords.date, new Date(startDate)))
if (endDate) conditions.push(lte(attendanceRecords.date, new Date(endDate)))
const rows = await db
.select({
record: attendanceRecords,
className: classes.name,
})
.from(attendanceRecords)
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
.where(and(...conditions))
.orderBy(desc(attendanceRecords.date))
const stats = computeStats(rows.map((r) => ({ status: r.record.status })))
const recentRecords: AttendanceListItem[] = rows.slice(0, 20).map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: student.name ?? "Unknown",
classId: r.record.classId,
className: r.className ?? "Unknown",
scheduleId: r.record.scheduleId ?? null,
date: serializeDate(r.record.date),
status: r.record.status,
remark: r.record.remark ?? null,
recordedBy: r.record.recordedBy,
recorderName: "Unknown",
createdAt: r.record.createdAt.toISOString(),
}))
return {
studentId,
studentName: student.name ?? "Unknown",
stats,
recentRecords,
}
}
export async function getClassAttendanceStats(
classId: string,
startDate?: string,
endDate?: string
): Promise<ClassAttendanceSummary | null> {
const [classRow] = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) return null
const conditions = [eq(attendanceRecords.classId, classId)]
if (startDate) conditions.push(gte(attendanceRecords.date, new Date(startDate)))
if (endDate) conditions.push(lte(attendanceRecords.date, new Date(endDate)))
const rows = await db
.select({
record: attendanceRecords,
studentName: users.name,
})
.from(attendanceRecords)
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
.where(and(...conditions))
.orderBy(asc(users.name))
const stats = computeStats(rows.map((r) => ({ status: r.record.status })))
const studentRecords: AttendanceListItem[] = rows.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: r.studentName ?? "Unknown",
classId: r.record.classId,
className: classRow.name,
scheduleId: r.record.scheduleId ?? null,
date: serializeDate(r.record.date),
status: r.record.status,
remark: r.record.remark ?? null,
recordedBy: r.record.recordedBy,
recorderName: "Unknown",
createdAt: r.record.createdAt.toISOString(),
}))
return {
classId,
className: classRow.name,
date: startDate ?? endDate ?? new Date().toISOString().slice(0, 10),
stats,
studentRecords,
}
}

View File

@@ -0,0 +1,271 @@
import "server-only"
import { and, asc, count, desc, eq, gte, inArray, lte, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import {
attendanceRecords,
attendanceRules,
classes,
classEnrollments,
users,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
AttendanceListItem,
AttendanceQueryParams,
AttendanceRule,
PaginatedAttendanceResult,
} from "./types"
import type {
AttendanceRuleInput,
BatchRecordAttendanceInput,
RecordAttendanceInput,
UpdateAttendanceInput,
} from "./schema"
const buildScopeFilter = (scope: DataScope): SQL | null => {
if (scope.type === "all") return null
if (scope.type === "class_taught") {
return scope.classIds.length > 0
? inArray(attendanceRecords.classId, scope.classIds)
: sql`1=0`
}
if (scope.type === "grade_managed") return sql`1=0`
if (scope.type === "class_members") return null
if (scope.type === "children") {
return scope.childrenIds.length > 0
? inArray(attendanceRecords.studentId, scope.childrenIds)
: sql`1=0`
}
if (scope.type === "owned") return eq(attendanceRecords.studentId, scope.userId)
return sql`1=0`
}
const serializeDate = (d: Date | string | null): string =>
d ? new Date(d).toISOString().slice(0, 10) : ""
const mapListItem = (
r: typeof attendanceRecords.$inferSelect,
studentName: string | null,
className: string | null,
recorderName: string
): AttendanceListItem => ({
id: r.id,
studentId: r.studentId,
studentName: studentName ?? "Unknown",
classId: r.classId,
className: className ?? "Unknown",
scheduleId: r.scheduleId ?? null,
date: serializeDate(r.date),
status: r.status,
remark: r.remark ?? null,
recordedBy: r.recordedBy,
recorderName,
createdAt: r.createdAt.toISOString(),
})
const resolveRecorderNames = async (rows: { record: typeof attendanceRecords.$inferSelect }[]) => {
const ids = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
const map = new Map<string, string>()
if (ids.length > 0) {
const recorders = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, ids))
for (const r of recorders) map.set(r.id, r.name ?? "Unknown")
}
return map
}
export async function getAttendanceRecords(
params: AttendanceQueryParams & { scope: DataScope; currentUserId?: string }
): Promise<PaginatedAttendanceResult> {
const page = Math.max(1, params.page ?? 1)
const pageSize = Math.max(1, Math.min(100, params.pageSize ?? 20))
const conditions: SQL[] = []
const scopeFilter = buildScopeFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
}
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
if (params.studentId) conditions.push(eq(attendanceRecords.studentId, params.studentId))
if (params.date) conditions.push(eq(attendanceRecords.date, new Date(params.date)))
if (params.startDate) conditions.push(gte(attendanceRecords.date, new Date(params.startDate)))
if (params.endDate) conditions.push(lte(attendanceRecords.date, new Date(params.endDate)))
if (params.status) conditions.push(eq(attendanceRecords.status, params.status))
const where = conditions.length > 0 ? and(...conditions) : undefined
const [totalRow] = await db.select({ c: count() }).from(attendanceRecords).where(where)
const total = Number(totalRow?.c ?? 0)
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const rows = await db
.select({
record: attendanceRecords,
studentName: users.name,
className: classes.name,
})
.from(attendanceRecords)
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
.where(where)
.orderBy(desc(attendanceRecords.date), desc(attendanceRecords.createdAt))
.limit(pageSize)
.offset((page - 1) * pageSize)
const recorderMap = await resolveRecorderNames(rows)
return {
items: rows.map((r) =>
mapListItem(r.record, r.studentName, r.className, recorderMap.get(r.record.recordedBy) ?? "Unknown")
),
total,
page,
pageSize,
totalPages,
}
}
export async function getClassAttendanceForDate(
classId: string,
date: string
): Promise<AttendanceListItem[]> {
const rows = await db
.select({
record: attendanceRecords,
studentName: users.name,
className: classes.name,
})
.from(attendanceRecords)
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
.where(and(eq(attendanceRecords.classId, classId), eq(attendanceRecords.date, new Date(date))))
.orderBy(asc(users.name))
return rows.map((r) => mapListItem(r.record, r.studentName, r.className, "Unknown"))
}
export async function createAttendanceRecord(
data: RecordAttendanceInput,
recordedBy: string
): Promise<string> {
const id = createId()
await db.insert(attendanceRecords).values({
id,
studentId: data.studentId,
classId: data.classId,
scheduleId: data.scheduleId ?? null,
date: new Date(data.date),
status: data.status,
remark: data.remark ?? null,
recordedBy,
})
return id
}
export async function batchCreateAttendanceRecords(
data: BatchRecordAttendanceInput,
recordedBy: string
): Promise<number> {
if (data.records.length === 0) return 0
const rows = data.records.map((r) => ({
id: createId(),
studentId: r.studentId,
classId: r.classId,
scheduleId: r.scheduleId ?? null,
date: new Date(r.date),
status: r.status,
remark: r.remark ?? null,
recordedBy,
}))
await db.insert(attendanceRecords).values(rows)
return rows.length
}
export async function updateAttendanceRecord(
id: string,
data: UpdateAttendanceInput
): Promise<void> {
const update: Record<string, unknown> = { updatedAt: new Date() }
if (data.status !== undefined) update.status = data.status
if (data.remark !== undefined) update.remark = data.remark
if (data.scheduleId !== undefined) update.scheduleId = data.scheduleId
await db.update(attendanceRecords).set(update).where(eq(attendanceRecords.id, id))
}
export async function deleteAttendanceRecord(id: string): Promise<void> {
await db.delete(attendanceRecords).where(eq(attendanceRecords.id, id))
}
export async function getClassStudentsForAttendance(
classId: string
): Promise<Array<{ id: string; name: string; email: string }>> {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(classEnrollments)
.innerJoin(users, eq(users.id, classEnrollments.studentId))
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
.orderBy(asc(users.name))
return rows.map((r) => ({ id: r.id, name: r.name ?? "Unknown", email: r.email }))
}
export async function getAttendanceRules(classId?: string): Promise<AttendanceRule[]> {
const conditions: SQL[] = []
if (classId) {
const classCondition = or(eq(attendanceRules.classId, classId), sql`${attendanceRules.classId} IS NULL`)
if (classCondition) conditions.push(classCondition)
}
const rows = await db
.select()
.from(attendanceRules)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(attendanceRules.createdAt))
return rows.map((r) => ({
id: r.id,
classId: r.classId ?? null,
lateThresholdMinutes: r.lateThresholdMinutes ?? null,
earlyLeaveThresholdMinutes: r.earlyLeaveThresholdMinutes ?? null,
enableAutoMark: r.enableAutoMark ?? null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}))
}
export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<string> {
const [existing] = await db
.select()
.from(attendanceRules)
.where(eq(attendanceRules.classId, data.classId))
.limit(1)
if (existing) {
await db
.update(attendanceRules)
.set({
lateThresholdMinutes: data.lateThresholdMinutes ?? 15,
earlyLeaveThresholdMinutes: data.earlyLeaveThresholdMinutes ?? 15,
enableAutoMark: data.enableAutoMark ?? false,
updatedAt: new Date(),
})
.where(eq(attendanceRules.id, existing.id))
return existing.id
}
const id = createId()
await db.insert(attendanceRules).values({
id,
classId: data.classId,
lateThresholdMinutes: data.lateThresholdMinutes ?? 15,
earlyLeaveThresholdMinutes: data.earlyLeaveThresholdMinutes ?? 15,
enableAutoMark: data.enableAutoMark ?? false,
})
return id
}

View File

@@ -0,0 +1,43 @@
import { z } from "zod"
export const AttendanceStatusEnum = z.enum([
"present",
"absent",
"late",
"early_leave",
"excused",
])
export const RecordAttendanceSchema = z.object({
studentId: z.string().min(1),
classId: z.string().min(1),
date: z.string().min(1),
status: AttendanceStatusEnum,
remark: z.string().optional(),
scheduleId: z.string().optional(),
})
export type RecordAttendanceInput = z.infer<typeof RecordAttendanceSchema>
export const BatchRecordAttendanceSchema = z.object({
records: z.array(RecordAttendanceSchema),
})
export type BatchRecordAttendanceInput = z.infer<typeof BatchRecordAttendanceSchema>
export const UpdateAttendanceSchema = z.object({
status: AttendanceStatusEnum.optional(),
remark: z.string().optional(),
scheduleId: z.string().optional(),
})
export type UpdateAttendanceInput = z.infer<typeof UpdateAttendanceSchema>
export const AttendanceRuleSchema = z.object({
classId: z.string().min(1),
lateThresholdMinutes: z.coerce.number().int().min(0).optional(),
earlyLeaveThresholdMinutes: z.coerce.number().int().min(0).optional(),
enableAutoMark: z.coerce.boolean().optional(),
})
export type AttendanceRuleInput = z.infer<typeof AttendanceRuleSchema>

View File

@@ -0,0 +1,103 @@
export type AttendanceStatus = "present" | "absent" | "late" | "early_leave" | "excused"
export interface AttendanceRecord {
id: string
studentId: string
classId: string
scheduleId: string | null
date: string
status: AttendanceStatus
remark: string | null
recordedBy: string
createdAt: string
updatedAt: string
}
export interface AttendanceListItem {
id: string
studentId: string
studentName: string
classId: string
className: string
scheduleId: string | null
date: string
status: AttendanceStatus
remark: string | null
recordedBy: string
recorderName: string
createdAt: string
}
export interface AttendanceStats {
total: number
present: number
absent: number
late: number
earlyLeave: number
excused: number
presentRate: number
lateRate: number
}
export interface StudentAttendanceSummary {
studentId: string
studentName: string
stats: AttendanceStats
recentRecords: AttendanceListItem[]
}
export interface ClassAttendanceSummary {
classId: string
className: string
date: string
stats: AttendanceStats
studentRecords: AttendanceListItem[]
}
export interface AttendanceRule {
id: string
classId: string | null
lateThresholdMinutes: number | null
earlyLeaveThresholdMinutes: number | null
enableAutoMark: boolean | null
createdAt: string
updatedAt: string
}
export interface AttendanceQueryParams {
classId?: string
studentId?: string
date?: string
startDate?: string
endDate?: string
status?: AttendanceStatus
page?: number
pageSize?: number
}
export interface PaginatedAttendanceResult {
items: AttendanceListItem[]
total: number
page: number
pageSize: number
totalPages: number
}
export const ATTENDANCE_STATUS_LABELS: Record<AttendanceStatus, string> = {
present: "Present",
absent: "Absent",
late: "Late",
early_leave: "Early Leave",
excused: "Excused",
}
export const ATTENDANCE_STATUS_COLORS: Record<
AttendanceStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
present: "default",
absent: "destructive",
late: "secondary",
early_leave: "outline",
excused: "outline",
}

View File

@@ -0,0 +1,212 @@
"use server"
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { exportToExcel } from "@/shared/lib/excel"
import {
getAuditLogsForExport,
getDataChangeLogs,
getDataChangeLogsForExport,
getDataChangeStats,
getDataChangeTableOptions,
getLoginLogsForExport,
} from "./data-access"
import type {
AuditLogQueryParams,
DataChangeLog,
DataChangeLogQueryParams,
DataChangeStat,
LoginLogQueryParams,
} from "./types"
export async function getDataChangeLogsAction(
params?: DataChangeLogQueryParams
): Promise<
ActionState<{
items: DataChangeLog[]
total: number
page: number
pageSize: number
totalPages: number
tableOptions: string[]
stats: DataChangeStat[]
}>
> {
try {
await requirePermission(Permissions.AUDIT_LOG_READ)
const [result, tableOptions, stats] = await Promise.all([
getDataChangeLogs(params),
getDataChangeTableOptions(),
getDataChangeStats(),
])
return {
success: true,
data: {
items: result.items,
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages,
tableOptions,
stats,
},
}
} catch (e) {
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" }
}
}
export async function exportAuditLogsAction(
params?: AuditLogQueryParams
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
try {
await requirePermission(Permissions.AUDIT_LOG_READ)
const items = await getAuditLogsForExport(params)
const buffer = await exportToExcel({
sheets: [
{
name: "Audit Logs",
columns: [
{ header: "User ID", key: "userId", width: 22 },
{ header: "User Name", key: "userName", width: 18 },
{ header: "Module", key: "module", width: 16 },
{ header: "Action", key: "action", width: 22 },
{ header: "Target ID", key: "targetId", width: 22 },
{ header: "Target Type", key: "targetType", width: 16 },
{ header: "Detail", key: "detail", width: 40 },
{ header: "IP Address", key: "ipAddress", width: 16 },
{ header: "Status", key: "status", width: 10 },
{ header: "Created At", key: "createdAt", width: 22 },
],
rows: items.map((r) => ({
userId: r.userId,
userName: r.userName,
module: r.module,
action: r.action,
targetId: r.targetId ?? "",
targetType: r.targetType ?? "",
detail: r.detail ?? "",
ipAddress: r.ipAddress ?? "",
status: r.status,
createdAt: r.createdAt,
})),
},
],
})
return {
success: true,
data: { buffer, filename: `audit_logs_${formatDateForFile()}.xlsx` },
}
} catch (e) {
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" }
}
}
export async function exportLoginLogsAction(
params?: LoginLogQueryParams
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
try {
await requirePermission(Permissions.AUDIT_LOG_READ)
const items = await getLoginLogsForExport(params)
const buffer = await exportToExcel({
sheets: [
{
name: "Login Logs",
columns: [
{ header: "User ID", key: "userId", width: 22 },
{ header: "User Email", key: "userEmail", width: 26 },
{ header: "Action", key: "action", width: 12 },
{ header: "Status", key: "status", width: 10 },
{ header: "IP Address", key: "ipAddress", width: 16 },
{ header: "User Agent", key: "userAgent", width: 40 },
{ header: "Error Message", key: "errorMessage", width: 30 },
{ header: "Created At", key: "createdAt", width: 22 },
],
rows: items.map((r) => ({
userId: r.userId ?? "",
userEmail: r.userEmail,
action: r.action,
status: r.status,
ipAddress: r.ipAddress ?? "",
userAgent: r.userAgent ?? "",
errorMessage: r.errorMessage ?? "",
createdAt: r.createdAt,
})),
},
],
})
return {
success: true,
data: { buffer, filename: `login_logs_${formatDateForFile()}.xlsx` },
}
} catch (e) {
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" }
}
}
export async function exportDataChangeLogsAction(
params?: DataChangeLogQueryParams
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
try {
await requirePermission(Permissions.AUDIT_LOG_READ)
const items = await getDataChangeLogsForExport(params)
const buffer = await exportToExcel({
sheets: [
{
name: "Data Change Logs",
columns: [
{ header: "Table Name", key: "tableName", width: 22 },
{ header: "Record ID", key: "recordId", width: 22 },
{ header: "Action", key: "action", width: 10 },
{ header: "Old Value", key: "oldValue", width: 50 },
{ header: "New Value", key: "newValue", width: 50 },
{ header: "Changed By", key: "changedBy", width: 22 },
{ header: "Changed By Name", key: "changedByName", width: 18 },
{ header: "IP Address", key: "ipAddress", width: 16 },
{ header: "Created At", key: "createdAt", width: 22 },
],
rows: items.map((r) => ({
tableName: r.tableName,
recordId: r.recordId,
action: r.action,
oldValue: r.oldValue ?? "",
newValue: r.newValue ?? "",
changedBy: r.changedBy,
changedByName: r.changedByName,
ipAddress: r.ipAddress ?? "",
createdAt: r.createdAt,
})),
},
],
})
return {
success: true,
data: { buffer, filename: `data_change_logs_${formatDateForFile()}.xlsx` },
}
} catch (e) {
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" }
}
}
function formatDateForFile(d = new Date()): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}-${m}-${day}`
}

View File

@@ -0,0 +1,89 @@
"use client"
import { useState } from "react"
import { Download, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
interface AuditLogExportButtonProps {
exportType: "audit" | "login" | "dataChange"
params?: Record<string, unknown>
label?: string
variant?: "default" | "outline" | "secondary" | "ghost" | "destructive"
size?: "default" | "sm" | "lg" | "icon"
className?: string
}
const ACTION_MAP = {
audit: "exportAuditLogsAction",
login: "exportLoginLogsAction",
dataChange: "exportDataChangeLogsAction",
} as const
export function AuditLogExportButton({
exportType,
params,
label = "Export Excel",
variant = "outline",
size = "sm",
className,
}: AuditLogExportButtonProps) {
const [isPending, setIsPending] = useState(false)
const handleExport = async () => {
setIsPending(true)
try {
const res = await fetch("/api/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: exportType, params: params ?? {} }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
toast.error(body?.message || "Export failed")
return
}
// Try to read filename from Content-Disposition header
const disposition = res.headers.get("Content-Disposition") || ""
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i)
const filename = filenameMatch?.[1] ?? `export_${Date.now()}.xlsx`
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success("Export ready")
} catch {
toast.error("Export failed")
} finally {
setIsPending(false)
}
}
return (
<Button
type="button"
variant={variant}
size={size}
className={className}
disabled={isPending}
onClick={() => void handleExport()}
data-action-name={ACTION_MAP[exportType]}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{label}
</Button>
)
}

View File

@@ -0,0 +1,100 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { X } from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
interface AuditLogFiltersProps {
moduleOptions: string[]
}
export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
const [module, setModule] = useQueryState("module", parseAsString.withOptions({ shallow: false }))
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
const [startDate, setStartDate] = useQueryState(
"startDate",
parseAsString.withOptions({ shallow: false })
)
const [endDate, setEndDate] = useQueryState(
"endDate",
parseAsString.withOptions({ shallow: false })
)
const hasFilters = Boolean(module || action || status || startDate || endDate)
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
<Select value={module || "all"} onValueChange={(val) => setModule(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background">
<SelectValue placeholder="Module" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Module</SelectItem>
{moduleOptions.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Action..."
className="w-full md:w-[180px] bg-background"
value={action || ""}
onChange={(e) => setAction(e.target.value || null)}
/>
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Status</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
value={startDate || ""}
onChange={(e) => setStartDate(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
value={endDate || ""}
onChange={(e) => setEndDate(e.target.value || null)}
/>
{hasFilters && (
<Button
variant="ghost"
onClick={() => {
setModule(null)
setAction(null)
setStatus(null)
setStartDate(null)
setEndDate(null)
}}
className="h-10 px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,156 @@
"use client"
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Button } from "@/shared/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { formatDate } from "@/shared/lib/utils"
import type { AuditLog } from "../types"
import { cn } from "@/shared/lib/utils"
interface AuditLogTableProps {
items: AuditLog[]
page: number
pageSize: number
total: number
totalPages: number
onPageChange: (page: number) => void
}
export function AuditLogTable({
items,
page,
pageSize,
total,
totalPages,
onPageChange,
}: AuditLogTableProps) {
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>User</TableHead>
<TableHead>Module</TableHead>
<TableHead>Action</TableHead>
<TableHead>Target</TableHead>
<TableHead>Status</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
No audit logs found.
</TableCell>
</TableRow>
) : (
items.map((log) => (
<TableRow key={log.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{log.userName}</span>
<span className="text-xs text-muted-foreground">{log.userId}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{log.module}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{log.action}</TableCell>
<TableCell>
{log.targetId ? (
<div className="flex flex-col">
<span className="text-xs font-mono">{log.targetId}</span>
{log.targetType && (
<span className="text-xs text-muted-foreground">{log.targetType}</span>
)}
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<StatusBadge status={log.status} />
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{log.ipAddress ?? "-"}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(log.createdAt, "zh-CN")}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between px-2 py-4">
<div className="text-sm text-muted-foreground">
{total > 0 ? (
<>
Showing <span className="font-medium">{start}</span>-
<span className="font-medium">{end}</span> of{" "}
<span className="font-medium">{total}</span> logs
</>
) : (
"No logs"
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
Page {page} of {Math.max(totalPages, 1)}
</span>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
<span className="sr-only">Previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
<span className="sr-only">Next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
function StatusBadge({ status }: { status: "success" | "failure" }) {
const variant: BadgeProps["variant"] = status === "success" ? "default" : "destructive"
return (
<Badge
variant={variant}
className={cn(
"capitalize",
status === "success" && "bg-green-600 hover:bg-green-700 border-transparent"
)}
>
{status}
</Badge>
)
}

View File

@@ -0,0 +1,61 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense } from "react"
import { AuditLogTable } from "./audit-log-table"
import { AuditLogFilters } from "./audit-log-filters"
import type { AuditLog } from "../types"
interface AuditLogViewProps {
items: AuditLog[]
page: number
pageSize: number
total: number
totalPages: number
moduleOptions: string[]
}
function AuditLogViewInner({
items,
page,
pageSize,
total,
totalPages,
moduleOptions,
}: AuditLogViewProps) {
const router = useRouter()
const searchParams = useSearchParams()
const handlePageChange = (newPage: number) => {
const params = new URLSearchParams(searchParams.toString())
if (newPage <= 1) {
params.delete("page")
} else {
params.set("page", String(newPage))
}
const query = params.toString()
router.push(query ? `?${query}` : "?")
}
return (
<div className="space-y-4">
<AuditLogFilters moduleOptions={moduleOptions} />
<AuditLogTable
items={items}
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</div>
)
}
export function AuditLogView(props: AuditLogViewProps) {
return (
<Suspense fallback={null}>
<AuditLogViewInner {...props} />
</Suspense>
)
}

View File

@@ -0,0 +1,325 @@
"use client"
import { useState, Fragment, Suspense } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { X } from "lucide-react"
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { formatDate } from "@/shared/lib/utils"
import { cn } from "@/shared/lib/utils"
import type { DataChangeLog, DataChangeStat } from "../types"
interface DataChangeLogTableProps {
items: DataChangeLog[]
page: number
pageSize: number
total: number
totalPages: number
tableOptions: string[]
stats: DataChangeStat[]
}
function DataChangeLogTableInner({
items,
page,
pageSize,
total,
totalPages,
tableOptions,
stats,
}: DataChangeLogTableProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [expandedId, setExpandedId] = useState<string | null>(null)
const handlePageChange = (newPage: number) => {
const params = new URLSearchParams(searchParams.toString())
if (newPage <= 1) {
params.delete("page")
} else {
params.set("page", String(newPage))
}
const query = params.toString()
router.push(query ? `?${query}` : "?")
}
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
return (
<div className="space-y-4">
<DataChangeLogFilters tableOptions={tableOptions} stats={stats} />
<div className="rounded-md border">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>Table</TableHead>
<TableHead>Record ID</TableHead>
<TableHead>Action</TableHead>
<TableHead>Changed By</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Time</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
No data change logs found.
</TableCell>
</TableRow>
) : (
items.map((log) => (
<Fragment key={log.id}>
<TableRow>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{log.tableName}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{log.recordId}</TableCell>
<TableCell>
<ActionBadge action={log.action} />
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{log.changedByName}</span>
<span className="text-xs text-muted-foreground">{log.changedBy}</span>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{log.ipAddress ?? "-"}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(log.createdAt, "zh-CN")}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
>
{expandedId === log.id ? "Hide" : "View"}
</Button>
</TableCell>
</TableRow>
{expandedId === log.id && (
<TableRow>
<TableCell colSpan={7} className="bg-muted/30">
<div className="grid gap-4 md:grid-cols-2">
<div>
<div className="mb-1 text-xs font-semibold text-muted-foreground">
Old Value
</div>
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
{log.oldValue ?? "—"}
</pre>
</div>
<div>
<div className="mb-1 text-xs font-semibold text-muted-foreground">
New Value
</div>
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
{log.newValue ?? "—"}
</pre>
</div>
</div>
</TableCell>
</TableRow>
)}
</Fragment>
))
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between px-2 py-4">
<div className="text-sm text-muted-foreground">
{total > 0 ? (
<>
Showing <span className="font-medium">{start}</span>-
<span className="font-medium">{end}</span> of{" "}
<span className="font-medium">{total}</span> logs
</>
) : (
"No logs"
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
Page {page} of {Math.max(totalPages, 1)}
</span>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
>
<span className="sr-only">Previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
>
<span className="sr-only">Next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
function DataChangeLogFilters({
tableOptions,
stats,
}: {
tableOptions: string[]
stats: DataChangeStat[]
}) {
const [tableName, setTableName] = useQueryState(
"tableName",
parseAsString.withOptions({ shallow: false })
)
const [action, setAction] = useQueryState(
"action",
parseAsString.withOptions({ shallow: false })
)
const [startDate, setStartDate] = useQueryState(
"startDate",
parseAsString.withOptions({ shallow: false })
)
const [endDate, setEndDate] = useQueryState(
"endDate",
parseAsString.withOptions({ shallow: false })
)
const hasFilters = Boolean(tableName || action || startDate || endDate)
return (
<div className="space-y-3">
{stats.length > 0 && (
<div className="flex flex-wrap gap-2">
{stats.slice(0, 8).map((s) => (
<Badge key={s.tableName} variant="secondary" className="gap-1">
<span className="font-mono text-xs">{s.tableName}</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs">{s.count}</span>
</Badge>
))}
</div>
)}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
<Select
value={tableName || "all"}
onValueChange={(val) => setTableName(val === "all" ? null : val)}
>
<SelectTrigger className="w-[180px] bg-background">
<SelectValue placeholder="Table" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Table</SelectItem>
{tableOptions.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={action || "all"}
onValueChange={(val) => setAction(val === "all" ? null : val)}
>
<SelectTrigger className="w-[140px] bg-background">
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Action</SelectItem>
<SelectItem value="create">Create</SelectItem>
<SelectItem value="update">Update</SelectItem>
<SelectItem value="delete">Delete</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
value={startDate || ""}
onChange={(e) => setStartDate(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
value={endDate || ""}
onChange={(e) => setEndDate(e.target.value || null)}
/>
{hasFilters && (
<Button
variant="ghost"
onClick={() => {
setTableName(null)
setAction(null)
setStartDate(null)
setEndDate(null)
}}
className="h-10 px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
)
}
function ActionBadge({ action }: { action: "create" | "update" | "delete" }) {
const variant: BadgeProps["variant"] =
action === "create" ? "default" : action === "update" ? "secondary" : "destructive"
return (
<Badge
variant={variant}
className={cn(
"capitalize",
action === "create" && "bg-green-600 hover:bg-green-700 border-transparent",
action === "delete" && "bg-red-600 hover:bg-red-700 border-transparent"
)}
>
{action}
</Badge>
)
}
export function DataChangeLogTable(props: DataChangeLogTableProps) {
return (
<Suspense fallback={null}>
<DataChangeLogTableInner {...props} />
</Suspense>
)
}

View File

@@ -0,0 +1,85 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { X } from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
export function LoginLogFilters() {
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
const [startDate, setStartDate] = useQueryState(
"startDate",
parseAsString.withOptions({ shallow: false })
)
const [endDate, setEndDate] = useQueryState(
"endDate",
parseAsString.withOptions({ shallow: false })
)
const hasFilters = Boolean(action || status || startDate || endDate)
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
<Select value={action || "all"} onValueChange={(val) => setAction(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background">
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Action</SelectItem>
<SelectItem value="signin">Sign In</SelectItem>
<SelectItem value="signout">Sign Out</SelectItem>
<SelectItem value="signup">Sign Up</SelectItem>
</SelectContent>
</Select>
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Status</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
value={startDate || ""}
onChange={(e) => setStartDate(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
value={endDate || ""}
onChange={(e) => setEndDate(e.target.value || null)}
/>
{hasFilters && (
<Button
variant="ghost"
onClick={() => {
setAction(null)
setStatus(null)
setStartDate(null)
setEndDate(null)
}}
className="h-10 px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,150 @@
"use client"
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Button } from "@/shared/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { formatDate } from "@/shared/lib/utils"
import type { LoginLog } from "../types"
import { cn } from "@/shared/lib/utils"
interface LoginLogTableProps {
items: LoginLog[]
page: number
pageSize: number
total: number
totalPages: number
onPageChange: (page: number) => void
}
export function LoginLogTable({
items,
page,
pageSize,
total,
totalPages,
onPageChange,
}: LoginLogTableProps) {
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Status</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>User Agent</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
No login logs found.
</TableCell>
</TableRow>
) : (
items.map((log) => (
<TableRow key={log.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{log.userEmail}</span>
{log.userId && (
<span className="text-xs text-muted-foreground">{log.userId}</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize font-mono text-xs">
{log.action}
</Badge>
</TableCell>
<TableCell>
<StatusBadge status={log.status} />
{log.errorMessage && (
<div className="mt-1 text-xs text-destructive">{log.errorMessage}</div>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{log.ipAddress ?? "-"}
</TableCell>
<TableCell className="max-w-[280px] truncate text-xs text-muted-foreground">
{log.userAgent ?? "-"}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(log.createdAt, "zh-CN")}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between px-2 py-4">
<div className="text-sm text-muted-foreground">
{total > 0 ? (
<>
Showing <span className="font-medium">{start}</span>-
<span className="font-medium">{end}</span> of{" "}
<span className="font-medium">{total}</span> logs
</>
) : (
"No logs"
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
Page {page} of {Math.max(totalPages, 1)}
</span>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
<span className="sr-only">Previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
<span className="sr-only">Next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
function StatusBadge({ status }: { status: "success" | "failure" }) {
const variant: BadgeProps["variant"] = status === "success" ? "default" : "destructive"
return (
<Badge
variant={variant}
className={cn(
"capitalize",
status === "success" && "bg-green-600 hover:bg-green-700 border-transparent"
)}
>
{status}
</Badge>
)
}

View File

@@ -0,0 +1,59 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense } from "react"
import { LoginLogTable } from "./login-log-table"
import { LoginLogFilters } from "./login-log-filters"
import type { LoginLog } from "../types"
interface LoginLogViewProps {
items: LoginLog[]
page: number
pageSize: number
total: number
totalPages: number
}
function LoginLogViewInner({
items,
page,
pageSize,
total,
totalPages,
}: LoginLogViewProps) {
const router = useRouter()
const searchParams = useSearchParams()
const handlePageChange = (newPage: number) => {
const params = new URLSearchParams(searchParams.toString())
if (newPage <= 1) {
params.delete("page")
} else {
params.set("page", String(newPage))
}
const query = params.toString()
router.push(query ? `?${query}` : "?")
}
return (
<div className="space-y-4">
<LoginLogFilters />
<LoginLogTable
items={items}
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</div>
)
}
export function LoginLogView(props: LoginLogViewProps) {
return (
<Suspense fallback={null}>
<LoginLogViewInner {...props} />
</Suspense>
)
}

View File

@@ -0,0 +1,260 @@
import "server-only"
import { and, asc, desc, eq, gte, lte, count, like } from "drizzle-orm"
import { db } from "@/shared/db"
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
import type {
AuditLog,
AuditLogQueryParams,
DataChangeLog,
DataChangeLogQueryParams,
DataChangeStat,
LoginLog,
LoginLogQueryParams,
PaginatedResult,
} from "./types"
const toIso = (d: Date) => d.toISOString()
const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100
const clampPageSize = (size?: number) => {
if (!size || size <= 0) return DEFAULT_PAGE_SIZE
return Math.min(size, MAX_PAGE_SIZE)
}
const clampPage = (page?: number) => {
if (!page || page <= 0) return 1
return page
}
export async function getAuditLogs(
params?: AuditLogQueryParams
): Promise<PaginatedResult<AuditLog>> {
const page = clampPage(params?.page)
const pageSize = clampPageSize(params?.pageSize)
const offset = (page - 1) * pageSize
const conditions = []
if (params?.userId) conditions.push(eq(auditLogs.userId, params.userId))
if (params?.module) conditions.push(eq(auditLogs.module, params.module))
if (params?.action) conditions.push(like(auditLogs.action, `%${params.action}%`))
if (params?.status) conditions.push(eq(auditLogs.status, params.status))
if (params?.startDate) conditions.push(gte(auditLogs.createdAt, new Date(params.startDate)))
if (params?.endDate) conditions.push(lte(auditLogs.createdAt, new Date(params.endDate)))
const where = conditions.length ? and(...conditions) : undefined
try {
const [rows, totalRows] = await Promise.all([
db
.select()
.from(auditLogs)
.where(where)
.orderBy(desc(auditLogs.createdAt))
.limit(pageSize)
.offset(offset),
db.select({ value: count() }).from(auditLogs).where(where),
])
const total = Number(totalRows[0]?.value ?? 0)
return {
items: rows.map((r) => ({
id: r.id,
userId: r.userId,
userName: r.userName,
action: r.action,
module: r.module,
targetId: r.targetId ?? null,
targetType: r.targetType ?? null,
detail: r.detail ?? null,
ipAddress: r.ipAddress ?? null,
userAgent: r.userAgent ?? null,
status: r.status as "success" | "failure",
createdAt: toIso(r.createdAt),
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
} catch {
return { items: [], total: 0, page, pageSize, totalPages: 0 }
}
}
export async function getLoginLogs(
params?: LoginLogQueryParams
): Promise<PaginatedResult<LoginLog>> {
const page = clampPage(params?.page)
const pageSize = clampPageSize(params?.pageSize)
const offset = (page - 1) * pageSize
const conditions = []
if (params?.userId) conditions.push(eq(loginLogs.userId, params.userId))
if (params?.action) conditions.push(eq(loginLogs.action, params.action))
if (params?.status) conditions.push(eq(loginLogs.status, params.status))
if (params?.startDate) conditions.push(gte(loginLogs.createdAt, new Date(params.startDate)))
if (params?.endDate) conditions.push(lte(loginLogs.createdAt, new Date(params.endDate)))
const where = conditions.length ? and(...conditions) : undefined
try {
const [rows, totalRows] = await Promise.all([
db
.select()
.from(loginLogs)
.where(where)
.orderBy(desc(loginLogs.createdAt))
.limit(pageSize)
.offset(offset),
db.select({ value: count() }).from(loginLogs).where(where),
])
const total = Number(totalRows[0]?.value ?? 0)
return {
items: rows.map((r) => ({
id: r.id,
userId: r.userId ?? null,
userEmail: r.userEmail,
action: r.action as "signin" | "signout" | "signup",
status: r.status as "success" | "failure",
ipAddress: r.ipAddress ?? null,
userAgent: r.userAgent ?? null,
errorMessage: r.errorMessage ?? null,
createdAt: toIso(r.createdAt),
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
} catch {
return { items: [], total: 0, page, pageSize, totalPages: 0 }
}
}
export async function getAuditModuleOptions(): Promise<string[]> {
try {
const rows = await db
.selectDistinct({ module: auditLogs.module })
.from(auditLogs)
.orderBy(asc(auditLogs.module))
return rows.map((r) => r.module)
} catch {
return []
}
}
export async function getDataChangeLogs(
params?: DataChangeLogQueryParams
): Promise<PaginatedResult<DataChangeLog>> {
const page = clampPage(params?.page)
const pageSize = clampPageSize(params?.pageSize)
const offset = (page - 1) * pageSize
const conditions = []
if (params?.tableName) conditions.push(eq(dataChangeLogs.tableName, params.tableName))
if (params?.recordId) conditions.push(eq(dataChangeLogs.recordId, params.recordId))
if (params?.action) conditions.push(eq(dataChangeLogs.action, params.action))
if (params?.userId) conditions.push(eq(dataChangeLogs.changedBy, params.userId))
if (params?.startDate) conditions.push(gte(dataChangeLogs.createdAt, new Date(params.startDate)))
if (params?.endDate) conditions.push(lte(dataChangeLogs.createdAt, new Date(params.endDate)))
const where = conditions.length ? and(...conditions) : undefined
try {
const [rows, totalRows] = await Promise.all([
db
.select()
.from(dataChangeLogs)
.where(where)
.orderBy(desc(dataChangeLogs.createdAt))
.limit(pageSize)
.offset(offset),
db.select({ value: count() }).from(dataChangeLogs).where(where),
])
const total = Number(totalRows[0]?.value ?? 0)
return {
items: rows.map((r) => ({
id: r.id,
tableName: r.tableName,
recordId: r.recordId,
action: r.action as "create" | "update" | "delete",
oldValue: r.oldValue ?? null,
newValue: r.newValue ?? null,
changedBy: r.changedBy,
changedByName: r.changedByName,
ipAddress: r.ipAddress ?? null,
createdAt: toIso(r.createdAt),
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
} catch {
return { items: [], total: 0, page, pageSize, totalPages: 0 }
}
}
export async function getDataChangeStats(): Promise<DataChangeStat[]> {
try {
const rows = await db
.select({
tableName: dataChangeLogs.tableName,
count: count(),
})
.from(dataChangeLogs)
.groupBy(dataChangeLogs.tableName)
.orderBy(desc(count()))
return rows.map((r) => ({ tableName: r.tableName, count: Number(r.count) }))
} catch {
return []
}
}
export async function getDataChangeTableOptions(): Promise<string[]> {
try {
const rows = await db
.selectDistinct({ tableName: dataChangeLogs.tableName })
.from(dataChangeLogs)
.orderBy(asc(dataChangeLogs.tableName))
return rows.map((r) => r.tableName)
} catch {
return []
}
}
/**
* Export-ready: fetch all audit logs matching params (no pagination cap).
*/
export async function getAuditLogsForExport(
params?: AuditLogQueryParams
): Promise<AuditLog[]> {
const result = await getAuditLogs({ ...params, page: 1, pageSize: 100 })
return result.items
}
/**
* Export-ready: fetch all login logs matching params (no pagination cap).
*/
export async function getLoginLogsForExport(
params?: LoginLogQueryParams
): Promise<LoginLog[]> {
const result = await getLoginLogs({ ...params, page: 1, pageSize: 100 })
return result.items
}
/**
* Export-ready: fetch all data change logs matching params.
*/
export async function getDataChangeLogsForExport(
params?: DataChangeLogQueryParams
): Promise<DataChangeLog[]> {
const result = await getDataChangeLogs({ ...params, page: 1, pageSize: 100 })
return result.items
}

View File

@@ -0,0 +1,91 @@
export type AuditLogStatus = "success" | "failure"
export type LoginLogAction = "signin" | "signout" | "signup"
export type LoginLogStatus = "success" | "failure"
export type DataChangeAction = "create" | "update" | "delete"
export interface AuditLog {
id: string
userId: string
userName: string
action: string
module: string
targetId: string | null
targetType: string | null
detail: string | null
ipAddress: string | null
userAgent: string | null
status: AuditLogStatus
createdAt: string
}
export interface LoginLog {
id: string
userId: string | null
userEmail: string
action: LoginLogAction
status: LoginLogStatus
ipAddress: string | null
userAgent: string | null
errorMessage: string | null
createdAt: string
}
export interface DataChangeLog {
id: string
tableName: string
recordId: string
action: DataChangeAction
oldValue: string | null
newValue: string | null
changedBy: string
changedByName: string
ipAddress: string | null
createdAt: string
}
export interface DataChangeStat {
tableName: string
count: number
}
export interface AuditLogQueryParams {
userId?: string
module?: string
action?: string
status?: AuditLogStatus
page?: number
pageSize?: number
startDate?: string
endDate?: string
}
export interface LoginLogQueryParams {
userId?: string
action?: LoginLogAction
status?: LoginLogStatus
page?: number
pageSize?: number
startDate?: string
endDate?: string
}
export interface DataChangeLogQueryParams {
tableName?: string
recordId?: string
action?: DataChangeAction
userId?: string
page?: number
pageSize?: number
startDate?: string
endDate?: string
}
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}

View File

@@ -7,36 +7,79 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { cn } from "@/shared/lib/utils"
import { Loader2, Github } from "lucide-react"
import { Loader2 } from "lucide-react"
import type { ActionState } from "@/shared/types/action-state"
const ADULT_AGE = 18
function calcAge(birth: string): number | null {
if (!birth) return null
const birthDate = new Date(birth)
if (Number.isNaN(birthDate.getTime())) return null
const now = new Date()
let age = now.getFullYear() - birthDate.getFullYear()
const monthDiff = now.getMonth() - birthDate.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) {
age -= 1
}
return age >= 0 ? age : null
}
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
registerAction: (formData: FormData) => Promise<ActionState>
}
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [birthDate, setBirthDate] = React.useState<string>("")
const [agreedTerms, setAgreedTerms] = React.useState<boolean>(false)
const [agreedGuardian, setAgreedGuardian] = React.useState<boolean>(false)
const [guardianRelation, setGuardianRelation] = React.useState<string>("")
const router = useRouter()
const age = React.useMemo(() => calcAge(birthDate), [birthDate])
const isMinor = age !== null && age < ADULT_AGE
async function onSubmit(event: React.SyntheticEvent) {
event.preventDefault()
setIsLoading(true)
if (!agreedTerms) {
toast.error("请阅读并同意《隐私政策》和《用户协议》后再注册")
return
}
if (isMinor && !agreedGuardian) {
toast.error("未成年人注册须确认已获得监护人同意")
return
}
if (isMinor && !guardianRelation) {
toast.error("请选择监护人与您的关系")
return
}
setIsLoading(true)
try {
const form = event.currentTarget as HTMLFormElement
const formData = new FormData(form)
const res = await registerAction(formData)
if (res.success) {
toast.success(res.message || "Account created")
toast.success(res.message || "账户创建成功")
router.push("/login")
router.refresh()
} else {
toast.error(res.message || "Failed to create account")
toast.error(res.message || "注册失败")
}
} catch {
toast.error("Failed to create account")
toast.error("注册失败")
} finally {
setIsLoading(false)
}
@@ -45,21 +88,19 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
return (
<div className={cn("grid gap-6", className)} {...props}>
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Create an account
</h1>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
Enter your email below to create your account
</p>
</div>
<form onSubmit={onSubmit}>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">Full Name</Label>
<Label htmlFor="name"></Label>
<Input
id="name"
name="name"
placeholder="John Doe"
placeholder="请输入姓名"
type="text"
autoCapitalize="words"
autoComplete="name"
@@ -68,7 +109,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email"></Label>
<Input
id="email"
name="email"
@@ -81,7 +122,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password"></Label>
<Input
id="password"
name="password"
@@ -90,39 +131,127 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
disabled={isLoading}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="birthDate"></Label>
<Input
id="birthDate"
name="birthDate"
type="date"
disabled={isLoading}
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
/>
{age !== null && (
<p className="text-xs text-muted-foreground">{age} </p>
)}
</div>
{isMinor && (
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
</p>
<div className="grid gap-2">
<Label htmlFor="guardianName"></Label>
<Input
id="guardianName"
name="guardianName"
placeholder="请输入监护人姓名"
type="text"
disabled={isLoading}
required={isMinor}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="guardianPhone"></Label>
<Input
id="guardianPhone"
name="guardianPhone"
placeholder="请输入监护人手机号"
type="tel"
disabled={isLoading}
required={isMinor}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="guardianRelation"></Label>
<Select value={guardianRelation} onValueChange={setGuardianRelation}>
<SelectTrigger id="guardianRelation">
<SelectValue placeholder="请选择关系" />
</SelectTrigger>
<SelectContent>
<SelectItem value="父亲"></SelectItem>
<SelectItem value="母亲"></SelectItem>
<SelectItem value="其他法定监护人"></SelectItem>
</SelectContent>
</Select>
<input
type="hidden"
name="guardianRelation"
value={guardianRelation}
/>
</div>
</div>
)}
<div className="flex items-start gap-2">
<Checkbox
id="agreeTerms"
checked={agreedTerms}
onCheckedChange={(v) => setAgreedTerms(v === true)}
disabled={isLoading}
/>
<Label htmlFor="agreeTerms" className="text-sm leading-relaxed font-normal">
<Link
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
>
</Link>
<Link
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
>
</Link>
</Label>
</div>
{isMinor && (
<div className="flex items-start gap-2">
<Checkbox
id="agreeGuardian"
checked={agreedGuardian}
onCheckedChange={(v) => setAgreedGuardian(v === true)}
disabled={isLoading}
/>
<Label htmlFor="agreeGuardian" className="text-sm leading-relaxed font-normal">
使
</Label>
</div>
)}
<Button disabled={isLoading}>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Account
</Button>
</div>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button variant="outline" type="button" disabled={isLoading}>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Github className="mr-2 h-4 w-4" />
)}{" "}
GitHub
</Button>
<p className="px-8 text-center text-sm text-muted-foreground">
Already have an account?{" "}
{" "}
<Link
href="/login"
className="underline underline-offset-4 hover:text-primary"
>
Sign in
</Link>
</p>
</div>

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
}

View File

@@ -0,0 +1,289 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Files, Search, Trash2, HardDrive, FileWarning } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
import { formatFileSize } from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon"
import { FileUpload } from "./file-upload"
import { FilePreviewDialog } from "./file-preview-dialog"
import type { FileAttachment, FileStats } from "../types"
interface AdminFilesViewProps {
files: FileAttachment[]
stats: FileStats
}
// 文件类型分组选项
const TYPE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "all", label: "All Types" },
{ value: "image/", label: "Images" },
{ value: "application/pdf", label: "PDF" },
{ value: "application/msword", label: "Word" },
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", label: "Word (docx)" },
{ value: "application/vnd.ms-excel", label: "Excel" },
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", label: "Excel (xlsx)" },
{ value: "application/vnd.ms-powerpoint", label: "PowerPoint" },
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", label: "PowerPoint (pptx)" },
{ value: "text/", label: "Text" },
{ value: "application/zip", label: "ZIP" },
]
export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
const router = useRouter()
const [typeFilter, setTypeFilter] = useState<string>("all")
const [search, setSearch] = useState<string>("")
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [deleting, setDeleting] = useState(false)
// 客户端二次筛选(与 server 端筛选互补,提升交互即时性)
const filteredFiles = useMemo(() => {
return files.filter((f) => {
if (typeFilter !== "all") {
if (typeFilter.endsWith("/")) {
if (!f.mimeType.startsWith(typeFilter)) return false
} else if (f.mimeType !== typeFilter) {
return false
}
}
if (search.trim()) {
const kw = search.trim().toLowerCase()
if (
!f.originalName.toLowerCase().includes(kw) &&
!f.filename.toLowerCase().includes(kw)
) {
return false
}
}
return true
})
}, [files, typeFilter, search])
const allSelected = filteredFiles.length > 0 && selectedIds.size === filteredFiles.length
const someSelected = selectedIds.size > 0 && !allSelected
const toggleAll = () => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredFiles.map((f) => f.id)))
}
}
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleUploaded = () => {
router.refresh()
}
const handleDeleted = () => {
router.refresh()
setSelectedIds(new Set())
}
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return
const ids = Array.from(selectedIds)
setDeleting(true)
try {
const res = await fetch("/api/files/batch-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
})
const body = await res.json().catch(() => null)
if (!res.ok || !body?.success) {
toast.error(body?.message || "Failed to delete files")
return
}
toast.success(`Deleted ${body.deletedCount} file(s)`)
handleDeleted()
} catch {
toast.error("Failed to delete files")
} finally {
setDeleting(false)
}
}
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div className="space-y-1">
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Files className="h-6 w-6" />
Files
</h2>
<p className="text-muted-foreground">
Upload and manage all files in the system.
</p>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Files className="h-3.5 w-3.5" />
Total Files
</div>
<p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
</div>
<div className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" />
Total Size
</div>
<p className="mt-1 text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
</div>
{stats.byType.slice(0, 2).map((t) => (
<div key={t.mimeType} className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon mimeType={t.mimeType} className="h-3.5 w-3.5" />
<span className="truncate" title={t.mimeType}>{t.mimeType}</span>
</div>
<p className="mt-1 text-2xl font-bold">{t.count}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(t.size)}</p>
</div>
))}
</div>
<FileUpload onUploaded={handleUploaded} />
{/* 筛选与批量操作工具栏 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-full sm:w-[200px]">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by file name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
</div>
{selectedIds.size > 0 ? (
<div className="flex items-center gap-2">
<Badge variant="secondary">{selectedIds.size} selected</Badge>
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleting}
onClick={() => void handleBatchDelete()}
>
<Trash2 className="mr-2 h-4 w-4" />
{deleting ? "Deleting..." : "Delete Selected"}
</Button>
</div>
) : null}
</div>
{/* 文件列表 */}
{filteredFiles.length === 0 ? (
<EmptyState
title="No files found"
description="Try adjusting your filters or upload a new file."
icon={FileWarning}
className="h-auto border-none shadow-none"
/>
) : (
<div className="rounded-md border">
<div className="flex items-center gap-3 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
<span className="flex-1">File</span>
<span className="hidden w-24 sm:block">Size</span>
<span className="hidden w-32 md:block">Type</span>
<span className="hidden w-32 md:block">Uploaded</span>
<span className="w-24 text-right">Actions</span>
</div>
<ul className="divide-y">
{filteredFiles.map((file) => {
const checked = selectedIds.has(file.id)
return (
<li
key={file.id}
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggleOne(file.id)}
aria-label={`Select ${file.originalName}`}
/>
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
<div className="min-w-0 flex-1">
<a
href={file.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={file.originalName}
>
{file.originalName}
</a>
<p className="mt-0.5 text-xs text-muted-foreground sm:hidden">
{formatFileSize(file.size)} · {formatDate(file.createdAt, "zh-CN")}
</p>
</div>
<span className="hidden w-24 shrink-0 text-xs text-muted-foreground sm:block">
{formatFileSize(file.size)}
</span>
<span className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block" title={file.mimeType}>
{file.mimeType}
</span>
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
{formatDate(file.createdAt, "zh-CN")}
</span>
<div className="flex w-24 shrink-0 justify-end gap-1">
<FilePreviewDialog
file={file}
triggerLabel=""
triggerVariant="ghost"
triggerSize="icon"
/>
</div>
</li>
)
})}
</ul>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,86 @@
import {
File as FileLucide,
FileText,
FileImage,
FileArchive,
FileSpreadsheet,
Presentation,
FileType,
} from "lucide-react"
import type { ComponentType } from "react"
import { cn } from "@/shared/lib/utils"
type FileCategory =
| "image"
| "pdf"
| "word"
| "excel"
| "powerpoint"
| "text"
| "archive"
| "other"
const ICON_MAP: Record<FileCategory, ComponentType<{ className?: string }>> = {
image: FileImage,
pdf: FileText,
word: FileText,
excel: FileSpreadsheet,
powerpoint: Presentation,
text: FileType,
archive: FileArchive,
other: FileLucide,
}
const COLOR_MAP: Record<FileCategory, string> = {
image: "text-pink-600",
pdf: "text-red-600",
word: "text-blue-600",
excel: "text-green-600",
powerpoint: "text-orange-600",
text: "text-gray-600",
archive: "text-yellow-600",
other: "text-muted-foreground",
}
function resolveCategory(mimeType: string): FileCategory {
if (mimeType.startsWith("image/")) return "image"
if (mimeType === "application/pdf") return "pdf"
if (
mimeType === "application/msword" ||
mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
) {
return "word"
}
if (
mimeType === "application/vnd.ms-excel" ||
mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
) {
return "excel"
}
if (
mimeType === "application/vnd.ms-powerpoint" ||
mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation"
) {
return "powerpoint"
}
if (mimeType === "text/plain" || mimeType === "text/markdown") return "text"
if (mimeType === "application/zip" || mimeType === "application/x-rar-compressed") {
return "archive"
}
return "other"
}
export function FileIcon({
mimeType,
className,
}: {
mimeType: string
className?: string
}) {
const category = resolveCategory(mimeType)
const Icon = ICON_MAP[category]
return (
<Icon className={cn("h-5 w-5", COLOR_MAP[category], className)} aria-hidden="true" />
)
}

View File

@@ -0,0 +1,126 @@
"use client"
import { useState } from "react"
import { Download, Trash2, FileWarning } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
import { formatFileSize } from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon"
import type { FileAttachment } from "../types"
interface FileListProps {
files: FileAttachment[]
canDelete?: boolean
onDeleted?: (id: string) => void
emptyTitle?: string
emptyDescription?: string
}
export function FileList({
files,
canDelete = false,
onDeleted,
emptyTitle = "No files",
emptyDescription = "There are no files yet.",
}: FileListProps) {
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = async (file: FileAttachment) => {
setDeletingId(file.id)
try {
const res = await fetch(`/api/files/${file.id}`, { method: "DELETE" })
const body = await res.json().catch(() => null)
if (!res.ok || !body?.success) {
toast.error(body?.message || "Failed to delete file")
return
}
toast.success("File deleted")
onDeleted?.(file.id)
} catch {
toast.error("Failed to delete file")
} finally {
setDeletingId(null)
}
}
if (files.length === 0) {
return (
<EmptyState
title={emptyTitle}
description={emptyDescription}
icon={FileWarning}
className="h-auto border-none shadow-none"
/>
)
}
return (
<ul className="divide-y rounded-md border">
{files.map((file) => (
<li
key={file.id}
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
>
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<a
href={file.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={file.originalName}
>
{file.originalName}
</a>
<span className="shrink-0 text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{file.mimeType}</span>
<span>·</span>
<span>{formatDate(file.createdAt, "zh-CN")}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8"
title="Download"
>
<a
href={file.url ?? "#"}
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4" />
<span className="sr-only">Download</span>
</a>
</Button>
{canDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
title="Delete"
disabled={deletingId === file.id}
onClick={() => void handleDelete(file)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
) : null}
</div>
</li>
))}
</ul>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { Eye } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { FilePreview } from "./file-preview"
import type { FileAttachment } from "../types"
interface FilePreviewDialogProps {
file: FileAttachment
trigger?: React.ReactNode
triggerLabel?: string
triggerVariant?: "default" | "outline" | "secondary" | "ghost" | "destructive"
triggerSize?: "default" | "sm" | "lg" | "icon"
}
export function FilePreviewDialog({
file,
trigger,
triggerLabel = "Preview",
triggerVariant = "outline",
triggerSize = "sm",
}: FilePreviewDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button type="button" variant={triggerVariant} size={triggerSize}>
<Eye className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"} />
{triggerLabel ? <span>{triggerLabel}</span> : <span className="sr-only">Preview</span>}
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-5xl overflow-hidden">
<DialogHeader>
<DialogTitle className="truncate">{file.originalName}</DialogTitle>
<DialogDescription>
File preview · {file.mimeType}
</DialogDescription>
</DialogHeader>
<div className="max-h-[75vh] overflow-auto">
<FilePreview file={file} />
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,234 @@
"use client"
import { useState } from "react"
import { Download, ZoomIn, ZoomOut, FileText } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FileIcon } from "./file-icon"
import { formatFileSize } from "@/shared/lib/file-storage"
import type { FileAttachment } from "../types"
interface FilePreviewProps {
file: FileAttachment
className?: string
}
type PreviewKind = "image" | "pdf" | "text" | "office" | "other"
const TEXT_MIME_TYPES = new Set([
"text/plain",
"text/markdown",
"text/csv",
"application/json",
"text/html",
"text/css",
"text/javascript",
])
const OFFICE_MIME_TYPES = new Set([
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
])
function classify(mimeType: string): PreviewKind {
if (mimeType.startsWith("image/")) return "image"
if (mimeType === "application/pdf") return "pdf"
if (TEXT_MIME_TYPES.has(mimeType)) return "text"
if (OFFICE_MIME_TYPES.has(mimeType)) return "office"
return "other"
}
export function FilePreview({ file, className }: FilePreviewProps) {
const kind = classify(file.mimeType)
const url = file.url ?? "#"
return (
<div className={className}>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<FileIcon mimeType={file.mimeType} className="h-5 w-5" />
<div className="min-w-0">
<p className="truncate text-sm font-medium" title={file.originalName}>
{file.originalName}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} · {file.mimeType}
</p>
</div>
</div>
<Button asChild variant="outline" size="sm">
<a
href={url}
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
</div>
<PreviewBody kind={kind} file={file} url={url} />
</div>
)
}
function PreviewBody({
kind,
file,
url,
}: {
kind: PreviewKind
file: FileAttachment
url: string
}) {
if (kind === "image") {
return <ImagePreview url={url} alt={file.originalName} />
}
if (kind === "pdf") {
return (
<iframe
src={url}
title={file.originalName}
className="h-[70vh] w-full rounded-md border"
/>
)
}
if (kind === "text") {
return <TextPreview url={url} />
}
// Office / other: show info card + download button
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
<p className="mt-3 text-sm font-medium">
{kind === "office" ? "Office file preview not available" : "Preview not available"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{kind === "office"
? "Download the file to view its contents in your Office application."
: "Download the file to view its contents."}
</p>
<Button asChild variant="outline" size="sm" className="mt-4">
<a
href={url}
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
</div>
)
}
function ImagePreview({ url, alt }: { url: string; alt: string }) {
const [zoom, setZoom] = useState(1)
return (
<div className="space-y-2">
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setZoom((z) => Math.max(0.25, z - 0.25))}
disabled={zoom <= 0.25}
>
<ZoomOut className="h-4 w-4" />
<span className="sr-only">Zoom out</span>
</Button>
<span className="text-xs text-muted-foreground w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setZoom((z) => Math.min(4, z + 0.25))}
disabled={zoom >= 4}
>
<ZoomIn className="h-4 w-4" />
<span className="sr-only">Zoom in</span>
</Button>
</div>
<div className="overflow-auto rounded-md border bg-muted/30 p-2" style={{ maxHeight: "70vh" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={alt}
style={{ transform: `scale(${zoom})`, transformOrigin: "top left" }}
className="mx-auto max-w-full transition-transform"
/>
</div>
</div>
)
}
function TextPreview({ url }: { url: string }) {
const [content, setContent] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text()
setContent(text)
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load text")
} finally {
setLoading(false)
}
}
if (content === null && !error && !loading) {
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">Text file</p>
<p className="mt-1 text-xs text-muted-foreground">Click below to load the content.</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => void load()}>
Load preview
</Button>
</div>
)
}
if (loading) {
return (
<div className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
Loading...
</div>
)
}
if (error) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
Failed to load text: {error}
</div>
)
}
return (
<pre className="max-h-[70vh] overflow-auto rounded-md border bg-background p-4 text-xs leading-relaxed">
<code>{content}</code>
</pre>
)
}

View File

@@ -0,0 +1,249 @@
"use client"
import { useCallback, useRef, useState } from "react"
import { UploadCloud, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Progress } from "@/shared/components/ui/progress"
import { cn } from "@/shared/lib/utils"
import {
ALLOWED_MIME_TYPES,
formatFileSize,
MAX_FILE_SIZE,
} from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon"
import type { FileTargetType, FileUploadResult } from "../types"
interface FileUploadProps {
targetType?: FileTargetType
targetId?: string
onUploaded?: (result: FileUploadResult) => void
multiple?: boolean
className?: string
}
interface UploadTask {
file: File
progress: number
status: "uploading" | "success" | "error"
message?: string
result?: FileUploadResult
}
const ACCEPT_ATTR = (ALLOWED_MIME_TYPES as readonly string[]).join(",")
export function FileUpload({
targetType,
targetId,
onUploaded,
multiple = true,
className,
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [tasks, setTasks] = useState<UploadTask[]>([])
const validateFile = (file: File): string | null => {
if (file.size === 0) return "File is empty"
if (file.size > MAX_FILE_SIZE) return "File size exceeds 10MB limit"
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) {
return `File type ${file.type || "unknown"} is not allowed`
}
return null
}
const uploadOne = useCallback(
async (file: File): Promise<void> => {
const taskId = `${file.name}-${file.size}-${Date.now()}`
setTasks((prev) => [
...prev,
{ file, progress: 0, status: "uploading" },
])
const validationError = validateFile(file)
if (validationError) {
setTasks((prev) =>
prev.map((t) =>
t.file === file
? { ...t, status: "error", message: validationError, progress: 100 }
: t
)
)
toast.error(`${file.name}: ${validationError}`)
return
}
try {
const formData = new FormData()
formData.append("file", file)
if (targetType) formData.append("targetType", targetType)
if (targetId) formData.append("targetId", targetId)
const xhr = new XMLHttpRequest()
const result = await new Promise<FileUploadResult>((resolve, reject) => {
xhr.open("POST", "/api/upload")
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100)
setTasks((prev) =>
prev.map((t) =>
t.file === file ? { ...t, progress: pct } : t
)
)
}
}
xhr.onload = () => {
try {
const body = JSON.parse(xhr.responseText)
if (xhr.status >= 200 && xhr.status < 300 && body.success) {
resolve(body as FileUploadResult)
} else {
reject(new Error(body.message || "Upload failed"))
}
} catch {
reject(new Error("Invalid response"))
}
}
xhr.onerror = () => reject(new Error("Network error"))
xhr.send(formData)
})
setTasks((prev) =>
prev.map((t) =>
t.file === file
? { ...t, status: "success", progress: 100, result }
: t
)
)
onUploaded?.(result)
toast.success(`${file.name} uploaded`)
void taskId
} catch (e) {
const message = e instanceof Error ? e.message : "Upload failed"
setTasks((prev) =>
prev.map((t) =>
t.file === file ? { ...t, status: "error", message } : t
)
)
toast.error(`${file.name}: ${message}`)
}
},
[targetType, targetId, onUploaded]
)
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return
const files = Array.from(fileList)
if (!multiple) {
void uploadOne(files[0])
} else {
files.forEach((f) => void uploadOne(f))
}
},
[uploadOne, multiple]
)
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
},
[handleFiles]
)
const removeTask = (task: UploadTask) => {
setTasks((prev) => prev.filter((t) => t !== task))
}
return (
<div className={cn("space-y-4", className)}>
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
inputRef.current?.click()
}
}}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={cn(
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-input hover:border-primary/50 hover:bg-accent/50"
)}
>
<UploadCloud className="h-10 w-10 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
Click to upload or drag and drop
</p>
<p className="mt-1 text-xs text-muted-foreground">
Images, PDF, Word, Excel, PPT, Text, ZIP / RAR · up to {formatFileSize(MAX_FILE_SIZE)}
</p>
<input
ref={inputRef}
type="file"
className="hidden"
accept={ACCEPT_ATTR}
multiple={multiple}
onChange={(e) => {
handleFiles(e.target.files)
e.target.value = ""
}}
/>
</div>
{tasks.length > 0 ? (
<ul className="space-y-2">
{tasks.map((task, idx) => (
<li
key={`${task.file.name}-${idx}`}
className="flex items-center gap-3 rounded-md border p-3"
>
<FileIcon mimeType={task.file.type} />
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{task.file.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatFileSize(task.file.size)}
</span>
</div>
{task.status === "uploading" ? (
<Progress value={task.progress} className="h-1.5" />
) : null}
{task.status === "error" ? (
<p className="text-xs text-destructive">{task.message}</p>
) : null}
{task.status === "success" ? (
<p className="text-xs text-green-600">Uploaded</p>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => removeTask(task)}
aria-label="Remove"
>
<X className="h-4 w-4" />
</Button>
</li>
))}
</ul>
) : null}
</div>
)
}

View File

@@ -0,0 +1,267 @@
import "server-only"
import { and, count, desc, eq, inArray, like, or, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { fileAttachments } from "@/shared/db/schema"
import type {
BatchDeleteResult,
CreateFileAttachmentInput,
FileAttachment,
FileAttachmentQueryParams,
FileStats,
} from "./types"
const toIso = (d: Date): string => d.toISOString()
const mapRow = (row: typeof fileAttachments.$inferSelect): FileAttachment => ({
id: row.id,
filename: row.filename,
originalName: row.originalName,
mimeType: row.mimeType,
size: row.size,
storagePath: row.storagePath,
url: row.url,
uploaderId: row.uploaderId,
targetType: row.targetType,
targetId: row.targetId,
createdAt: toIso(row.createdAt),
})
/**
* 插入文件附件记录
*/
export async function createFileAttachment(
data: CreateFileAttachmentInput
): Promise<FileAttachment | null> {
try {
await db.insert(fileAttachments).values({
id: data.id,
filename: data.filename,
originalName: data.originalName,
mimeType: data.mimeType,
size: data.size,
storagePath: data.storagePath,
url: data.url,
uploaderId: data.uploaderId,
targetType: data.targetType ?? null,
targetId: data.targetId ?? null,
})
const created = await getFileAttachment(data.id)
return created
} catch {
return null
}
}
/**
* 按 ID 查询文件附件
*/
export async function getFileAttachment(id: string): Promise<FileAttachment | null> {
try {
const [row] = await db
.select()
.from(fileAttachments)
.where(eq(fileAttachments.id, id))
.limit(1)
return row ? mapRow(row) : null
} catch {
return null
}
}
/**
* 按关联资源查询文件列表
*/
export async function getFileAttachmentsByTarget(
targetType: string,
targetId: string
): Promise<FileAttachment[]> {
try {
const rows = await db
.select()
.from(fileAttachments)
.where(
and(
eq(fileAttachments.targetType, targetType),
eq(fileAttachments.targetId, targetId)
)
)
.orderBy(desc(fileAttachments.createdAt))
return rows.map(mapRow)
} catch {
return []
}
}
/**
* 按上传者查询文件列表
*/
export async function getFileAttachmentsByUploader(
uploaderId: string
): Promise<FileAttachment[]> {
try {
const rows = await db
.select()
.from(fileAttachments)
.where(eq(fileAttachments.uploaderId, uploaderId))
.orderBy(desc(fileAttachments.createdAt))
return rows.map(mapRow)
} catch {
return []
}
}
/**
* 查询所有文件(用于管理员文件管理页面)
*/
export async function getAllFileAttachments(limit = 100): Promise<FileAttachment[]> {
try {
const rows = await db
.select()
.from(fileAttachments)
.orderBy(desc(fileAttachments.createdAt))
.limit(limit)
return rows.map(mapRow)
} catch {
return []
}
}
/**
* 删除文件附件记录
*/
export async function deleteFileAttachment(id: string): Promise<boolean> {
try {
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
return true
} catch {
return false
}
}
/**
* 批量删除文件附件记录
* 仅删除数据库记录,磁盘文件由调用方处理
*/
export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteResult> {
if (ids.length === 0) {
return { success: true, deletedCount: 0, failedIds: [] }
}
try {
await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids))
return { success: true, deletedCount: ids.length, failedIds: [] }
} catch {
// 失败时回退到逐条删除,尽量多删
const failedIds: string[] = []
let deletedCount = 0
for (const id of ids) {
try {
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
deletedCount += 1
} catch {
failedIds.push(id)
}
}
return {
success: failedIds.length === 0,
deletedCount,
failedIds,
}
}
}
/**
* 按条件筛选文件列表(管理员页面)
* - mimeType: 精确匹配或前缀匹配(如 "image/"
* - search: 在 originalName / filename 中模糊匹配
*/
export async function getFileAttachmentsWithFilters(
params: FileAttachmentQueryParams
): Promise<FileAttachment[]> {
try {
const { mimeType, search, limit = 100, offset = 0 } = params
const conditions = []
if (mimeType) {
if (mimeType.endsWith("/")) {
conditions.push(like(fileAttachments.mimeType, `${mimeType}%`))
} else {
conditions.push(eq(fileAttachments.mimeType, mimeType))
}
}
if (search) {
const kw = `%${search}%`
conditions.push(
or(
like(fileAttachments.originalName, kw),
like(fileAttachments.filename, kw)
)!
)
}
const where = conditions.length > 0 ? and(...conditions) : undefined
const rows = await db
.select()
.from(fileAttachments)
.where(where)
.orderBy(desc(fileAttachments.createdAt))
.limit(limit)
.offset(offset)
return rows.map(mapRow)
} catch {
return []
}
}
/**
* 获取文件统计信息(总数、总大小、按类型分组)
*/
export async function getFileStats(): Promise<FileStats> {
try {
const rows = await db
.select({
mimeType: fileAttachments.mimeType,
count: count(),
size: sql<number>`COALESCE(SUM(${fileAttachments.size}), 0)`,
})
.from(fileAttachments)
.groupBy(fileAttachments.mimeType)
const byType = rows.map((r) => ({
mimeType: r.mimeType,
count: Number(r.count),
size: Number(r.size),
}))
const totalCount = byType.reduce((sum, r) => sum + r.count, 0)
const totalSize = byType.reduce((sum, r) => sum + r.size, 0)
return { totalCount, totalSize, byType }
} catch {
return { totalCount: 0, totalSize: 0, byType: [] }
}
}
/**
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
*/
export async function getFileAttachmentsByIds(ids: string[]): Promise<FileAttachment[]> {
if (ids.length === 0) return []
try {
const rows = await db
.select()
.from(fileAttachments)
.where(inArray(fileAttachments.id, ids))
return rows.map(mapRow)
} catch {
return []
}
}

View File

@@ -0,0 +1,63 @@
// 文件关联的目标资源类型(多态关联)
export type FileTargetType = "exam" | "textbook" | "question" | "announcement"
// 文件附件记录DB 行的 TypeScript 表示)
export interface FileAttachment {
id: string
filename: string
originalName: string
mimeType: string
size: number
storagePath: string
url: string | null
uploaderId: string
targetType: string | null
targetId: string | null
createdAt: string
}
// 上传成功后返回给前端的结果
export interface FileUploadResult {
id: string
url: string
filename: string
originalName: string
size: number
mimeType: string
}
// 创建文件附件记录的输入
export interface CreateFileAttachmentInput {
id: string
filename: string
originalName: string
mimeType: string
size: number
storagePath: string
url: string | null
uploaderId: string
targetType?: string | null
targetId?: string | null
}
// 文件查询参数(管理员页面筛选)
export interface FileAttachmentQueryParams {
mimeType?: string | null
search?: string | null
limit?: number
offset?: number
}
// 文件统计信息
export interface FileStats {
totalCount: number
totalSize: number
byType: Array<{ mimeType: string; count: number; size: number }>
}
// 批量删除结果
export interface BatchDeleteResult {
success: boolean
deletedCount: number
failedIds: string[]
}

View File

@@ -0,0 +1,133 @@
"use server"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import {
getClassComparison,
getGradeDistribution,
getGradeTrend,
getSubjectComparison,
type ClassComparisonParams,
type GradeDistributionParams,
type GradeTrendParams,
type SubjectComparisonParams,
} from "./data-access-analytics"
import { getRankingTrend } from "./data-access-ranking"
import type {
ClassComparisonItem,
GradeDistributionResult,
GradeTrendResult,
RankingTrendResult,
SubjectComparisonItem,
} from "./types"
export async function getGradeTrendAction(
params: Omit<GradeTrendParams, "scope" | "currentUserId">
): Promise<ActionState<GradeTrendResult | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getGradeTrend({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
return { success: true, data: result }
} catch (e) {
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" }
}
}
export async function getClassComparisonAction(
params: Omit<ClassComparisonParams, "scope">
): Promise<ActionState<ClassComparisonItem[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getClassComparison({
...params,
scope: ctx.dataScope,
})
return { success: true, data: result }
} catch (e) {
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" }
}
}
export async function getSubjectComparisonAction(
params: Omit<SubjectComparisonParams, "scope">
): Promise<ActionState<SubjectComparisonItem[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getSubjectComparison({
...params,
scope: ctx.dataScope,
})
return { success: true, data: result }
} catch (e) {
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" }
}
}
export async function getGradeDistributionAction(
params: Omit<GradeDistributionParams, "scope" | "currentUserId">
): Promise<ActionState<GradeDistributionResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getGradeDistribution({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
return { success: true, data: result }
} catch (e) {
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" }
}
}
export async function getRankingTrendAction(
studentId: string,
subjectId?: string,
semester?: "1" | "2"
): Promise<ActionState<RankingTrendResult | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
// Students can only view their own ranking trend
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
return { success: false, message: "Can only view your own ranking trend" }
}
// Parents can only view their children's ranking trend
if (
ctx.dataScope.type === "children" &&
!ctx.dataScope.childrenIds.includes(studentId)
) {
return { success: false, message: "Can only view your children's ranking trend" }
}
const result = await getRankingTrend(studentId, subjectId, semester)
return { success: true, data: result }
} catch (e) {
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" }
}
}

View File

@@ -0,0 +1,312 @@
"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 {
CreateGradeRecordSchema,
BatchCreateGradeRecordSchema,
UpdateGradeRecordSchema,
} from "./schema"
import {
createGradeRecord,
batchCreateGradeRecords,
updateGradeRecord,
deleteGradeRecord,
getGradeRecords,
getGradeRecordById,
getClassGradeStatsWithMeta,
getStudentGradeSummary,
getClassRanking,
} from "./data-access"
import {
exportGradeRecordsToExcel,
exportClassGradeReportToExcel,
formatDateForFile,
} from "./export"
import type { GradeQueryParams, GradeRecordListItem, GradeStats } from "./types"
export async function createGradeRecordAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const parsed = CreateGradeRecordSchema.safeParse({
studentId: formData.get("studentId"),
classId: formData.get("classId"),
subjectId: formData.get("subjectId"),
examId: formData.get("examId") || undefined,
academicYearId: formData.get("academicYearId") || undefined,
title: formData.get("title"),
score: formData.get("score"),
fullScore: formData.get("fullScore") || undefined,
type: formData.get("type") || undefined,
semester: formData.get("semester") || undefined,
remark: formData.get("remark") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await createGradeRecord(parsed.data, ctx.userId)
revalidatePath("/teacher/grades")
return { success: true, message: "Grade record created", data: id }
} catch (e) {
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" }
}
}
export async function batchCreateGradeRecordsAction(
prevState: ActionState<number> | null,
formData: FormData
): Promise<ActionState<number>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const recordsJson = formData.get("recordsJson")
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
return { success: false, message: "Missing records data" }
}
const parsed = BatchCreateGradeRecordSchema.safeParse({
classId: formData.get("classId"),
subjectId: formData.get("subjectId"),
examId: formData.get("examId") || undefined,
academicYearId: formData.get("academicYearId") || undefined,
title: formData.get("title"),
fullScore: formData.get("fullScore") || undefined,
type: formData.get("type") || undefined,
semester: formData.get("semester") || undefined,
records: JSON.parse(recordsJson),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const count = await batchCreateGradeRecords(parsed.data, ctx.userId)
revalidatePath("/teacher/grades")
return { success: true, message: `Created ${count} grade records`, data: count }
} catch (e) {
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" }
}
}
export async function updateGradeRecordAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const parsed = UpdateGradeRecordSchema.safeParse({
title: formData.get("title") || undefined,
score: formData.get("score") || undefined,
fullScore: formData.get("fullScore") || undefined,
type: formData.get("type") || undefined,
semester: formData.get("semester") || undefined,
remark: formData.get("remark") || undefined,
examId: formData.get("examId") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
await updateGradeRecord(id, parsed.data)
revalidatePath("/teacher/grades")
return { success: true, message: "Grade record updated" }
} catch (e) {
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" }
}
}
export async function deleteGradeRecordAction(
id: string
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
await deleteGradeRecord(id)
revalidatePath("/teacher/grades")
return { success: true, message: "Grade record deleted" }
} catch (e) {
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" }
}
}
export async function getGradeRecordsAction(
params: GradeQueryParams
): Promise<ActionState<GradeRecordListItem[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const records = await getGradeRecords({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
return { success: true, data: records }
} catch (e) {
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" }
}
}
export async function getClassGradeStatsAction(
classId: string,
subjectId?: string,
examId?: string
): Promise<ActionState<GradeStats | null>> {
try {
await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getClassGradeStatsWithMeta(classId, subjectId, examId)
return { success: true, data: result?.stats ?? null }
} catch (e) {
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" }
}
}
export async function getStudentGradeSummaryAction(
studentId: string
): Promise<ActionState<Awaited<ReturnType<typeof getStudentGradeSummary>>>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
return { success: false, message: "Can only view your own grades" }
}
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
return { success: false, message: "Can only view your children's grades" }
}
const summary = await getStudentGradeSummary(studentId)
return { success: true, data: summary }
} catch (e) {
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" }
}
}
export async function getClassRankingAction(
classId: string,
subjectId?: string,
examId?: string
): Promise<ActionState<Awaited<ReturnType<typeof getClassRanking>>>> {
try {
await requirePermission(Permissions.GRADE_RECORD_READ)
const ranking = await getClassRanking(classId, subjectId, examId)
return { success: true, data: ranking }
} catch (e) {
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" }
}
}
export async function getGradeRecordByIdAction(
id: string
): Promise<ActionState<Awaited<ReturnType<typeof getGradeRecordById>>>> {
try {
await requirePermission(Permissions.GRADE_RECORD_READ)
const record = await getGradeRecordById(id)
return { success: true, data: record }
} catch (e) {
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" }
}
}
/**
* 导出成绩单(返回 base64 编码的 Excel
*/
export async function exportGradesAction(params: {
classId: string
subjectId?: string
examId?: string
reportType?: "detail" | "class"
}): Promise<ActionState<{ buffer: string; filename: string }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
let buffer: Buffer
let filename: string
if (params.reportType === "class") {
buffer = await exportClassGradeReportToExcel({
classId: params.classId,
scope: ctx.dataScope,
})
filename = `班级成绩总表_${formatDateForFile()}.xlsx`
} else {
buffer = await exportGradeRecordsToExcel({
classId: params.classId,
subjectId: params.subjectId,
examId: params.examId,
scope: ctx.dataScope,
})
filename = `成绩单_${formatDateForFile()}.xlsx`
}
return {
success: true,
data: {
buffer: buffer.toString("base64"),
filename,
},
}
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "导出失败" }
}
}

Some files were not shown because too many files have changed in this diff Show More