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,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: "导出失败" }
}
}

View File

@@ -0,0 +1,219 @@
"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { batchCreateGradeRecordsAction } from "../actions"
type Option = { id: string; name: string }
type Student = { id: string; name: string; email: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save All Grades"}
</Button>
)
}
export function BatchGradeEntry({
classes,
subjects,
students,
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
students: Student[]
defaultClassId?: string
defaultSubjectId?: string
}) {
const router = useRouter()
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
const [semester, setSemester] = useState<"1" | "2">("1")
const [scores, setScores] = useState<Record<string, string>>({})
const handleScoreChange = (studentId: string, value: string) => {
setScores((prev) => ({ ...prev, [studentId]: value }))
}
const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId) {
toast.error("Please select class and subject")
return
}
const records = students
.map((s) => ({
studentId: s.id,
score: Number(scores[s.id] ?? 0),
remark: undefined as string | undefined,
}))
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
if (records.length === 0) {
toast.error("Please enter at least one score")
return
}
formData.set("classId", classId)
formData.set("subjectId", subjectId)
formData.set("type", type)
formData.set("semester", semester)
formData.set("recordsJson", JSON.stringify(records))
const result = await batchCreateGradeRecordsAction(null, formData)
if (result.success) {
toast.success(result.message)
router.push("/teacher/grades")
router.refresh()
} else {
toast.error(result.message || "Failed to save")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Batch Grade Entry</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>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>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Exam / Quiz Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">Full Score</Label>
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Semester</Label>
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground">No students in this class.</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-32">Score</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>
<Input
type="number"
step="0.01"
min="0"
placeholder="0"
value={scores[s.id] ?? ""}
onChange={(e) => handleScoreChange(s.id, e.target.value)}
className="h-8"
/>
</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,132 @@
"use client"
import { BarChart3 } from "lucide-react"
import {
Bar,
BarChart,
CartesianGrid,
Legend,
XAxis,
YAxis,
} from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { ClassComparisonItem } from "@/modules/grades/types"
const chartConfig = {
averageScore: { label: "Average (%)", color: "hsl(var(--primary))" },
passRate: { label: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
excellentRate: { label: "Excellent (%)", color: "hsl(var(--chart-3))" },
}
interface ClassComparisonChartProps {
data: ClassComparisonItem[]
}
export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Class Comparison
</CardTitle>
<CardDescription>
Compare average, pass rate, and excellent rate across classes.
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={BarChart3}
title="No comparison data"
description="Select a grade and subject to compare classes."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.map((d) => ({
name: d.className,
averageScore: d.averageScore,
passRate: d.passRate,
excellentRate: d.excellentRate,
count: d.count,
studentCount: d.studentCount,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Class Comparison
</CardTitle>
<CardDescription>
Average score, pass rate (60%), and excellent rate (85%) per class.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<BarChart
data={chartData}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value}%`}
width={36}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[240px]" />} />
<Legend />
<Bar
dataKey="averageScore"
fill="var(--color-averageScore)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="passRate"
fill="var(--color-passRate)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="excellentRate"
fill="var(--color-excellentRate)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,92 @@
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 { Trophy } from "lucide-react"
import { GradeStatsCard } from "./grade-stats-card"
import type { ClassGradeStats, ClassRankingItem } from "../types"
interface ClassGradeReportProps {
stats: ClassGradeStats | null
ranking: ClassRankingItem[]
}
export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
return (
<div className="space-y-6">
{stats ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">{stats.className}</h3>
<p className="text-sm text-muted-foreground">
{stats.studentCount} students · {stats.stats.count} grade records
</p>
</div>
</div>
<GradeStatsCard stats={stats.stats} />
</div>
) : (
<EmptyState
title="No data"
description="No grade records found for this class."
icon={Trophy}
className="border-none shadow-none"
/>
)}
{ranking.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Class Ranking</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Rank</TableHead>
<TableHead>Student</TableHead>
<TableHead className="text-right">Average Score</TableHead>
<TableHead className="text-right">Records</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ranking.map((r) => (
<TableRow key={r.studentId}>
<TableCell>
<Badge
variant={
r.rank === 1 ? "default" : r.rank <= 3 ? "secondary" : "outline"
}
className="font-mono"
>
#{r.rank}
</Badge>
</TableCell>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell className="text-right font-mono">
{r.averageScore.toFixed(2)}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{r.recordCount}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { Download, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { exportGradesAction } from "../actions"
function downloadBase64File(base64: string, filename: string) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
})
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)
}
type ExportButtonProps = {
classId: string
subjectId?: string
examId?: string
variant?: "default" | "outline" | "secondary" | "ghost"
size?: "default" | "sm" | "lg" | "icon"
label?: string
}
export function ExportButton({
classId,
subjectId,
examId,
variant = "outline",
size = "default",
label = "导出",
}: ExportButtonProps) {
const [isExporting, setIsExporting] = useState(false)
const handleExport = async (reportType: "detail" | "class") => {
if (!classId) {
toast.error("请先选择班级")
return
}
setIsExporting(true)
const result = await exportGradesAction({
classId,
subjectId,
examId,
reportType,
})
setIsExporting(false)
if (result.success && result.data) {
downloadBase64File(result.data.buffer, result.data.filename)
toast.success("导出成功")
} else {
toast.error(result.message ?? "导出失败")
}
}
if (isExporting) {
return (
<Button variant={variant} size={size} disabled>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={variant} size={size}>
<Download className="mr-2 h-4 w-4" />
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("detail")}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("class")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,138 @@
"use client"
import { PieChart as PieChartIcon } from "lucide-react"
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { GradeDistributionResult } from "@/modules/grades/types"
const BUCKET_COLORS: Record<string, string> = {
"90-100": "hsl(142, 71%, 45%)",
"80-89": "hsl(217, 91%, 60%)",
"70-79": "hsl(43, 96%, 56%)",
"60-69": "hsl(25, 95%, 53%)",
"<60": "hsl(0, 84%, 60%)",
}
const chartConfig = {
count: { label: "Students", color: "hsl(var(--primary))" },
}
interface GradeDistributionChartProps {
data: GradeDistributionResult | null
}
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
if (!data || data.totalCount === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChartIcon className="h-4 w-4" />
Score Distribution
</CardTitle>
<CardDescription>
Number of students in each score range (normalized to 0-100).
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={PieChartIcon}
title="No distribution data"
description="Select a class and subject to view score distribution."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.buckets.map((b) => ({
label: b.label,
count: b.count,
percentage:
data.totalCount > 0
? Math.round((b.count / data.totalCount) * 1000) / 10
: 0,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChartIcon className="h-4 w-4" />
Score Distribution
</CardTitle>
<CardDescription>
{data.totalCount} grade record{data.totalCount === 1 ? "" : "s"} across
score ranges.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart
data={chartData}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis
allowDecimals={false}
tickLine={false}
axisLine={false}
width={32}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[200px]"
formatter={(payload: unknown) => {
const item = (payload as { payload?: (typeof chartData)[number] })?.payload
if (!item) return null
return (
<div className="flex w-full flex-col gap-0.5">
<span className="text-sm font-medium">
{item.label}: {item.count} student
{item.count === 1 ? "" : "s"}
</span>
<span className="text-xs text-muted-foreground">
{item.percentage}% of total
</span>
</div>
)
}}
/>
}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{chartData.map((entry) => (
<Cell key={entry.label} fill={BUCKET_COLORS[entry.label]} />
))}
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,104 @@
"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"
type Option = { id: string; name: string }
interface GradeQueryFiltersProps {
classes: Option[]
subjects: Option[]
}
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps) {
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 subjectId = searchParams.get("subjectId") ?? "all"
const type = searchParams.get("type") ?? "all"
const semester = searchParams.get("semester") ?? "all"
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-4">
<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">Subject</Label>
<Select value={subjectId} onValueChange={(v) => updateParam("subjectId", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All subjects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All subjects</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Type</Label>
<Select value={type} onValueChange={(v) => updateParam("type", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Semester</Label>
<Select value={semester} onValueChange={(v) => updateParam("semester", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All semesters" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All semesters</SelectItem>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { createGradeRecordAction } from "../actions"
type Option = { id: string; name: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Record"}
</Button>
)
}
export function GradeRecordForm({
classes,
subjects,
students,
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
students: Option[]
defaultClassId?: string
defaultSubjectId?: string
}) {
const router = useRouter()
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
const [studentId, setStudentId] = useState(students[0]?.id ?? "")
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
const [semester, setSemester] = useState<"1" | "2">("1")
const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId || !studentId) {
toast.error("Please select class, subject and student")
return
}
formData.set("classId", classId)
formData.set("subjectId", subjectId)
formData.set("studentId", studentId)
formData.set("type", type)
formData.set("semester", semester)
const result = await createGradeRecordAction(null, formData)
if (result.success) {
toast.success(result.message)
router.push("/teacher/grades")
router.refresh()
} else {
toast.error(result.message || "Failed to create")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Record Grade</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>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>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Student</Label>
<Select value={studentId} onValueChange={setStudentId}>
<SelectTrigger>
<SelectValue placeholder="Select a student" />
</SelectTrigger>
<SelectContent>
{students.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
</div>
<div className="grid gap-2">
<Label htmlFor="score">Score</Label>
<Input id="score" name="score" type="number" step="0.01" min="0" required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">Full Score</Label>
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Semester</Label>
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="remark">Remark (optional)</Label>
<Textarea id="remark" name="remark" placeholder="Notes about this grade..." />
</div>
</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,136 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
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 { Trash2 } from "lucide-react"
import { deleteGradeRecordAction } from "../actions"
import type { GradeRecordListItem } from "../types"
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
exam: "default",
quiz: "secondary",
homework: "outline",
other: "outline",
}
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
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 deleteGradeRecordAction(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 grade records found.
</div>
)
}
return (
<>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Class</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Title</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Type</TableHead>
<TableHead>Semester</TableHead>
<TableHead>Recorded By</TableHead>
<TableHead>Date</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.subjectName}</TableCell>
<TableCell>{r.title}</TableCell>
<TableCell className="text-right font-mono">
{r.score} / {r.fullScore}
</TableCell>
<TableCell>
<Badge variant={typeColors[r.type]} className="capitalize">
{r.type}
</Badge>
</TableCell>
<TableCell>S{r.semester}</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 Grade Record</DialogTitle>
<DialogDescription>
Are you sure you want to delete this grade 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,93 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
import type { GradeStats } 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 GradeStatsCard({ stats }: { stats: GradeStats | null }) {
if (!stats || stats.count === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No data available for statistics.</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label="Average"
value={stats.average.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label="Median"
value={stats.median.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label="Max"
value={stats.max.toFixed(2)}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatItem
label="Min"
value={stats.min.toFixed(2)}
icon={<TrendingDown className="h-4 w-4" />}
/>
<StatItem
label="Std Dev"
value={stats.stdDev.toFixed(2)}
icon={<Target className="h-4 w-4" />}
hint="Standard deviation"
/>
<StatItem
label="Pass Rate"
value={`${stats.passRate.toFixed(1)}%`}
icon={<CheckCircle2 className="h-4 w-4" />}
hint="Score >= 60% of full"
/>
<StatItem
label="Excellent Rate"
value={`${stats.excellentRate.toFixed(1)}%`}
icon={<Award className="h-4 w-4" />}
hint="Score >= 85% of full"
/>
<StatItem
label="Count"
value={stats.count}
icon={<BarChart3 className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,137 @@
"use client"
import { BarChart3 } from "lucide-react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
import type { GradeTrendResult } from "@/modules/grades/types"
const chartConfig = {
normalizedScore: {
label: "Score (%)",
color: "hsl(var(--primary))",
},
}
interface GradeTrendChartProps {
data: GradeTrendResult | null
}
export function GradeTrendChart({ data }: GradeTrendChartProps) {
if (!data || data.points.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Grade Trend
</CardTitle>
<CardDescription>
Score progression over time (normalized to 0-100).
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={BarChart3}
title="No trend data"
description="Select a class and subject to view the grade trend."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.points.map((p) => ({
title: p.title,
normalizedScore: p.normalizedScore,
fullTitle: p.title,
date: formatDate(p.date),
rawScore: p.score,
fullScore: p.fullScore,
type: p.type,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Grade Trend
</CardTitle>
<CardDescription>
{data.label} · avg {data.averageScore.toFixed(1)}%
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<LineChart
data={chartData}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="title"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 10 ? `${value.slice(0, 10)}...` : value
}
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value}%`}
width={36}
/>
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={
<ChartTooltipContent
indicator="line"
labelKey="fullTitle"
className="w-[220px]"
/>
}
/>
<Line
dataKey="normalizedScore"
type="monotone"
stroke="var(--color-normalizedScore)"
strokeWidth={2}
dot={{
fill: "var(--color-normalizedScore)",
r: 3,
strokeWidth: 2,
}}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,117 @@
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 { formatDate } from "@/shared/lib/utils"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { GraduationCap } from "lucide-react"
import type { StudentGradeSummary } from "../types"
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
exam: "default",
quiz: "secondary",
homework: "outline",
other: "outline",
}
export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }) {
if (!summary) {
return (
<EmptyState
title="No data"
description="Student grade summary is not available."
icon={GraduationCap}
className="border-none shadow-none"
/>
)
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<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">Average Score</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.averageScore.toFixed(2)}</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.records.length}</p>
</CardContent>
</Card>
</div>
{summary.records.length === 0 ? (
<EmptyState
title="No grades yet"
description="There are no grade records for this student."
icon={GraduationCap}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Grade History</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Class</TableHead>
<TableHead>Subject</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Type</TableHead>
<TableHead>Semester</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.title}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.subjectName}</TableCell>
<TableCell className="text-right font-mono">
{r.score} / {r.fullScore}
</TableCell>
<TableCell>
<Badge variant={typeColors[r.type]} className="capitalize">
{r.type}
</Badge>
</TableCell>
<TableCell>S{r.semester}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,116 @@
"use client"
import { Radar } from "lucide-react"
import {
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar as RechartsRadar,
RadarChart,
} from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { SubjectComparisonItem } from "@/modules/grades/types"
const chartConfig = {
averageScore: { label: "Average (%)", color: "hsl(var(--primary))" },
passRate: { label: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
}
interface SubjectComparisonChartProps {
data: SubjectComparisonItem[]
}
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radar className="h-4 w-4" />
Subject Comparison
</CardTitle>
<CardDescription>
Compare performance across subjects for the selected class.
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={Radar}
title="No comparison data"
description="Select a class to compare subject performance."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.map((d) => ({
subject: d.subjectName,
averageScore: d.averageScore,
passRate: d.passRate,
excellentRate: d.excellentRate,
count: d.count,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radar className="h-4 w-4" />
Subject Comparison
</CardTitle>
<CardDescription>
Average score and pass rate per subject (normalized to 0-100).
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<RadarChart data={chartData} outerRadius="75%">
<PolarGrid strokeOpacity={0.4} />
<PolarAngleAxis
dataKey="subject"
tick={{ fontSize: 12 }}
tickFormatter={(value: string) =>
value.length > 6 ? `${value.slice(0, 6)}...` : value
}
/>
<PolarRadiusAxis
domain={[0, 100]}
tickFormatter={(value: number) => `${value}%`}
tick={{ fontSize: 10 }}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
<RechartsRadar
name="Average"
dataKey="averageScore"
stroke="var(--color-averageScore)"
fill="var(--color-averageScore)"
fillOpacity={0.4}
/>
<RechartsRadar
name="Pass Rate"
dataKey="passRate"
stroke="var(--color-passRate)"
fill="var(--color-passRate)"
fillOpacity={0.2}
/>
</RadarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,293 @@
import "server-only"
import { and, asc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
gradeRecords,
subjects,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
ClassComparisonItem,
GradeDistributionBucket,
GradeDistributionResult,
GradeTrendPoint,
GradeTrendResult,
SubjectComparisonItem,
} from "./types"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const normalize = (score: number, fullScore: number): number => {
if (fullScore <= 0) return 0
return Math.round((score / fullScore) * 10000) / 100
}
const buildScopeClassFilter = (scope: DataScope) => {
if (scope.type === "all") return null
if (scope.type === "class_taught") {
return scope.classIds.length > 0 ? inArray(gradeRecords.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(gradeRecords.studentId, scope.childrenIds)
: sql`1=0`
}
if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId)
return sql`1=0`
}
export interface GradeTrendParams {
classId: string
subjectId?: string
studentId?: string
semester?: "1" | "2"
scope: DataScope
currentUserId?: string
}
export async function getGradeTrend(
params: GradeTrendParams
): Promise<GradeTrendResult | null> {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
record: gradeRecords,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
if (rows.length === 0) return null
const points: GradeTrendPoint[] = rows.map((r) => {
const score = toNumber(r.record.score)
const fullScore = toNumber(r.record.fullScore)
return {
date: r.record.createdAt.toISOString(),
title: r.record.title,
score,
fullScore,
normalizedScore: normalize(score, fullScore),
type: r.record.type,
}
})
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
const className = rows[0].className ?? "Class"
const subjectName = rows[0].subjectName ?? "All Subjects"
const studentLabel = params.studentId
? `Student ${params.studentId.slice(-4)}`
: "Class Average"
return {
label: params.subjectId
? `${className} · ${subjectName} · ${studentLabel}`
: `${className} · ${studentLabel}`,
points,
averageScore: Math.round(avg * 100) / 100,
}
}
export interface ClassComparisonParams {
gradeId: string
subjectId: string
examId?: string
scope: DataScope
}
export async function getClassComparison(
params: ClassComparisonParams
): Promise<ClassComparisonItem[]> {
const classRows = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.gradeId, params.gradeId))
if (classRows.length === 0) return []
const scope = params.scope
const allowedClassIds =
scope.type === "class_taught"
? classRows.filter((c) => scope.classIds.includes(c.id)).map((c) => c.id)
: classRows.map((c) => c.id)
if (allowedClassIds.length === 0) return []
const result: ClassComparisonItem[] = []
for (const cls of classRows) {
if (!allowedClassIds.includes(cls.id)) continue
const conditions = [
eq(gradeRecords.classId, cls.id),
eq(gradeRecords.subjectId, params.subjectId),
]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
const rows = await db
.select({
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
studentId: gradeRecords.studentId,
})
.from(gradeRecords)
.where(and(...conditions))
if (rows.length === 0) {
result.push({
classId: cls.id, className: cls.name, averageScore: 0, medianScore: 0,
passRate: 0, excellentRate: 0, count: 0, studentCount: 0,
})
continue
}
const normalized = rows.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore)))
const sorted = [...normalized].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
result.push({
classId: cls.id,
className: cls.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((normalized.filter((s) => s >= 60).length / normalized.length) * 10000) / 100,
excellentRate: Math.round((normalized.filter((s) => s >= 85).length / normalized.length) * 10000) / 100,
count: normalized.length,
studentCount: uniqueStudents,
})
}
return result
}
export interface SubjectComparisonParams {
classId: string
examId?: string
scope: DataScope
}
export async function getSubjectComparison(
params: SubjectComparisonParams
): Promise<SubjectComparisonItem[]> {
const scopeFilter = buildScopeClassFilter(params.scope)
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
subjectId: gradeRecords.subjectId,
subjectName: subjects.name,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions))
const bySubject = new Map<string, { name: string; scores: number[] }>()
for (const r of rows) {
const sid = r.subjectId
if (!sid) continue
const entry = bySubject.get(sid) ?? { name: r.subjectName ?? "Unknown", scores: [] }
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
bySubject.set(sid, entry)
}
const result: SubjectComparisonItem[] = []
for (const [subjectId, entry] of bySubject.entries()) {
if (entry.scores.length === 0) continue
const sorted = [...entry.scores].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
result.push({
subjectId,
subjectName: entry.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((entry.scores.filter((s) => s >= 60).length / entry.scores.length) * 10000) / 100,
excellentRate: Math.round((entry.scores.filter((s) => s >= 85).length / entry.scores.length) * 10000) / 100,
count: entry.scores.length,
})
}
return result.sort((a, b) => b.averageScore - a.averageScore)
}
export interface GradeDistributionParams {
classId: string
subjectId?: string
examId?: string
scope: DataScope
currentUserId?: string
}
export async function getGradeDistribution(
params: GradeDistributionParams
): Promise<GradeDistributionResult> {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore })
.from(gradeRecords)
.where(and(...conditions))
const buckets: GradeDistributionBucket[] = [
{ label: "90-100", min: 90, max: 100, count: 0 },
{ label: "80-89", min: 80, max: 89, count: 0 },
{ label: "70-79", min: 70, max: 79, count: 0 },
{ label: "60-69", min: 60, max: 69, count: 0 },
{ label: "<60", min: 0, max: 59, count: 0 },
]
for (const r of rows) {
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
const rounded = Math.round(normalized)
if (rounded >= 90) buckets[0].count++
else if (rounded >= 80) buckets[1].count++
else if (rounded >= 70) buckets[2].count++
else if (rounded >= 60) buckets[3].count++
else buckets[4].count++
}
return { buckets, totalCount: rows.length }
}

View File

@@ -0,0 +1,121 @@
import "server-only"
import { and, asc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classEnrollments,
gradeRecords,
users,
} from "@/shared/db/schema"
import type {
RankingTrendPoint,
RankingTrendResult,
} from "./types"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const normalize = (score: number, fullScore: number): number => {
if (fullScore <= 0) return 0
return Math.round((score / fullScore) * 10000) / 100
}
/**
* Get a student's ranking trend across assessments within their class.
* Each point represents one assessment (grouped by title), with the
* student's normalized score, rank, and total participants.
*/
export async function getRankingTrend(
studentId: string,
subjectId?: string,
semester?: "1" | "2"
): Promise<RankingTrendResult | null> {
const [student] = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.id, studentId))
.limit(1)
if (!student) return null
const [enrollment] = await db
.select({ classId: classEnrollments.classId })
.from(classEnrollments)
.where(
and(
eq(classEnrollments.studentId, studentId),
eq(classEnrollments.status, "active")
)
)
.limit(1)
if (!enrollment) {
return {
studentId,
studentName: student.name ?? "Unknown",
points: [],
}
}
const conditions = [eq(gradeRecords.classId, enrollment.classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (semester) conditions.push(eq(gradeRecords.semester, semester))
const rows = await db
.select({
title: gradeRecords.title,
createdAt: gradeRecords.createdAt,
studentId: gradeRecords.studentId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
const byTitle = new Map<
string,
{
date: Date
entries: Array<{ studentId: string; normalized: number }>
}
>()
for (const r of rows) {
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
entry.entries.push({
studentId: r.studentId,
normalized: normalize(toNumber(r.score), toNumber(r.fullScore)),
})
byTitle.set(r.title, entry)
}
const points: RankingTrendPoint[] = []
for (const [title, entry] of byTitle.entries()) {
if (entry.entries.length === 0) continue
const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized)
const rank = sorted.findIndex((e) => e.studentId === studentId) + 1
if (rank <= 0) continue
const studentEntry = sorted.find((e) => e.studentId === studentId)
if (!studentEntry) continue
points.push({
title,
date: entry.date.toISOString(),
score: studentEntry.normalized,
rank,
totalStudents: sorted.length,
})
}
points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
return {
studentId,
studentName: student.name ?? "Unknown",
points,
}
}

View File

@@ -0,0 +1,419 @@
import "server-only"
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
gradeRecords,
subjects,
users,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
ClassGradeStats,
ClassRankingItem,
GradeQueryParams,
GradeRecord,
GradeRecordListItem,
GradeStats,
StudentGradeSummary,
} from "./types"
import type {
BatchCreateGradeRecordInput,
CreateGradeRecordInput,
UpdateGradeRecordInput,
} from "./schema"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
id: r.id,
studentId: r.studentId,
classId: r.classId,
subjectId: r.subjectId,
examId: r.examId ?? null,
academicYearId: r.academicYearId ?? null,
title: r.title,
score: String(r.score),
fullScore: String(r.fullScore),
type: r.type,
semester: r.semester,
recordedBy: r.recordedBy,
remark: r.remark ?? null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
const buildScopeClassFilter = (scope: DataScope) => {
if (scope.type === "all") return null
if (scope.type === "class_taught") {
return scope.classIds.length > 0 ? inArray(gradeRecords.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(gradeRecords.studentId, scope.childrenIds) : sql`1=0`
}
if (scope.type === "owned") {
return eq(gradeRecords.studentId, scope.userId)
}
return sql`1=0`
}
export async function getGradeRecords(
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
): Promise<GradeRecordListItem[]> {
const conditions = []
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
if (params.classId) conditions.push(eq(gradeRecords.classId, params.classId))
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
if (params.type) conditions.push(eq(gradeRecords.type, params.type))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
const rows = await db
.select({
record: gradeRecords,
studentName: users.name,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(users, eq(users.id, gradeRecords.studentId))
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(gradeRecords.createdAt))
const recorderIds = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
const recorderMap = new Map<string, string>()
if (recorderIds.length > 0) {
const recorders = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, recorderIds))
for (const r of recorders) {
recorderMap.set(r.id, r.name ?? "Unknown")
}
}
return rows.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: r.studentName ?? "Unknown",
classId: r.record.classId,
className: r.className ?? "Unknown",
subjectId: r.record.subjectId,
subjectName: r.subjectName ?? "Unknown",
examId: r.record.examId ?? null,
title: r.record.title,
score: toNumber(r.record.score),
fullScore: toNumber(r.record.fullScore),
type: r.record.type,
semester: r.record.semester,
recordedBy: r.record.recordedBy,
recorderName: recorderMap.get(r.record.recordedBy) ?? "Unknown",
remark: r.record.remark ?? null,
createdAt: r.record.createdAt.toISOString(),
}))
}
export async function getGradeRecordById(id: string): Promise<GradeRecord | null> {
const [row] = await db.select().from(gradeRecords).where(eq(gradeRecords.id, id)).limit(1)
return row ? serializeRecord(row) : null
}
export async function createGradeRecord(
data: CreateGradeRecordInput,
recordedBy: string
): Promise<string> {
const { createId } = await import("@paralleldrive/cuid2")
const id = createId()
await db.insert(gradeRecords).values({
id,
studentId: data.studentId,
classId: data.classId,
subjectId: data.subjectId,
examId: data.examId ?? null,
academicYearId: data.academicYearId ?? null,
title: data.title,
score: String(data.score),
fullScore: String(data.fullScore ?? 100),
type: data.type ?? "exam",
semester: data.semester ?? "1",
recordedBy,
remark: data.remark ?? null,
})
return id
}
export async function batchCreateGradeRecords(
data: BatchCreateGradeRecordInput,
recordedBy: string
): Promise<number> {
const { createId } = await import("@paralleldrive/cuid2")
const rows = data.records.map((r) => ({
id: createId(),
studentId: r.studentId,
classId: data.classId,
subjectId: data.subjectId,
examId: data.examId ?? null,
academicYearId: data.academicYearId ?? null,
title: data.title,
score: String(r.score),
fullScore: String(data.fullScore ?? 100),
type: data.type ?? "exam",
semester: data.semester ?? "1",
recordedBy,
remark: r.remark ?? null,
}))
if (rows.length === 0) return 0
await db.insert(gradeRecords).values(rows)
return rows.length
}
export async function updateGradeRecord(
id: string,
data: UpdateGradeRecordInput
): Promise<void> {
const update: Record<string, unknown> = { updatedAt: new Date() }
if (data.title !== undefined) update.title = data.title
if (data.score !== undefined) update.score = String(data.score)
if (data.fullScore !== undefined) update.fullScore = String(data.fullScore)
if (data.type !== undefined) update.type = data.type
if (data.semester !== undefined) update.semester = data.semester
if (data.remark !== undefined) update.remark = data.remark
if (data.examId !== undefined) update.examId = data.examId
await db.update(gradeRecords).set(update).where(eq(gradeRecords.id, id))
}
export async function deleteGradeRecord(id: string): Promise<void> {
await db.delete(gradeRecords).where(eq(gradeRecords.id, id))
}
export async function getClassGradeStats(
classId: string,
subjectId?: string,
examId?: string
): Promise<GradeStats | null> {
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId))
const rows = await db
.select({
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
if (rows.length === 0) return null
const scores = rows.map((r) => toNumber(r.score))
const fullScores = rows.map((r) => toNumber(r.fullScore))
const countN = scores.length
const sum = scores.reduce((a, b) => a + b, 0)
const average = sum / countN
const sorted = [...scores].sort((a, b) => a - b)
const mid = Math.floor(countN / 2)
const median = countN % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const max = sorted[countN - 1]
const min = sorted[0]
const variance = scores.reduce((acc, s) => acc + Math.pow(s - average, 2), 0) / countN
const stdDev = Math.sqrt(variance)
let passCount = 0
let excellentCount = 0
for (let i = 0; i < countN; i++) {
const ratio = scores[i] / fullScores[i]
if (ratio >= 0.6) passCount++
if (ratio >= 0.85) excellentCount++
}
return {
average: Math.round(average * 100) / 100,
median: Math.round(median * 100) / 100,
max,
min,
stdDev: Math.round(stdDev * 100) / 100,
passRate: Math.round((passCount / countN) * 10000) / 100,
excellentRate: Math.round((excellentCount / countN) * 10000) / 100,
count: countN,
}
}
export async function getStudentGradeSummary(
studentId: string
): Promise<StudentGradeSummary | null> {
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1)
if (!student) return null
const records = await db
.select({
record: gradeRecords,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(eq(gradeRecords.studentId, studentId))
.orderBy(desc(gradeRecords.createdAt))
if (records.length === 0) {
return {
studentId,
studentName: student.name ?? "Unknown",
records: [],
averageScore: 0,
rank: 0,
}
}
const listItems: GradeRecordListItem[] = records.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: student.name ?? "Unknown",
classId: r.record.classId,
className: r.className ?? "Unknown",
subjectId: r.record.subjectId,
subjectName: r.subjectName ?? "Unknown",
examId: r.record.examId ?? null,
title: r.record.title,
score: toNumber(r.record.score),
fullScore: toNumber(r.record.fullScore),
type: r.record.type,
semester: r.record.semester,
recordedBy: r.record.recordedBy,
recorderName: "Unknown",
remark: r.record.remark ?? null,
createdAt: r.record.createdAt.toISOString(),
}))
const avg = listItems.reduce((a, b) => a + b.score, 0) / listItems.length
return {
studentId,
studentName: student.name ?? "Unknown",
records: listItems,
averageScore: Math.round(avg * 100) / 100,
rank: 0,
}
}
export async function getClassRanking(
classId: string,
subjectId?: string,
examId?: string
): Promise<ClassRankingItem[]> {
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId))
const rows = await db
.select({
studentId: gradeRecords.studentId,
studentName: users.name,
avgScore: sql<number>`AVG(${gradeRecords.score})`,
recordCount: count(gradeRecords.id),
})
.from(gradeRecords)
.leftJoin(users, eq(users.id, gradeRecords.studentId))
.where(and(...conditions))
.groupBy(gradeRecords.studentId, users.name)
.orderBy(desc(sql`AVG(${gradeRecords.score})`))
return rows.map((r, idx) => ({
studentId: r.studentId,
studentName: r.studentName ?? "Unknown",
averageScore: Math.round(toNumber(r.avgScore) * 100) / 100,
rank: idx + 1,
recordCount: toNumber(r.recordCount),
}))
}
export async function getClassStudentsForEntry(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 getClassGradeStatsWithMeta(
classId: string,
subjectId?: string,
examId?: string
): Promise<ClassGradeStats | 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 stats = await getClassGradeStats(classId, subjectId, examId)
if (!stats) {
return {
classId,
className: classRow.name,
stats: {
average: 0,
median: 0,
max: 0,
min: 0,
stdDev: 0,
passRate: 0,
excellentRate: 0,
count: 0,
},
studentCount: 0,
}
}
const [studentCountRow] = await db
.select({ c: count() })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
return {
classId,
className: classRow.name,
stats,
studentCount: toNumber(studentCountRow?.c ?? 0),
}
}

View File

@@ -0,0 +1,214 @@
import "server-only"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
gradeRecords,
subjects,
users,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import { exportToExcel } from "@/shared/lib/excel"
import { getClassGradeStats, getGradeRecords } from "./data-access"
import type { GradeRecordType } from "./types"
const TYPE_LABELS: Record<GradeRecordType, string> = {
exam: "考试",
quiz: "测验",
homework: "作业",
other: "其他",
}
const formatDateForFile = (d = new Date()) => {
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}`
}
/**
* 导出成绩单
* Sheet 1: 成绩明细
* Sheet 2: 统计汇总
*/
export async function exportGradeRecordsToExcel(params: {
classId: string
subjectId?: string
examId?: string
scope: DataScope
}): Promise<Buffer> {
const records = await getGradeRecords({
scope: params.scope,
classId: params.classId,
subjectId: params.subjectId,
examId: params.examId,
})
const detailRows = records.map((r) => ({
studentName: r.studentName,
className: r.className,
subjectName: r.subjectName,
title: r.title,
score: r.score,
fullScore: r.fullScore,
type: TYPE_LABELS[r.type] ?? r.type,
semester: r.semester === "1" ? "第一学期" : "第二学期",
recorderName: r.recorderName,
remark: r.remark ?? "",
createdAt: r.createdAt.split("T")[0],
}))
const stats = await getClassGradeStats(params.classId, params.subjectId, params.examId)
const statsRows = stats
? [
{ metric: "均分", value: stats.average },
{ metric: "中位数", value: stats.median },
{ metric: "最高分", value: stats.max },
{ metric: "最低分", value: stats.min },
{ metric: "标准差", value: stats.stdDev },
{ metric: "及格率(%)", value: stats.passRate },
{ metric: "优秀率(%)", value: stats.excellentRate },
{ metric: "参考人数", value: stats.count },
]
: [{ metric: "无数据", value: "" }]
return exportToExcel({
sheets: [
{
name: "成绩明细",
columns: [
{ header: "学生姓名", key: "studentName", width: 16 },
{ header: "班级", key: "className", width: 20 },
{ header: "科目", key: "subjectName", width: 14 },
{ header: "标题", key: "title", width: 24 },
{ header: "分数", key: "score", width: 10 },
{ header: "满分", key: "fullScore", width: 10 },
{ header: "类型", key: "type", width: 10 },
{ header: "学期", key: "semester", width: 12 },
{ header: "录入人", key: "recorderName", width: 14 },
{ header: "备注", key: "remark", width: 24 },
{ header: "录入日期", key: "createdAt", width: 14 },
],
rows: detailRows,
},
{
name: "统计汇总",
columns: [
{ header: "指标", key: "metric", width: 20 },
{ header: "数值", key: "value", width: 16 },
],
rows: statsRows,
},
],
})
}
/**
* 导出班级成绩总表(多科目横向对比)
*/
export async function exportClassGradeReportToExcel(params: {
classId: string
scope: DataScope
}): Promise<Buffer> {
const [classRow] = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.id, params.classId))
.limit(1)
const className = classRow?.name ?? "Unknown"
// Get all subjects that have grade records for this class
const subjectRows = await db
.select({
id: subjects.id,
name: subjects.name,
})
.from(subjects)
.innerJoin(gradeRecords, eq(gradeRecords.subjectId, subjects.id))
.where(eq(gradeRecords.classId, params.classId))
.groupBy(subjects.id, subjects.name)
// Get all students with grades in this class
const studentRows = await db
.select({
id: users.id,
name: users.name,
})
.from(users)
.innerJoin(gradeRecords, eq(gradeRecords.studentId, users.id))
.where(eq(gradeRecords.classId, params.classId))
.groupBy(users.id, users.name)
.orderBy(users.name)
// Build a map: studentId -> subjectId -> average score
const allRecords = await getGradeRecords({
scope: params.scope,
classId: params.classId,
})
const scoreMap = new Map<string, Map<string, number[]>>()
for (const r of allRecords) {
if (!scoreMap.has(r.studentId)) scoreMap.set(r.studentId, new Map())
const subjMap = scoreMap.get(r.studentId)!
const arr = subjMap.get(r.subjectId) ?? []
arr.push(r.score)
subjMap.set(r.subjectId, arr)
}
const avg = (arr: number[]) =>
arr.length > 0 ? Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 100) / 100 : 0
const columns = [
{ header: "学生姓名", key: "studentName", width: 16 },
...subjectRows.map((s) => ({
header: s.name,
key: s.id,
width: 14,
})),
{ header: "总分", key: "_total", width: 12 },
{ header: "平均分", key: "_average", width: 12 },
{ header: "排名", key: "_rank", width: 10 },
]
const rowsData = studentRows.map((student) => {
const subjMap = scoreMap.get(student.id) ?? new Map<string, number[]>()
const row: Record<string, unknown> = {
studentName: student.name ?? "Unknown",
}
let total = 0
let count = 0
for (const subj of subjectRows) {
const scores = subjMap.get(subj.id) ?? []
const score = avg(scores)
row[subj.id] = scores.length > 0 ? score : "-"
if (scores.length > 0) {
total += score
count++
}
}
row["_total"] = total
row["_average"] = count > 0 ? Math.round((total / count) * 100) / 100 : 0
return { row, total }
})
// Rank by total descending
rowsData.sort((a, b) => b.total - a.total)
const rows = rowsData.map((d, idx) => ({
...d.row,
_rank: idx + 1,
}))
return exportToExcel({
sheets: [
{
name: `${className}_成绩总表`,
columns,
rows,
},
],
})
}
export { formatDateForFile }

View File

@@ -0,0 +1,52 @@
import { z } from "zod"
export const GradeRecordTypeEnum = z.enum(["exam", "quiz", "homework", "other"])
export const GradeRecordSemesterEnum = z.enum(["1", "2"])
export const CreateGradeRecordSchema = z.object({
studentId: z.string().min(1),
classId: z.string().min(1),
subjectId: z.string().min(1),
examId: z.string().optional(),
academicYearId: z.string().optional(),
title: z.string().min(1).max(255),
score: z.coerce.number().min(0),
fullScore: z.coerce.number().min(1).optional(),
type: GradeRecordTypeEnum.optional(),
semester: GradeRecordSemesterEnum.optional(),
remark: z.string().optional(),
})
export type CreateGradeRecordInput = z.infer<typeof CreateGradeRecordSchema>
export const BatchGradeRecordItemSchema = z.object({
studentId: z.string().min(1),
score: z.coerce.number().min(0),
remark: z.string().optional(),
})
export const BatchCreateGradeRecordSchema = z.object({
classId: z.string().min(1),
subjectId: z.string().min(1),
examId: z.string().optional(),
academicYearId: z.string().optional(),
title: z.string().min(1).max(255),
fullScore: z.coerce.number().min(1).optional(),
type: GradeRecordTypeEnum.optional(),
semester: GradeRecordSemesterEnum.optional(),
records: z.array(BatchGradeRecordItemSchema),
})
export type BatchCreateGradeRecordInput = z.infer<typeof BatchCreateGradeRecordSchema>
export const UpdateGradeRecordSchema = z.object({
title: z.string().min(1).max(255).optional(),
score: z.coerce.number().min(0).optional(),
fullScore: z.coerce.number().min(1).optional(),
type: GradeRecordTypeEnum.optional(),
semester: GradeRecordSemesterEnum.optional(),
remark: z.string().optional(),
examId: z.string().optional(),
})
export type UpdateGradeRecordInput = z.infer<typeof UpdateGradeRecordSchema>

176
src/modules/grades/types.ts Normal file
View File

@@ -0,0 +1,176 @@
export type GradeRecordType = "exam" | "quiz" | "homework" | "other"
export type GradeRecordSemester = "1" | "2"
export interface GradeRecord {
id: string
studentId: string
classId: string
subjectId: string
examId: string | null
academicYearId: string | null
title: string
score: string
fullScore: string
type: GradeRecordType
semester: GradeRecordSemester
recordedBy: string
remark: string | null
createdAt: string
updatedAt: string
}
export interface GradeRecordListItem {
id: string
studentId: string
studentName: string
classId: string
className: string
subjectId: string
subjectName: string
examId: string | null
title: string
score: number
fullScore: number
type: GradeRecordType
semester: GradeRecordSemester
recordedBy: string
recorderName: string
remark: string | null
createdAt: string
}
export interface GradeStats {
average: number
median: number
max: number
min: number
stdDev: number
passRate: number
excellentRate: number
count: number
}
export interface ClassGradeStats {
classId: string
className: string
stats: GradeStats
studentCount: number
}
export interface StudentGradeSummary {
studentId: string
studentName: string
records: GradeRecordListItem[]
averageScore: number
rank: number
}
export interface ClassRankingItem {
studentId: string
studentName: string
averageScore: number
rank: number
recordCount: number
}
export interface GradeQueryParams {
classId?: string
subjectId?: string
studentId?: string
type?: GradeRecordType
semester?: GradeRecordSemester
examId?: string
}
// --- Analytics Types ---
export interface GradeTrendPoint {
/** ISO date string of the grade record creation */
date: string
/** Title of the exam/assessment */
title: string
/** Raw score */
score: number
/** Full score for this record */
fullScore: number
/** Score normalized to 0-100 scale for cross-record comparison */
normalizedScore: number
/** Type of grade record */
type: GradeRecordType
}
export interface GradeTrendResult {
/** Label for the trend series (e.g. class name + subject) */
label: string
/** Sorted ascending by date */
points: GradeTrendPoint[]
/** Average of normalized scores */
averageScore: number
}
export interface ClassComparisonItem {
classId: string
className: string
/** Average score (normalized to 0-100) */
averageScore: number
/** Median score */
medianScore: number
/** Pass rate (score/fullScore >= 0.6) */
passRate: number
/** Excellent rate (score/fullScore >= 0.85) */
excellentRate: number
/** Number of grade records */
count: number
/** Number of unique students */
studentCount: number
}
export interface SubjectComparisonItem {
subjectId: string
subjectName: string
/** Average normalized score (0-100) */
averageScore: number
/** Median normalized score */
medianScore: number
/** Pass rate */
passRate: number
/** Excellent rate */
excellentRate: number
/** Number of records */
count: number
}
export interface GradeDistributionBucket {
/** Bucket label e.g. "90-100" */
label: string
/** Lower bound (inclusive) */
min: number
/** Upper bound (inclusive) */
max: number
/** Number of students in this bucket */
count: number
}
export interface GradeDistributionResult {
buckets: GradeDistributionBucket[]
totalCount: number
}
export interface RankingTrendPoint {
/** Title of the exam/assessment */
title: string
/** ISO date string */
date: string
/** Student's average score (normalized) */
score: number
/** Rank in class for this assessment (1-based) */
rank: number
/** Total students participating */
totalStudents: number
}
export interface RankingTrendResult {
studentId: string
studentName: string
points: RankingTrendPoint[]
}

View File

@@ -3,11 +3,10 @@
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Bell, Menu, Search } from "lucide-react"
import { Menu } from "lucide-react"
import { signOut, useSession } from "next-auth/react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Separator } from "@/shared/components/ui/separator"
import {
Breadcrumb,
@@ -26,7 +25,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { GlobalSearch } from "@/shared/components/global-search"
import { NotificationDropdown } from "@/modules/messaging/components/notification-dropdown"
import { useSidebar } from "./sidebar-provider"
import { NAV_CONFIG } from "../config/navigation"
@@ -110,20 +111,10 @@ export function SiteHeader() {
<div className="flex items-center gap-4">
{/* Global Search */}
<div className="relative hidden md:block">
<Search className="text-muted-foreground absolute top-2.5 left-2.5 size-4" />
<Input
type="search"
placeholder="Search... (Cmd+K)"
className="w-[200px] pl-9 lg:w-[300px]"
/>
</div>
<GlobalSearch className="hidden md:block" />
{/* Notifications */}
<Button variant="ghost" size="icon" className="text-muted-foreground">
<Bell className="size-5" />
<span className="sr-only">Notifications</span>
</Button>
<NotificationDropdown />
{/* User Nav */}
<DropdownMenu>

View File

@@ -1,18 +1,22 @@
import {
BarChart,
BookOpen,
Calendar,
CalendarRange,
LayoutDashboard,
Settings,
Users,
MessageSquare,
Shield,
CreditCard,
FileQuestion,
ClipboardList,
Library,
PenTool,
Briefcase
Briefcase,
ScrollText,
Megaphone,
GraduationCap,
Mail,
CalendarCheck,
CalendarClock
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { Permissions } from "@/shared/types/permissions"
@@ -47,38 +51,43 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{ title: "Departments", href: "/admin/school/departments" },
{ title: "Classes", href: "/admin/school/classes" },
{ title: "Academic Year", href: "/admin/school/academic-year" },
{ title: "Course Plans", href: "/admin/course-plans", permission: Permissions.COURSE_PLAN_MANAGE },
{ title: "Import Users", href: "/admin/users/import", permission: Permissions.USER_MANAGE },
]
},
{
title: "Users",
icon: Users,
href: "/admin/users",
permission: Permissions.USER_MANAGE,
title: "Scheduling",
icon: CalendarClock,
href: "/admin/scheduling/rules",
permission: Permissions.SCHEDULE_ADJUST,
items: [
{ title: "Teachers", href: "/admin/users/teachers" },
{ title: "Students", href: "/admin/users/students" },
{ title: "Parents", href: "/admin/users/parents" },
{ title: "Staff", href: "/admin/users/staff" },
{ title: "Rules", href: "/admin/scheduling/rules", permission: Permissions.SCHEDULE_ADJUST },
{ title: "Auto Schedule", href: "/admin/scheduling/auto", permission: Permissions.SCHEDULE_AUTO },
{ title: "Change Requests", href: "/admin/scheduling/changes", permission: Permissions.SCHEDULE_ADJUST },
]
},
{
title: "Courses",
icon: BookOpen,
href: "/courses",
title: "Audit Logs",
icon: ScrollText,
href: "/admin/audit-logs",
permission: Permissions.AUDIT_LOG_READ,
items: [
{ title: "Course Catalog", href: "/courses/catalog" },
{ title: "Schedules", href: "/courses/schedules" },
{ title: "Operation Logs", href: "/admin/audit-logs" },
{ title: "Login Logs", href: "/admin/audit-logs/login-logs" },
{ title: "Data Changes", href: "/admin/audit-logs/data-changes" },
]
},
{
title: "Reports",
icon: BarChart,
href: "/reports",
title: "Announcements",
icon: Megaphone,
href: "/admin/announcements",
permission: Permissions.ANNOUNCEMENT_MANAGE,
},
{
title: "Finance",
icon: CreditCard,
href: "/finance",
title: "Messages",
icon: Mail,
href: "/messages",
permission: Permissions.MESSAGE_READ,
},
{
title: "Settings",
@@ -119,6 +128,18 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{ title: "Submissions", href: "/teacher/homework/submissions" },
]
},
{
title: "Grades",
icon: GraduationCap,
href: "/teacher/grades",
permission: Permissions.GRADE_RECORD_MANAGE,
items: [
{ title: "All Grades", href: "/teacher/grades" },
{ title: "Batch Entry", href: "/teacher/grades/entry", permission: Permissions.GRADE_RECORD_MANAGE },
{ title: "Statistics", href: "/teacher/grades/stats", permission: Permissions.GRADE_RECORD_READ },
{ title: "Analytics", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ },
]
},
{
title: "Question Bank",
icon: ClipboardList,
@@ -136,15 +157,51 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{ title: "Schedule", href: "/teacher/classes/schedule", permission: Permissions.CLASS_SCHEDULE },
]
},
{
title: "Course Plans",
icon: CalendarRange,
href: "/teacher/course-plans",
permission: Permissions.COURSE_PLAN_READ,
},
{
title: "Attendance",
icon: CalendarCheck,
href: "/teacher/attendance",
permission: Permissions.ATTENDANCE_MANAGE,
items: [
{ title: "Records", href: "/teacher/attendance" },
{ title: "Take Attendance", href: "/teacher/attendance/sheet", permission: Permissions.ATTENDANCE_MANAGE },
{ title: "Statistics", href: "/teacher/attendance/stats", permission: Permissions.ATTENDANCE_READ },
]
},
{
title: "Schedule Changes",
icon: CalendarClock,
href: "/teacher/schedule-changes",
permission: Permissions.SCHEDULE_ADJUST,
},
{
title: "Management",
icon: Briefcase,
href: "/management",
permission: Permissions.GRADE_MANAGE,
items: [
{ title: "Grade Classes", href: "/management/grade/classes" },
{ title: "Grade Insights", href: "/management/grade/insights" },
]
},
{
title: "Announcements",
icon: Megaphone,
href: "/announcements",
permission: Permissions.ANNOUNCEMENT_READ,
},
{
title: "Messages",
icon: Mail,
href: "/messages",
permission: Permissions.MESSAGE_READ,
},
],
student: [
{
@@ -169,6 +226,30 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
href: "/student/schedule",
permission: Permissions.CLASS_SCHEDULE,
},
{
title: "My Grades",
icon: GraduationCap,
href: "/student/grades",
permission: Permissions.GRADE_RECORD_READ,
},
{
title: "Attendance",
icon: CalendarCheck,
href: "/student/attendance",
permission: Permissions.ATTENDANCE_READ,
},
{
title: "Announcements",
icon: Megaphone,
href: "/announcements",
permission: Permissions.ANNOUNCEMENT_READ,
},
{
title: "Messages",
icon: Mail,
href: "/messages",
permission: Permissions.MESSAGE_READ,
},
],
parent: [
{
@@ -177,19 +258,28 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
href: "/parent/dashboard",
},
{
title: "Children",
icon: Users,
href: "/parent/children",
title: "Grades",
icon: GraduationCap,
href: "/parent/grades",
permission: Permissions.GRADE_RECORD_READ,
},
{
title: "Tuition",
icon: CreditCard,
href: "/parent/tuition",
title: "Attendance",
icon: CalendarCheck,
href: "/parent/attendance",
permission: Permissions.ATTENDANCE_READ,
},
{
title: "Announcements",
icon: Megaphone,
href: "/announcements",
permission: Permissions.ANNOUNCEMENT_READ,
},
{
title: "Messages",
icon: MessageSquare,
icon: Mail,
href: "/messages",
permission: Permissions.MESSAGE_READ,
},
]
}

View File

@@ -0,0 +1,245 @@
"use server"
import { revalidatePath } from "next/cache"
import { PermissionDeniedError, requireAuth, requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { SendMessageSchema } from "./schema"
import {
getMessages,
getMessageById,
createMessage,
markMessageAsRead,
deleteMessage,
getNotifications,
createNotification,
markNotificationAsRead,
markAllNotificationsAsRead,
getRecipients,
} from "./data-access"
import {
getNotificationPreferences,
upsertNotificationPreferences,
} from "./notification-preferences"
import type {
Message,
Notification,
MessageType,
NotificationPreferences,
RecipientOption,
UpdateNotificationPreferencesInput,
} from "./types"
export async function sendMessageAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsed = SendMessageSchema.safeParse({
receiverId: formData.get("receiverId"),
subject: formData.get("subject") || undefined,
content: formData.get("content"),
parentMessageId: formData.get("parentMessageId") || undefined,
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input = parsed.data
if (input.receiverId === ctx.userId) {
return { success: false, message: "Cannot send a message to yourself" }
}
const id = await createMessage({
senderId: ctx.userId,
receiverId: input.receiverId,
subject: input.subject,
content: input.content,
parentMessageId: input.parentMessageId,
})
// Notify the receiver about the new message
await createNotification({
userId: input.receiverId,
type: "message",
title: input.subject ? `New message: ${input.subject}` : "New message",
content: input.content.slice(0, 200),
link: `/messages/${id}`,
})
revalidatePath("/messages")
revalidatePath(`/messages/${id}`)
return { success: true, message: "Message sent", 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 markMessageAsReadAction(messageId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
await markMessageAsRead(messageId, ctx.userId)
revalidatePath("/messages")
revalidatePath(`/messages/${messageId}`)
return { success: true, message: "Marked as read" }
} 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 deleteMessageAction(messageId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_DELETE)
await deleteMessage(messageId, ctx.userId)
revalidatePath("/messages")
revalidatePath(`/messages/${messageId}`)
return { success: true, message: "Message 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 getMessagesAction(
params: { type: MessageType; page?: number; pageSize?: number }
): Promise<ActionState<{ items: Message[]; total: number; page: number; pageSize: number; totalPages: number }>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const result = await getMessages({ userId: ctx.userId, ...params })
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 getMessageDetailAction(messageId: string): Promise<ActionState<Message>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const message = await getMessageById(messageId, ctx.userId)
if (!message) return { success: false, message: "Message not found" }
// Auto-mark as read when viewed by receiver
if (!message.isRead && message.receiverId === ctx.userId) {
await markMessageAsRead(messageId, ctx.userId)
revalidatePath("/messages")
}
return { success: true, data: message }
} 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 getRecipientsAction(): Promise<ActionState<RecipientOption[]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const recipients = await getRecipients(ctx.userId, ctx.dataScope)
return { success: true, data: recipients }
} 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 getNotificationsAction(
params?: { page?: number; pageSize?: number; unreadOnly?: boolean }
): Promise<ActionState<{ items: Notification[]; total: number; page: number; pageSize: number; totalPages: number }>> {
try {
const ctx = await requireAuth()
const result = await getNotifications(ctx.userId, params)
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 markNotificationAsReadAction(
notificationId: string
): Promise<ActionState<string>> {
try {
const ctx = await requireAuth()
await markNotificationAsRead(notificationId, ctx.userId)
revalidatePath("/messages")
return { success: true, message: "Notification marked as read" }
} 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 markAllNotificationsAsReadAction(): Promise<ActionState<string>> {
try {
const ctx = await requireAuth()
await markAllNotificationsAsRead(ctx.userId)
revalidatePath("/messages")
return { success: true, message: "All notifications marked as read" }
} 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 getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requireAuth()
const prefs = await getNotificationPreferences(ctx.userId)
return { success: true, data: prefs }
} 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 updateNotificationPreferencesAction(
prevState: ActionState<NotificationPreferences> | null,
formData: FormData
): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requireAuth()
// 从 FormData 中解析布尔值checkbox 提交 "on" 或不提交)
const parseBool = (key: string): boolean => formData.get(key) === "on"
const input: UpdateNotificationPreferencesInput = {
emailEnabled: parseBool("emailEnabled"),
smsEnabled: parseBool("smsEnabled"),
pushEnabled: parseBool("pushEnabled"),
homeworkNotifications: parseBool("homeworkNotifications"),
gradeNotifications: parseBool("gradeNotifications"),
announcementNotifications: parseBool("announcementNotifications"),
messageNotifications: parseBool("messageNotifications"),
attendanceNotifications: parseBool("attendanceNotifications"),
}
const updated = await upsertNotificationPreferences(ctx.userId, input)
if (!updated) {
return { success: false, message: "Failed to update notification preferences" }
}
revalidatePath("/settings")
return { success: true, message: "Notification preferences updated", data: 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" }
}
}

View File

@@ -0,0 +1,146 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { ArrowLeft, Send } from "lucide-react"
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 { sendMessageAction } from "../actions"
import type { RecipientOption } from "../types"
export function MessageCompose({
recipients,
parentMessageId,
defaultReceiverId,
defaultSubject,
backHref = "/messages",
}: {
recipients: RecipientOption[]
parentMessageId?: string
defaultReceiverId?: string
defaultSubject?: string
backHref?: string
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [receiverId, setReceiverId] = useState(defaultReceiverId ?? "")
const handleSubmit = async (formData: FormData) => {
if (!receiverId) {
toast.error("Please select a recipient")
return
}
formData.set("receiverId", receiverId)
if (parentMessageId) {
formData.set("parentMessageId", parentMessageId)
}
setIsWorking(true)
try {
const res = await sendMessageAction(null, formData)
if (res.success) {
toast.success(res.message)
router.push("/messages")
router.refresh()
} else {
toast.error(res.message || "Failed to send message")
}
} catch {
toast.error("Failed to send message")
} finally {
setIsWorking(false)
}
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="icon">
<a href={backHref}>
<ArrowLeft className="h-4 w-4" />
</a>
</Button>
<CardTitle>{parentMessageId ? "Reply" : "New Message"}</CardTitle>
</div>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="receiverId">To</Label>
<Select value={receiverId} onValueChange={setReceiverId} disabled={!!defaultReceiverId}>
<SelectTrigger>
<SelectValue placeholder="Select a recipient" />
</SelectTrigger>
<SelectContent>
{recipients.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
{r.role ? ` (${r.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="receiverId" value={receiverId} />
</div>
<div className="grid gap-2">
<Label htmlFor="subject">Subject</Label>
<Input
id="subject"
name="subject"
placeholder="Message subject"
defaultValue={defaultSubject ?? ""}
maxLength={255}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
name="content"
placeholder="Write your message..."
className="min-h-[200px]"
required
/>
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button
type="button"
variant="outline"
onClick={() => router.push(backHref)}
disabled={isWorking}
>
Cancel
</Button>
<Button type="submit" disabled={isWorking || !receiverId}>
{isWorking ? (
"Sending..."
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send
</>
)}
</Button>
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,153 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { ArrowLeft, Mail, Reply, 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 { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { deleteMessageAction } from "../actions"
import type { Message } from "../types"
export function MessageDetail({
message,
currentUserId,
backHref = "/messages",
}: {
message: Message
currentUserId: string
backHref?: string
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const { hasPermission } = usePermission()
const canSend = hasPermission(Permissions.MESSAGE_SEND)
const canDelete = hasPermission(Permissions.MESSAGE_DELETE)
const isReceived = message.receiverId === currentUserId
const counterpart = isReceived ? message.senderName : message.receiverName
const counterpartLabel = isReceived ? "From" : "To"
const handleDelete = async () => {
setIsWorking(true)
try {
const res = await deleteMessageAction(message.id)
if (res.success) {
toast.success(res.message)
router.push("/messages")
router.refresh()
} else {
toast.error(res.message || "Failed to delete")
}
} catch {
toast.error("Failed to delete")
} finally {
setIsWorking(false)
setDeleteOpen(false)
}
}
const replyHref = canSend
? `/messages/compose?parentId=${message.id}&receiverId=${isReceived ? message.senderId : message.receiverId}&subject=${encodeURIComponent(
message.subject?.startsWith("Re:") ? message.subject : `Re: ${message.subject ?? ""}`
)}`
: undefined
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="icon">
<a href={backHref}>
<ArrowLeft className="h-4 w-4" />
</a>
</Button>
<h2 className="text-2xl font-bold tracking-tight">Message</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{canSend ? (
<Button asChild variant="outline">
<Link href={replyHref ?? "#"}>
<Reply className="mr-2 h-4 w-4" />
Reply
</Link>
</Button>
) : null}
{canDelete ? (
<Button onClick={() => setDeleteOpen(true)} disabled={isWorking} variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
) : null}
</div>
</div>
<Card>
<CardHeader className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Mail className="text-muted-foreground h-4 w-4" />
{isReceived && !message.isRead ? (
<Badge variant="default">New</Badge>
) : isReceived ? (
<Badge variant="secondary">Read</Badge>
) : (
<Badge variant="outline">Sent</Badge>
)}
</div>
<CardTitle className="text-2xl">{message.subject ?? "(no subject)"}</CardTitle>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
{counterpartLabel}: <span className="font-medium">{counterpart ?? "Unknown"}</span>
</span>
<span>·</span>
<span>{formatDate(message.createdAt)}</span>
{message.readAt && isReceived ? (
<>
<span>·</span>
<span>Read {formatDate(message.readAt)}</span>
</>
) : null}
</div>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
</CardContent>
</Card>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete message</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the message &quot;{message.subject ?? "(no subject)"}&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,117 @@
"use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import { Mail, MailOpen, Plus, Send, Inbox } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import type { Message, MessageType } from "../types"
type Tab = "inbox" | "sent"
export function MessageList({
messages,
currentUserId,
initialType = "inbox",
}: {
messages: Message[]
currentUserId: string
initialType?: MessageType
}) {
const [tab, setTab] = useState<Tab>(initialType === "sent" ? "sent" : "inbox")
const { hasPermission } = usePermission()
const canSend = hasPermission(Permissions.MESSAGE_SEND)
const filtered = useMemo(() => {
if (tab === "inbox") return messages.filter((m) => m.receiverId === currentUserId)
return messages.filter((m) => m.senderId === currentUserId)
}, [messages, tab, currentUserId])
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
<TabsList>
<TabsTrigger value="inbox" className="gap-2">
<Inbox className="h-4 w-4" />
Inbox
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
<Send className="h-4 w-4" />
Sent
</TabsTrigger>
</TabsList>
</Tabs>
{canSend ? (
<Button asChild>
<Link href="/messages/compose">
<Plus className="mr-2 h-4 w-4" />
Compose
</Link>
</Button>
) : null}
</div>
{filtered.length === 0 ? (
<EmptyState
title={tab === "inbox" ? "Inbox is empty" : "No sent messages"}
description={
tab === "inbox"
? "You have no incoming messages yet."
: "You have not sent any messages yet."
}
icon={Mail}
className="h-auto border-none shadow-none"
/>
) : (
<div className="space-y-3">
{filtered.map((m) => {
const isReceived = m.receiverId === currentUserId
const counterpart = isReceived ? m.senderName : m.receiverName
const unread = isReceived && !m.isRead
return (
<Link key={m.id} href={`/messages/${m.id}`} className="block">
<Card className={`transition-colors hover:bg-accent/50 ${unread ? "border-primary/40" : ""}`}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
{unread ? (
<Mail className="h-4 w-4 text-primary" />
) : (
<MailOpen className="text-muted-foreground h-4 w-4" />
)}
<span className={`text-sm font-medium ${unread ? "text-primary" : ""}`}>
{m.subject ?? "(no subject)"}
</span>
{unread ? <Badge variant="default" className="text-xs">New</Badge> : null}
</div>
<p className="text-muted-foreground text-xs">
{isReceived ? "From" : "To"}: {counterpart ?? "Unknown"}
</p>
</div>
<span className="text-muted-foreground shrink-0 text-xs">
{formatDate(m.createdAt)}
</span>
</CardHeader>
<CardContent className="pt-0">
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
{m.content}
</p>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,159 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { formatDate } from "@/shared/lib/utils"
import {
getNotificationsAction,
markAllNotificationsAsReadAction,
markNotificationAsReadAction,
} from "../actions"
import type { Notification, NotificationType } from "../types"
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
message: MessageSquare,
announcement: Megaphone,
homework: PenTool,
grade: GraduationCap,
}
export function NotificationDropdown() {
const router = useRouter()
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [open, setOpen] = useState(false)
useEffect(() => {
let active = true
void (async () => {
const res = await getNotificationsAction({ pageSize: 10 })
if (!active) return
if (res.success && res.data) {
setNotifications(res.data.items)
setUnreadCount(res.data.items.filter((n) => !n.isRead).length)
}
})()
return () => {
active = false
}
}, [])
const handleMarkAllRead = async () => {
const res = await markAllNotificationsAsReadAction()
if (res.success) {
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
setUnreadCount(0)
router.refresh()
}
}
const handleMarkRead = async (id: string) => {
const res = await markNotificationAsReadAction(id)
if (res.success) {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
)
setUnreadCount((c) => Math.max(0, c - 1))
router.refresh()
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative text-muted-foreground">
<Bell className="size-5" />
{unreadCount > 0 ? (
<Badge
variant="destructive"
className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center px-1 text-[10px]"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
) : null}
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Notifications</span>
{unreadCount > 0 ? (
<button
type="button"
onClick={handleMarkAllRead}
className="text-primary text-xs hover:underline"
>
<CheckCheck className="mr-1 inline h-3 w-3" />
Mark all read
</button>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator className="mb-0" />
<ScrollArea className="max-h-[320px]">
{notifications.length === 0 ? (
<div className="text-muted-foreground px-4 py-8 text-center text-sm">
No notifications
</div>
) : (
notifications.map((n) => {
const Icon = TYPE_ICON[n.type] ?? Bell
return (
<DropdownMenuItem
key={n.id}
className="flex items-start gap-2 py-3"
onSelect={(e) => {
if (!n.isRead) {
e.preventDefault()
handleMarkRead(n.id)
}
}}
>
<div className="bg-muted mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full">
<Icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-1.5">
{!n.isRead ? (
<span className="bg-primary size-1.5 shrink-0 rounded-full" />
) : null}
<span className={`text-xs ${!n.isRead ? "font-semibold" : "font-medium"}`}>
{n.title}
</span>
</div>
{n.content ? (
<p className="text-muted-foreground line-clamp-2 text-xs">{n.content}</p>
) : null}
<span className="text-muted-foreground text-[10px]">
{formatDate(n.createdAt)}
</span>
</div>
</DropdownMenuItem>
)
})
)}
</ScrollArea>
<DropdownMenuSeparator className="mt-0" />
<DropdownMenuItem asChild>
<Link href="/messages" className="text-primary justify-center text-xs">
View all notifications
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,141 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
import { markAllNotificationsAsReadAction, markNotificationAsReadAction } from "../actions"
import type { Notification, NotificationType } from "../types"
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
message: MessageSquare,
announcement: Megaphone,
homework: PenTool,
grade: GraduationCap,
}
const TYPE_LABEL: Record<NotificationType, string> = {
message: "Message",
announcement: "Announcement",
homework: "Homework",
grade: "Grade",
}
export function NotificationList({ notifications }: { notifications: Notification[] }) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const hasUnread = notifications.some((n) => !n.isRead)
const handleMarkAllRead = async () => {
setIsWorking(true)
try {
const res = await markAllNotificationsAsReadAction()
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to mark all as read")
}
} catch {
toast.error("Failed to mark all as read")
} finally {
setIsWorking(false)
}
}
const handleMarkRead = async (id: string) => {
try {
const res = await markNotificationAsReadAction(id)
if (res.success) {
router.refresh()
}
} catch {
toast.error("Failed to mark as read")
}
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-2xl font-bold tracking-tight">Notifications</h2>
<p className="text-muted-foreground text-sm">Stay updated on your latest activities.</p>
</div>
{hasUnread ? (
<Button onClick={handleMarkAllRead} disabled={isWorking} variant="outline">
<CheckCheck className="mr-2 h-4 w-4" />
Mark all as read
</Button>
) : null}
</div>
{notifications.length === 0 ? (
<EmptyState
title="No notifications"
description="You have no notifications yet."
icon={Bell}
className="h-auto border-none shadow-none"
/>
) : (
<div className="space-y-3">
{notifications.map((n) => {
const Icon = TYPE_ICON[n.type] ?? Bell
return (
<Card
key={n.id}
className={`transition-colors ${!n.isRead ? "border-primary/40 bg-primary/5" : ""}`}
>
<CardContent className="flex items-start gap-3 py-4">
<div className="bg-muted flex size-9 shrink-0 items-center justify-center rounded-full">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className={`text-sm ${!n.isRead ? "font-semibold" : "font-medium"}`}>
{n.title}
</span>
{!n.isRead ? <Badge variant="default" className="text-xs">New</Badge> : null}
</div>
{n.content ? (
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
{n.content}
</p>
) : null}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs">
{TYPE_LABEL[n.type]}
</Badge>
<span>{formatDate(n.createdAt)}</span>
{!n.isRead ? (
<button
type="button"
onClick={() => handleMarkRead(n.id)}
className="text-primary hover:underline"
>
Mark as read
</button>
) : null}
{n.link ? (
<Link href={n.link} className="ml-auto text-primary hover:underline">
View
</Link>
) : null}
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,252 @@
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import {
messages,
messageNotifications,
users,
classEnrollments,
classes,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
Message,
Notification,
NotificationType,
GetMessagesParams,
GetNotificationsParams,
CreateMessageInput,
CreateNotificationInput,
PaginatedResult,
RecipientOption,
} from "./types"
const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null)
interface MessageRow {
id: string
senderId: string
receiverId: string
subject: string | null
content: string
isRead: boolean
readAt: Date | null
parentMessageId: string | null
createdAt: Date
}
interface NotificationRow {
id: string
userId: string
type: string
title: string
content: string | null
link: string | null
isRead: boolean
createdAt: Date
}
async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
const uniqueIds = [...new Set(userIds)].filter(Boolean)
if (uniqueIds.length === 0) return new Map()
const rows = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, uniqueIds))
return new Map(rows.map((r) => [r.id, r.name ?? r.id]))
}
const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
id: r.id,
senderId: r.senderId,
senderName: nameMap.get(r.senderId) ?? null,
receiverId: r.receiverId,
receiverName: nameMap.get(r.receiverId) ?? null,
subject: r.subject,
content: r.content,
isRead: r.isRead,
readAt: toIso(r.readAt),
parentMessageId: r.parentMessageId,
createdAt: toIso(r.createdAt) as string,
})
const mapNotification = (r: NotificationRow): Notification => ({
id: r.id,
userId: r.userId,
type: r.type as NotificationType,
title: r.title,
content: r.content,
link: r.link,
isRead: r.isRead,
createdAt: toIso(r.createdAt) as string,
})
export const getMessages = cache(
async (params: GetMessagesParams): Promise<PaginatedResult<Message>> => {
const page = Math.max(1, params.page ?? 1)
const pageSize = Math.max(1, params.pageSize ?? 20)
const offset = (page - 1) * pageSize
const conds = []
if (params.type === "inbox") conds.push(eq(messages.receiverId, params.userId))
else if (params.type === "sent") conds.push(eq(messages.senderId, params.userId))
else conds.push(or(eq(messages.receiverId, params.userId), eq(messages.senderId, params.userId))!)
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(pageSize).offset(offset),
db.select({ value: count() }).from(messages).where(where),
])
const userIds = rows.flatMap((r) => [r.senderId, r.receiverId])
const nameMap = await resolveUserNames(userIds)
const total = Number(totalRow?.value ?? 0)
return { items: rows.map((r) => mapMessage(r, nameMap)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
)
export const getMessageById = cache(
async (id: string, userId: string): Promise<Message | null> => {
const [row] = await db
.select()
.from(messages)
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
.limit(1)
if (!row) return null
const nameMap = await resolveUserNames([row.senderId, row.receiverId])
return mapMessage(row, nameMap)
}
)
export const getMessageThread = cache(async (messageId: string): Promise<Message[]> => {
const [root] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1)
if (!root) return []
const replies = await db
.select()
.from(messages)
.where(eq(messages.parentMessageId, messageId))
.orderBy(desc(messages.createdAt))
const allRows = [root, ...replies]
const nameMap = await resolveUserNames(allRows.flatMap((r) => [r.senderId, r.receiverId]))
return allRows.map((r) => mapMessage(r, nameMap))
})
export async function createMessage(data: CreateMessageInput): Promise<string> {
const id = createId()
await db.insert(messages).values({
id,
senderId: data.senderId,
receiverId: data.receiverId,
subject: data.subject ?? null,
content: data.content,
parentMessageId: data.parentMessageId ?? null,
})
return id
}
export async function markMessageAsRead(id: string, userId: string): Promise<void> {
await db
.update(messages)
.set({ isRead: true, readAt: new Date() })
.where(and(eq(messages.id, id), eq(messages.receiverId, userId), eq(messages.isRead, false)))
}
export async function deleteMessage(id: string, userId: string): Promise<void> {
await db
.delete(messages)
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
}
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => {
const [row] = await db
.select({ value: count() })
.from(messages)
.where(and(eq(messages.receiverId, userId), eq(messages.isRead, false)))
return Number(row?.value ?? 0)
})
export const getNotifications = cache(
async (userId: string, params?: GetNotificationsParams): Promise<PaginatedResult<Notification>> => {
const page = Math.max(1, params?.page ?? 1)
const pageSize = Math.max(1, params?.pageSize ?? 20)
const offset = (page - 1) * pageSize
const conds = [eq(messageNotifications.userId, userId)]
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
db.select().from(messageNotifications).where(where).orderBy(desc(messageNotifications.createdAt)).limit(pageSize).offset(offset),
db.select({ value: count() }).from(messageNotifications).where(where),
])
const total = Number(totalRow?.value ?? 0)
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
)
export async function createNotification(data: CreateNotificationInput): Promise<string> {
const id = createId()
await db.insert(messageNotifications).values({
id,
userId: data.userId,
type: data.type,
title: data.title,
content: data.content ?? null,
link: data.link ?? null,
})
return id
}
export async function markNotificationAsRead(id: string, userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isRead: true })
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
}
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isRead: true })
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
}
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
const [row] = await db
.select({ value: count() })
.from(messageNotifications)
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
return Number(row?.value ?? 0)
})
export const getRecipients = cache(
async (userId: string, scope: DataScope): Promise<RecipientOption[]> => {
if (scope.type === "all") {
const all = await db.select({ id: users.id, name: users.name, email: users.email }).from(users)
return all.filter((r) => r.id !== userId).map((r) => ({ ...r, name: r.name ?? r.email }))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const rows = await db
.selectDistinct({ id: users.id, name: users.name, email: users.email })
.from(users)
.innerJoin(classEnrollments, eq(classEnrollments.studentId, users.id))
.where(inArray(classEnrollments.classId, scope.classIds))
return rows.map((r) => ({ ...r, name: r.name ?? r.email, role: "student" }))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
const rows = await db
.selectDistinct({ id: users.id, name: users.name, email: users.email })
.from(users)
.innerJoin(classEnrollments, eq(classEnrollments.studentId, users.id))
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(inArray(classes.gradeId, scope.gradeIds))
return rows.map((r) => ({ ...r, name: r.name ?? r.email, role: "student" }))
}
return []
}
)

View File

@@ -0,0 +1,166 @@
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { notificationPreferences } from "@/shared/db/schema"
import type {
NotificationPreferences,
UpdateNotificationPreferencesInput,
} from "./types"
const toIso = (d: Date): string => d.toISOString()
const mapRow = (
row: typeof notificationPreferences.$inferSelect
): NotificationPreferences => ({
id: row.id,
userId: row.userId,
emailEnabled: row.emailEnabled,
smsEnabled: row.smsEnabled,
pushEnabled: row.pushEnabled,
homeworkNotifications: row.homeworkNotifications,
gradeNotifications: row.gradeNotifications,
announcementNotifications: row.announcementNotifications,
messageNotifications: row.messageNotifications,
attendanceNotifications: row.attendanceNotifications,
createdAt: toIso(row.createdAt),
updatedAt: toIso(row.updatedAt),
})
// 默认偏好值(首次创建时使用)
const DEFAULTS = {
emailEnabled: false,
smsEnabled: false,
pushEnabled: true,
homeworkNotifications: true,
gradeNotifications: true,
announcementNotifications: true,
messageNotifications: true,
attendanceNotifications: true,
}
/**
* 获取用户的通知偏好设置
* 如果用户尚无记录,则自动创建一条默认记录并返回
*/
export const getNotificationPreferences = cache(
async (userId: string): Promise<NotificationPreferences> => {
// 先查询
const [existing] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.userId, userId))
.limit(1)
if (existing) {
return mapRow(existing)
}
// 不存在则创建默认记录
const id = createId()
try {
await db.insert(notificationPreferences).values({
id,
userId,
...DEFAULTS,
})
const [created] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.id, id))
.limit(1)
if (created) return mapRow(created)
} catch {
// 并发情况下可能违反唯一约束,回退到查询
const [fallback] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.userId, userId))
.limit(1)
if (fallback) return mapRow(fallback)
}
// 极端情况:返回内存中的默认值(不带 id
return {
id: "",
userId,
...DEFAULTS,
createdAt: toIso(new Date()),
updatedAt: toIso(new Date()),
}
}
)
/**
* 更新(或创建)用户的通知偏好设置
* 使用 upsert 语义:存在则更新,不存在则插入
*/
export async function upsertNotificationPreferences(
userId: string,
input: UpdateNotificationPreferencesInput
): Promise<NotificationPreferences | null> {
// 先查询是否存在
const [existing] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.userId, userId))
.limit(1)
if (existing) {
// 更新
const updateData: Partial<typeof notificationPreferences.$inferInsert> = {}
if (input.emailEnabled !== undefined) updateData.emailEnabled = input.emailEnabled
if (input.smsEnabled !== undefined) updateData.smsEnabled = input.smsEnabled
if (input.pushEnabled !== undefined) updateData.pushEnabled = input.pushEnabled
if (input.homeworkNotifications !== undefined) updateData.homeworkNotifications = input.homeworkNotifications
if (input.gradeNotifications !== undefined) updateData.gradeNotifications = input.gradeNotifications
if (input.announcementNotifications !== undefined) updateData.announcementNotifications = input.announcementNotifications
if (input.messageNotifications !== undefined) updateData.messageNotifications = input.messageNotifications
if (input.attendanceNotifications !== undefined) updateData.attendanceNotifications = input.attendanceNotifications
if (Object.keys(updateData).length === 0) {
return mapRow(existing)
}
await db
.update(notificationPreferences)
.set(updateData)
.where(and(eq(notificationPreferences.id, existing.id), eq(notificationPreferences.userId, userId)))
const [updated] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.id, existing.id))
.limit(1)
return updated ? mapRow(updated) : null
}
// 不存在则插入
const id = createId()
try {
await db.insert(notificationPreferences).values({
id,
userId,
emailEnabled: input.emailEnabled ?? DEFAULTS.emailEnabled,
smsEnabled: input.smsEnabled ?? DEFAULTS.smsEnabled,
pushEnabled: input.pushEnabled ?? DEFAULTS.pushEnabled,
homeworkNotifications: input.homeworkNotifications ?? DEFAULTS.homeworkNotifications,
gradeNotifications: input.gradeNotifications ?? DEFAULTS.gradeNotifications,
announcementNotifications: input.announcementNotifications ?? DEFAULTS.announcementNotifications,
messageNotifications: input.messageNotifications ?? DEFAULTS.messageNotifications,
attendanceNotifications: input.attendanceNotifications ?? DEFAULTS.attendanceNotifications,
})
const [created] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.id, id))
.limit(1)
return created ? mapRow(created) : null
} catch {
return null
}
}

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
export const SendMessageSchema = z
.object({
receiverId: z.string().trim().min(1),
subject: z.string().trim().max(255).optional().nullable(),
content: z.string().trim().min(1),
parentMessageId: z.string().trim().optional().nullable(),
})
.transform((v) => ({
receiverId: v.receiverId,
subject: v.subject && v.subject.length > 0 ? v.subject : null,
content: v.content,
parentMessageId:
v.parentMessageId && v.parentMessageId.length > 0 ? v.parentMessageId : null,
}))
export type SendMessageInput = z.infer<typeof SendMessageSchema>

View File

@@ -0,0 +1,108 @@
export type MessageType = "inbox" | "sent" | "all"
export interface Message {
id: string
senderId: string
senderName: string | null
receiverId: string
receiverName: string | null
subject: string | null
content: string
isRead: boolean
readAt: string | null
parentMessageId: string | null
createdAt: string
}
export type MessageListItem = Message
export interface MessageThread {
messages: Message[]
}
export type NotificationType = "message" | "announcement" | "homework" | "grade"
export interface Notification {
id: string
userId: string
type: NotificationType
title: string
content: string | null
link: string | null
isRead: boolean
createdAt: string
}
export type NotificationListItem = Notification
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface GetMessagesParams {
userId: string
type: MessageType
page?: number
pageSize?: number
}
export interface GetNotificationsParams {
page?: number
pageSize?: number
unreadOnly?: boolean
}
export interface CreateMessageInput {
senderId: string
receiverId: string
subject?: string | null
content: string
parentMessageId?: string | null
}
export interface CreateNotificationInput {
userId: string
type: NotificationType
title: string
content?: string | null
link?: string | null
}
export interface RecipientOption {
id: string
name: string
email: string
role?: string
}
// 通知偏好设置
export interface NotificationPreferences {
id: string
userId: string
emailEnabled: boolean
smsEnabled: boolean
pushEnabled: boolean
homeworkNotifications: boolean
gradeNotifications: boolean
announcementNotifications: boolean
messageNotifications: boolean
attendanceNotifications: boolean
createdAt: string
updatedAt: string
}
// 更新通知偏好的输入(部分字段可选,未提供则保留原值)
export interface UpdateNotificationPreferencesInput {
emailEnabled?: boolean
smsEnabled?: boolean
pushEnabled?: boolean
homeworkNotifications?: boolean
gradeNotifications?: boolean
announcementNotifications?: boolean
messageNotifications?: boolean
attendanceNotifications?: boolean
}

View File

@@ -0,0 +1,89 @@
import Link from "next/link"
import { ChevronRight, GraduationCap, PenTool, TriangleAlert } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import type { ChildDashboardData } from "@/modules/parent/types"
const getInitials = (name: string | null) => {
if (!name) return "S"
return name.slice(0, 2).toUpperCase()
}
export function ChildCard({ child }: { child: ChildDashboardData }) {
const { basicInfo, homeworkSummary, gradeTrend } = child
const ranking = gradeTrend.ranking
const latestGrade = gradeTrend.recent[0]
return (
<Link href={`/parent/children/${basicInfo.id}`} className="block">
<Card className="hover:bg-muted/50 transition-colors cursor-pointer h-full">
<CardHeader className="flex flex-row items-center gap-4 space-y-0">
<Avatar className="h-12 w-12">
<AvatarImage src={basicInfo.image ?? undefined} alt={basicInfo.name ?? "Child"} />
<AvatarFallback>{getInitials(basicInfo.name)}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-1">
<CardTitle className="text-base truncate">{basicInfo.name ?? "Unnamed"}</CardTitle>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{basicInfo.className ? (
<Badge variant="secondary" className="text-xs">
{basicInfo.className}
</Badge>
) : null}
{basicInfo.gradeName ? <span>{basicInfo.gradeName}</span> : null}
{basicInfo.relation ? <span>· {basicInfo.relation}</span> : null}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-md border bg-card p-2">
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<PenTool className="h-3 w-3" />
Pending
</div>
<div className="text-lg font-semibold tabular-nums">
{homeworkSummary.pendingCount}
</div>
</div>
<div className="rounded-md border bg-card p-2">
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<TriangleAlert className="h-3 w-3" />
Overdue
</div>
<div
className={`text-lg font-semibold tabular-nums ${
homeworkSummary.overdueCount > 0 ? "text-destructive" : ""
}`}
>
{homeworkSummary.overdueCount}
</div>
</div>
<div className="rounded-md border bg-card p-2">
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<GraduationCap className="h-3 w-3" />
Avg
</div>
<div className="text-lg font-semibold tabular-nums">
{ranking ? `${Math.round(ranking.percentage)}%` : "-"}
</div>
</div>
</div>
{latestGrade ? (
<div className="text-xs text-muted-foreground">
Latest:{" "}
<span className="font-medium text-foreground tabular-nums">
{latestGrade.score}/{latestGrade.maxScore}
</span>{" "}
({latestGrade.assignmentTitle.slice(0, 20)}
{latestGrade.assignmentTitle.length > 20 ? "..." : ""})
</div>
) : null}
</CardContent>
</Card>
</Link>
)
}

View File

@@ -0,0 +1,49 @@
import Link from "next/link"
import { ArrowLeft, GraduationCap } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import type { ChildDashboardData } from "@/modules/parent/types"
const getInitials = (name: string | null) => {
if (!name) return "S"
return name.slice(0, 2).toUpperCase()
}
export function ChildDetailHeader({ child }: { child: ChildDashboardData }) {
const { basicInfo } = child
return (
<div className="space-y-4">
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
<Link href="/parent/dashboard">
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Link>
</Button>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={basicInfo.image ?? undefined} alt={basicInfo.name ?? "Child"} />
<AvatarFallback className="text-lg">{getInitials(basicInfo.name)}</AvatarFallback>
</Avatar>
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{basicInfo.name ?? "Unnamed"}</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{basicInfo.className ? (
<Badge variant="secondary">{basicInfo.className}</Badge>
) : null}
{basicInfo.gradeName ? (
<span className="flex items-center gap-1">
<GraduationCap className="h-3 w-3" />
{basicInfo.gradeName}
</span>
) : null}
{basicInfo.relation ? <span>· {basicInfo.relation}</span> : null}
<span>· {basicInfo.email}</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { ChildGradeSummary } from "./child-grade-summary"
import { ChildHomeworkSummary } from "./child-homework-summary"
import { ChildScheduleCard } from "./child-schedule-card"
import type { ChildDashboardData } from "@/modules/parent/types"
export function ChildDetailPanel({ child }: { child: ChildDashboardData }) {
const { basicInfo, todaySchedule, homeworkSummary, gradeTrend } = child
const childName = basicInfo.name ?? "Child"
return (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<ChildHomeworkSummary
summary={homeworkSummary}
childId={basicInfo.id}
childName={childName}
/>
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
</div>
<div className="space-y-6">
<ChildScheduleCard items={todaySchedule} childName={childName} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
"use client"
import Link from "next/link"
import { BarChart3, Trophy } from "lucide-react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { formatDate } from "@/shared/lib/utils"
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
const chartConfig = {
score: {
label: "Score (%)",
color: "hsl(var(--primary))",
},
}
export function ChildGradeSummary({
grades,
childId,
childName,
}: {
grades: StudentDashboardGradeProps
childId: string
childName: string
}) {
const hasGradeTrend = grades.trend.length > 0
const ranking = grades.ranking
const latestGrade = grades.trend[grades.trend.length - 1]
const chartData = grades.trend.map((item) => ({
title: item.assignmentTitle,
score: Math.round(item.percentage),
fullTitle: item.assignmentTitle,
submittedAt: formatDate(item.submittedAt),
rawScore: item.score,
maxScore: item.maxScore,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
{childName}&apos;s Grades
</CardTitle>
</CardHeader>
<CardContent>
{!hasGradeTrend ? (
<EmptyState
icon={BarChart3}
title="No graded work yet"
description="Finish and submit assignments to see score trend."
className="border-none h-60"
/>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border bg-card p-3">
<div className="text-xs text-muted-foreground flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
Latest Score
</div>
<div className="text-xl font-semibold tabular-nums">
{latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"}
</div>
{latestGrade ? (
<div className="text-xs text-muted-foreground tabular-nums">
{latestGrade.score}/{latestGrade.maxScore}
</div>
) : null}
</div>
<div className="rounded-md border bg-card p-3">
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Trophy className="h-3 w-3" />
Class Rank
</div>
<div className="text-xl font-semibold tabular-nums">
{ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
</div>
{ranking ? (
<div className="text-xs text-muted-foreground">Current position</div>
) : null}
</div>
</div>
<div className="rounded-md border bg-card p-3">
<ChartContainer config={chartConfig} className="h-[160px] w-full">
<LineChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
dataKey="title"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
value.slice(0, 8) + (value.length > 8 ? "..." : "")
}
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}%`}
width={30}
/>
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={
<ChartTooltipContent
indicator="line"
labelKey="fullTitle"
className="w-[200px]"
/>
}
/>
<Line
dataKey="score"
type="monotone"
stroke="var(--color-score)"
strokeWidth={2}
dot={{ fill: "var(--color-score)", r: 3, strokeWidth: 2 }}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
</LineChart>
</ChartContainer>
</div>
{grades.recent.length > 0 ? (
<div className="space-y-2">
<div className="text-xs font-medium uppercase text-muted-foreground">
Recent Grades
</div>
{grades.recent.slice(0, 3).map((r) => (
<Link
key={r.assignmentId}
href={`/parent/children/${childId}`}
className="flex items-center justify-between rounded-md border bg-card p-2 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{r.assignmentTitle}</div>
<div className="text-xs text-muted-foreground">{formatDate(r.submittedAt)}</div>
</div>
<Badge variant="secondary" className="tabular-nums shrink-0 ml-2">
{r.score}/{r.maxScore}
</Badge>
</Link>
))}
</div>
) : null}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,131 @@
import Link from "next/link"
import { PenTool, TriangleAlert } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { cn, formatDate } from "@/shared/lib/utils"
import type { ChildHomeworkSummary } from "@/modules/parent/types"
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded") return "default"
if (status === "submitted" || status === "in_progress") return "secondary"
return "outline"
}
const getStatusLabel = (status: string) => {
if (status === "graded") return "Graded"
if (status === "submitted") return "Submitted"
if (status === "in_progress") return "In progress"
return "Not started"
}
const getDueUrgency = (dueAt: string | null) => {
if (!dueAt) return null
const now = new Date()
const due = new Date(dueAt)
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
if (diffHours < 0) return "overdue"
if (diffHours < 48) return "urgent"
return "normal"
}
export function ChildHomeworkSummary({
summary,
childId,
childName,
}: {
summary: ChildHomeworkSummary
childId: string
childName: string
}) {
const hasAssignments = summary.recentAssignments.length > 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<PenTool className="h-4 w-4 text-muted-foreground" />
{childName}&apos;s Homework
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-center">
<div className="rounded-md border bg-card p-2">
<div className="text-xs text-muted-foreground">Pending</div>
<div className="text-lg font-semibold tabular-nums">{summary.pendingCount}</div>
</div>
<div className="rounded-md border bg-card p-2">
<div className="text-xs text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold tabular-nums">{summary.submittedCount}</div>
</div>
<div className="rounded-md border bg-card p-2">
<div className="text-xs text-muted-foreground">Graded</div>
<div className="text-lg font-semibold tabular-nums">{summary.gradedCount}</div>
</div>
<div className="rounded-md border bg-card p-2">
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<TriangleAlert className="h-3 w-3" />
Overdue
</div>
<div
className={cn(
"text-lg font-semibold tabular-nums",
summary.overdueCount > 0 && "text-destructive",
)}
>
{summary.overdueCount}
</div>
</div>
</div>
{!hasAssignments ? (
<EmptyState
icon={PenTool}
title="No assignments"
description="No homework assigned right now."
className="border-none h-40"
/>
) : (
<div className="space-y-2">
<div className="text-xs font-medium uppercase text-muted-foreground">
Recent Assignments
</div>
{summary.recentAssignments.map((a) => {
const urgency = getDueUrgency(a.dueAt)
const isGraded = a.progressStatus === "graded"
return (
<Link
key={a.id}
href={`/parent/children/${childId}`}
className="flex items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="font-medium text-sm truncate">{a.title}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant={getStatusVariant(a.progressStatus)} className="text-[10px]">
{getStatusLabel(a.progressStatus)}
</Badge>
{a.dueAt ? (
<span
className={cn(
!isGraded && urgency === "overdue" && "text-destructive font-medium",
)}
>
Due {formatDate(a.dueAt)}
</span>
) : null}
</div>
</div>
<div className="text-sm font-medium tabular-nums shrink-0 ml-2">
{a.latestScore ?? "-"}
</div>
</Link>
)
})}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,67 @@
import { CalendarDays, CalendarX, Clock, MapPin } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { ChildScheduleItem } from "@/modules/parent/types"
export function ChildScheduleCard({
items,
childName,
}: {
items: ChildScheduleItem[]
childName: string
}) {
const hasSchedule = items.length > 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
{childName}&apos;s Today Schedule
</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (
<EmptyState
icon={CalendarX}
title="No classes today"
description="The timetable is clear for today."
className="border-none h-60"
/>
) : (
<div className="space-y-3">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0"
>
<div className="space-y-1 min-w-0">
<div className="font-medium leading-none truncate">{item.course}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="flex items-center">
<Clock className="mr-1 h-3 w-3" />
<span>
{item.startTime}{item.endTime}
</span>
</div>
{item.location ? (
<div className="flex items-center">
<MapPin className="mr-1 h-3 w-3" />
<span className="truncate">{item.location}</span>
</div>
) : null}
</div>
</div>
<Badge variant="secondary" className="shrink-0">
{item.className}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,68 @@
import { CalendarDays, GraduationCap, Users } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ChildCard } from "./child-card"
import type { ParentDashboardData } from "@/modules/parent/types"
export function ParentDashboard({ data }: { data: ParentDashboardData }) {
const { parentName, children } = data
const hasChildren = children.length > 0
const hour = new Date().getHours()
let greeting = "Welcome"
if (hour < 12) greeting = "Good morning"
else if (hour < 18) greeting = "Good afternoon"
else greeting = "Good evening"
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Parent Dashboard</h1>
<div className="text-sm text-muted-foreground">
{greeting}
{parentName ? `, ${parentName}` : ""}. Here&apos;s an overview of your children.
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm" className="gap-2">
<a href="/parent/grades">
<GraduationCap className="h-4 w-4" />
Grades
</a>
</Button>
<Button asChild variant="outline" size="sm" className="gap-2">
<a href="/announcements">
<CalendarDays className="h-4 w-4" />
Announcements
</a>
</Button>
</div>
</div>
{!hasChildren ? (
<EmptyState
icon={Users}
title="No children linked"
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
className="border-none shadow-none"
/>
) : (
<>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span>
{children.length} {children.length === 1 ? "child" : "children"} linked
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{children.map((child) => (
<ChildCard key={child.basicInfo.id} child={child} />
))}
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,234 @@
import "server-only"
import { cache } from "react"
import { and, asc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
grades,
parentStudentRelations,
users,
} from "@/shared/db/schema"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import {
getStudentDashboardGrades,
getStudentHomeworkAssignments,
} from "@/modules/homework/data-access"
import { getStudentGradeSummary } from "@/modules/grades/data-access"
import type {
ChildDashboardData,
ChildHomeworkSummary,
ChildScheduleItem,
ParentChildRelation,
ParentDashboardData,
} from "./types"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay()
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
export const getChildren = cache(async (parentId: string): Promise<ParentChildRelation[]> => {
const id = parentId.trim()
if (!id) return []
const rows = await db
.select({
id: parentStudentRelations.id,
parentId: parentStudentRelations.parentId,
studentId: parentStudentRelations.studentId,
relation: parentStudentRelations.relation,
createdAt: parentStudentRelations.createdAt,
})
.from(parentStudentRelations)
.where(eq(parentStudentRelations.parentId, id))
.orderBy(asc(parentStudentRelations.createdAt))
return rows.map((r) => ({
id: r.id,
parentId: r.parentId,
studentId: r.studentId,
relation: r.relation,
createdAt: r.createdAt.toISOString(),
}))
})
export const getChildBasicInfo = cache(async (studentId: string, relation: string | null = null) => {
const [student] = await db
.select({
id: users.id,
name: users.name,
email: users.email,
image: users.image,
gradeId: users.gradeId,
})
.from(users)
.where(eq(users.id, studentId))
.limit(1)
if (!student) return null
let gradeName: string | null = null
if (student.gradeId) {
const [grade] = await db
.select({ name: grades.name })
.from(grades)
.where(eq(grades.id, student.gradeId))
.limit(1)
gradeName = grade?.name ?? null
}
const [enrollment] = await db
.select({
classId: classEnrollments.classId,
status: classEnrollments.status,
})
.from(classEnrollments)
.where(and(eq(classEnrollments.studentId, studentId), eq(classEnrollments.status, "active")))
.orderBy(asc(classEnrollments.createdAt))
.limit(1)
let className: string | null = null
let classId: string | null = null
if (enrollment) {
const [cls] = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.id, enrollment.classId))
.limit(1)
if (cls) {
classId = cls.id
className = cls.name
}
}
return {
id: student.id,
name: student.name,
email: student.email,
image: student.image,
gradeName,
className,
classId,
relation,
}
})
const buildHomeworkSummary = (
assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>,
): ChildHomeworkSummary => {
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
let pendingCount = 0
let submittedCount = 0
let gradedCount = 0
let overdueCount = 0
for (const a of assignments) {
if (a.progressStatus === "graded") {
gradedCount += 1
continue
}
if (a.progressStatus === "submitted") {
submittedCount += 1
}
if (a.progressStatus === "not_started" || a.progressStatus === "in_progress") {
pendingCount += 1
}
if (a.dueAt) {
const due = new Date(a.dueAt)
if (due < now && a.progressStatus !== "submitted") {
overdueCount += 1
}
}
}
const recentAssignments = [...assignments]
.sort((a, b) => {
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
return aDue - bDue
})
.slice(0, 5)
return {
pendingCount,
submittedCount,
gradedCount,
overdueCount,
recentAssignments,
}
}
const buildTodaySchedule = (
schedule: Awaited<ReturnType<typeof getStudentSchedule>>,
): ChildScheduleItem[] => {
const todayWeekday = toWeekday(new Date())
return schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
.sort((a, b) => a.startTime.localeCompare(b.startTime))
}
export const getChildDashboardData = cache(
async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
const basicInfo = await getChildBasicInfo(studentId, relation)
if (!basicInfo) return null
const [enrolledClasses, schedule, assignments, gradeTrend, gradeSummary] = await Promise.all([
getStudentClasses(studentId),
getStudentSchedule(studentId),
getStudentHomeworkAssignments(studentId),
getStudentDashboardGrades(studentId),
getStudentGradeSummary(studentId),
])
return {
basicInfo,
enrolledClasses,
todaySchedule: buildTodaySchedule(schedule),
homeworkSummary: buildHomeworkSummary(assignments),
gradeTrend,
gradeSummary,
}
},
)
export const getParentDashboardData = cache(
async (parentId: string): Promise<ParentDashboardData> => {
const id = parentId.trim()
if (!id) return { parentName: null, children: [] }
const [parent, relations] = await Promise.all([
db.select({ name: users.name }).from(users).where(eq(users.id, id)).limit(1),
getChildren(id),
])
const parentName = parent[0]?.name ?? null
if (relations.length === 0) {
return { parentName, children: [] }
}
const children = await Promise.all(
relations.map((r) => getChildDashboardData(r.studentId, r.relation)),
)
return {
parentName,
children: children.filter((c): c is ChildDashboardData => c !== null),
}
},
)

View File

@@ -0,0 +1,57 @@
import type {
StudentDashboardGradeProps,
StudentHomeworkAssignmentListItem,
} from "@/modules/homework/types"
import type { StudentEnrolledClass } from "@/modules/classes/types"
import type { StudentGradeSummary } from "@/modules/grades/types"
export type ParentChildRelation = {
id: string
parentId: string
studentId: string
relation: string | null
createdAt: string
}
export type ChildBasicInfo = {
id: string
name: string | null
email: string
image: string | null
gradeName: string | null
className: string | null
classId: string | null
relation: string | null
}
export type ChildScheduleItem = {
id: string
classId: string
className: string
course: string
startTime: string
endTime: string
location: string | null
}
export type ChildHomeworkSummary = {
pendingCount: number
submittedCount: number
gradedCount: number
overdueCount: number
recentAssignments: StudentHomeworkAssignmentListItem[]
}
export type ChildDashboardData = {
basicInfo: ChildBasicInfo
enrolledClasses: StudentEnrolledClass[]
todaySchedule: ChildScheduleItem[]
homeworkSummary: ChildHomeworkSummary
gradeTrend: StudentDashboardGradeProps
gradeSummary: StudentGradeSummary | null
}
export type ParentDashboardData = {
parentName: string | null
children: ChildDashboardData[]
}

View File

@@ -76,7 +76,7 @@ export function QuestionActions({ question }: QuestionActionsProps) {
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Open menu">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>

View File

@@ -0,0 +1,302 @@
"use server"
import { revalidatePath } from "next/cache"
import { eq, or } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { db } from "@/shared/db"
import { classSchedule, users } from "@/shared/db/schema"
import {
getSchedulingRules,
upsertSchedulingRules,
getScheduleChanges,
createScheduleChange,
updateScheduleChangeStatus,
getClassConflicts,
getAdminClassesForScheduling,
getTeachersForScheduling,
getClassroomsForScheduling,
getClassSubjectsForScheduling,
} from "./data-access"
import { autoSchedule, buildDefaultTimeSlots } from "./auto-scheduler"
import {
SchedulingRuleSchema,
ScheduleChangeSchema,
AutoScheduleParamsSchema,
} from "./schema"
import type {
AutoScheduleParams,
AutoScheduleResult,
ScheduleChangeListItem,
ScheduleConflict,
ScheduleChangeQueryParams,
} from "./types"
export async function saveSchedulingRulesAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.SCHEDULE_ADJUST)
const parsed = SchedulingRuleSchema.safeParse({
classId: formData.get("classId"),
maxDailyHours: formData.get("maxDailyHours") || undefined,
maxContinuousHours: formData.get("maxContinuousHours") || undefined,
lunchBreakStart: formData.get("lunchBreakStart") || undefined,
lunchBreakEnd: formData.get("lunchBreakEnd") || undefined,
morningStart: formData.get("morningStart") || undefined,
afternoonEnd: formData.get("afternoonEnd") || undefined,
avoidBackToBack: formData.get("avoidBackToBack") === "true",
balancedSubjects: formData.get("balancedSubjects") === "true",
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await upsertSchedulingRules(parsed.data)
revalidatePath("/admin/scheduling/rules")
return { success: true, message: "Scheduling 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 autoScheduleAction(
prevState: ActionState<AutoScheduleResult> | null,
formData: FormData
): Promise<ActionState<AutoScheduleResult>> {
try {
await requirePermission(Permissions.SCHEDULE_AUTO)
const classId = String(formData.get("classId") ?? "").trim()
if (!classId) return { success: false, message: "Class is required" }
// Load rules for class (or global fallback)
const rulesRows = await getSchedulingRules(classId)
const rules = rulesRows.find((r) => r.classId === classId) ?? rulesRows.find((r) => r.classId === null)
if (!rules) return { success: false, message: "No scheduling rules found. Please configure rules first." }
// Load subjects + teacher assignments for this class
const subjectRows = await getClassSubjectsForScheduling(classId)
if (subjectRows.length === 0) {
return { success: false, message: "No subjects assigned to this class" }
}
// Default weekly hours: 5 per subject (configurable in future)
const subjectsInput = subjectRows.map((r) => ({
subjectId: r.subjectId,
subjectName: r.subjectName,
weeklyHours: 5,
teacherId: r.teacherId ?? null,
}))
// Load teachers
const teacherIds = Array.from(
new Set(subjectRows.map((r) => r.teacherId).filter((v): v is string => v !== null))
)
const teacherRows =
teacherIds.length > 0
? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(teacherIds.length === 1 ? eq(users.id, teacherIds[0]!) : or(...teacherIds.map((id) => eq(users.id, id))))
: []
const teachersInput = teacherRows.map((t) => ({ id: t.id, name: t.name ?? "Unknown" }))
// Load classrooms
const classroomRows = await getClassroomsForScheduling()
const classroomsInput = classroomRows.map((c) => ({ id: c.id, name: c.name }))
// Build default time slots: Mon-Fri, 4 morning + 4 afternoon sessions
const timeSlots = buildDefaultTimeSlots(rules.morningStart, rules.afternoonEnd, rules.lunchBreakStart, rules.lunchBreakEnd)
const params: AutoScheduleParams = {
classId,
rules,
subjects: subjectsInput,
teachers: teachersInput,
classrooms: classroomsInput,
timeSlots,
}
const parsed = AutoScheduleParamsSchema.safeParse(params)
if (!parsed.success) {
return { success: false, message: "Invalid scheduling parameters", errors: parsed.error.flatten().fieldErrors }
}
const result = autoSchedule(params)
return { success: true, message: `Scheduled ${result.scheduledCount} sessions, ${result.conflictCount} conflicts`, 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 applyAutoScheduleAction(
classId: string,
schedules: Array<{
weekday: number
startTime: string
endTime: string
course: string
location: string | null
}>
): Promise<ActionState<number>> {
try {
await requirePermission(Permissions.SCHEDULE_AUTO)
if (!classId) return { success: false, message: "Class is required" }
if (!Array.isArray(schedules) || schedules.length === 0) {
return { success: false, message: "No schedules to apply" }
}
// Replace existing schedule for the class
await db.transaction(async (tx) => {
await tx.delete(classSchedule).where(eq(classSchedule.classId, classId))
const rows = schedules.map((s) => ({
id: createId(),
classId,
weekday: s.weekday,
startTime: s.startTime,
endTime: s.endTime,
course: s.course,
location: s.location ?? null,
}))
await tx.insert(classSchedule).values(rows)
})
revalidatePath("/admin/scheduling/auto")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: `Applied ${schedules.length} schedule items`, data: schedules.length }
} 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 requestScheduleChangeAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.SCHEDULE_ADJUST)
const parsed = ScheduleChangeSchema.safeParse({
classId: formData.get("classId"),
originalScheduleId: formData.get("originalScheduleId") || undefined,
originalTeacherId: formData.get("originalTeacherId") || undefined,
substituteTeacherId: formData.get("substituteTeacherId") || undefined,
originalDate: formData.get("originalDate") || undefined,
newDate: formData.get("newDate") || undefined,
newStartTime: formData.get("newStartTime") || undefined,
newEndTime: formData.get("newEndTime") || undefined,
reason: formData.get("reason"),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await createScheduleChange(parsed.data, ctx.userId)
revalidatePath("/teacher/schedule-changes")
revalidatePath("/admin/scheduling/changes")
return { success: true, message: "Schedule change request submitted", 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 approveScheduleChangeAction(changeId: string): Promise<ActionState> {
try {
const ctx = await requirePermission(Permissions.SCHEDULE_AUTO)
if (!changeId) return { success: false, message: "Missing change id" }
await updateScheduleChangeStatus(changeId, "approved", ctx.userId)
revalidatePath("/admin/scheduling/changes")
revalidatePath("/teacher/schedule-changes")
return { success: true, message: "Schedule change approved" }
} 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 rejectScheduleChangeAction(
changeId: string,
reason?: string
): Promise<ActionState> {
try {
const ctx = await requirePermission(Permissions.SCHEDULE_AUTO)
if (!changeId) return { success: false, message: "Missing change id" }
await updateScheduleChangeStatus(changeId, "rejected", ctx.userId)
revalidatePath("/admin/scheduling/changes")
revalidatePath("/teacher/schedule-changes")
return { success: true, message: reason ? `Rejected: ${reason}` : "Schedule change rejected" }
} 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 getScheduleChangesAction(
params: ScheduleChangeQueryParams
): Promise<ActionState<ScheduleChangeListItem[]>> {
try {
await requirePermission(Permissions.SCHEDULE_ADJUST)
const items = await getScheduleChanges(params)
return { success: true, data: items }
} 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 getClassConflictsAction(
classId: string
): Promise<ActionState<ScheduleConflict[]>> {
try {
await requirePermission(Permissions.SCHEDULE_ADJUST)
if (!classId) return { success: false, message: "Class is required" }
const conflicts = await getClassConflicts(classId)
return { success: true, data: conflicts }
} 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" }
}
}
// Re-export data-access for server pages
export {
getSchedulingRules,
getScheduleChanges,
getClassConflicts,
getAdminClassesForScheduling,
getTeachersForScheduling,
getClassroomsForScheduling,
}

View File

@@ -0,0 +1,310 @@
import "server-only"
import type {
AutoScheduleParams,
AutoScheduleResult,
GeneratedSchedule,
ScheduleConflict,
SchedulingRule,
TimeSlot,
} from "./types"
/**
* Convert "HH:MM" to minutes since midnight for easy comparison.
*/
const toMinutes = (t: string): number => {
const [h, m] = t.split(":").map(Number)
return (h ?? 0) * 60 + (m ?? 0)
}
/**
* Check if two time ranges overlap on the same weekday.
*/
const isOverlap = (
a: { weekday: number; startTime: string; endTime: string },
b: { weekday: number; startTime: string; endTime: string }
): boolean => {
if (a.weekday !== b.weekday) return false
const aStart = toMinutes(a.startTime)
const aEnd = toMinutes(a.endTime)
const bStart = toMinutes(b.startTime)
const bEnd = toMinutes(b.endTime)
return aStart < bEnd && bStart < aEnd
}
/**
* Check if a candidate slot falls within the lunch break.
*/
const isWithinLunchBreak = (slot: TimeSlot, rules: SchedulingRule): boolean => {
const start = toMinutes(slot.startTime)
const end = toMinutes(slot.endTime)
const lunchStart = toMinutes(rules.lunchBreakStart)
const lunchEnd = toMinutes(rules.lunchBreakEnd)
return start < lunchEnd && lunchStart < end
}
/**
* Check if a candidate slot is outside the allowed daily window.
*/
const isOutsideDailyWindow = (slot: TimeSlot, rules: SchedulingRule): boolean => {
const start = toMinutes(slot.startTime)
const end = toMinutes(slot.endTime)
return start < toMinutes(rules.morningStart) || end > toMinutes(rules.afternoonEnd)
}
/**
* Count existing schedules for a given weekday.
*/
const countByWeekday = (schedules: GeneratedSchedule[], weekday: number): number =>
schedules.filter((s) => s.weekday === weekday).length
/**
* Find an optimal time slot for a single course session.
* Greedy: pick the first slot that satisfies all constraints.
*/
export function findOptimalSlot(params: {
candidateSlots: TimeSlot[]
existingSchedules: GeneratedSchedule[]
rules: SchedulingRule
teacherId: string | null
classroomId: string | null
usedSlots: Set<string>
}): TimeSlot | null {
const {
candidateSlots,
existingSchedules,
rules,
teacherId,
classroomId,
usedSlots,
} = params
for (const slot of candidateSlots) {
if (isWithinLunchBreak(slot, rules)) continue
if (isOutsideDailyWindow(slot, rules)) continue
const slotKey = `${slot.weekday}-${slot.startTime}-${slot.endTime}`
if (usedSlots.has(slotKey)) continue
// Class overlap check (same class can't have two courses at the same time)
const classConflict = existingSchedules.some((s) => isOverlap(s, slot))
if (classConflict) continue
// Teacher conflict (teacher can't teach two classes at the same time)
if (teacherId) {
const teacherConflict = existingSchedules.some(
(s) => s.teacherId === teacherId && isOverlap(s, slot)
)
if (teacherConflict) continue
}
// Classroom conflict (classroom can't be used by two classes at the same time)
if (classroomId) {
const roomConflict = existingSchedules.some(
(s) => s.location === classroomId && isOverlap(s, slot)
)
if (roomConflict) continue
}
// Daily max hours check
const dailyCount = countByWeekday(existingSchedules, slot.weekday)
if (dailyCount >= rules.maxDailyHours) continue
// Avoid back-to-back if rule is set
if (rules.avoidBackToBack) {
const hasBackToBack = existingSchedules.some((s) => {
if (s.weekday !== slot.weekday) return false
return (
toMinutes(s.endTime) === toMinutes(slot.startTime) ||
toMinutes(s.startTime) === toMinutes(slot.endTime)
)
})
if (hasBackToBack) continue
}
return slot
}
return null
}
/**
* Validate a generated schedule against the rules.
* Returns a list of conflicts/violations found.
*/
export function validateSchedule(
schedule: GeneratedSchedule[],
rules: SchedulingRule
): ScheduleConflict[] {
const conflicts: ScheduleConflict[] = []
// Pairwise overlap check (class/teacher/classroom)
for (let i = 0; i < schedule.length; i += 1) {
for (let j = i + 1; j < schedule.length; j += 1) {
const a = schedule[i]!
const b = schedule[j]!
if (!isOverlap(a, b)) continue
if (a.teacherId && a.teacherId === b.teacherId) {
conflicts.push({
type: "teacher_overlap",
description: `Teacher ${a.teacherId} has overlapping sessions on day ${a.weekday}`,
scheduleIds: [a.classId, b.classId],
})
}
if (a.location && a.location === b.location) {
conflicts.push({
type: "classroom_overlap",
description: `Classroom ${a.location} double-booked on day ${a.weekday}`,
scheduleIds: [a.classId, b.classId],
})
}
if (a.classId === b.classId) {
conflicts.push({
type: "class_overlap",
description: `Class ${a.classId} has overlapping sessions on day ${a.weekday}`,
scheduleIds: [a.classId, b.classId],
})
}
}
}
// Daily max hours check
const byClassDay = new Map<string, number>()
for (const s of schedule) {
const key = `${s.classId}-${s.weekday}`
byClassDay.set(key, (byClassDay.get(key) ?? 0) + 1)
}
for (const [key, count] of byClassDay.entries()) {
if (count > rules.maxDailyHours) {
conflicts.push({
type: "rule_violation",
description: `${key} exceeds max daily hours (${count} > ${rules.maxDailyHours})`,
scheduleIds: [],
})
}
}
return conflicts
}
/**
* Core auto-scheduling algorithm: greedy + backtracking (limited).
* - Sorts subjects by weekly hours (desc) to schedule heavy subjects first.
* - For each subject, tries to allocate `weeklyHours` sessions.
* - Picks the first valid slot for each session.
*/
export function autoSchedule(params: AutoScheduleParams): AutoScheduleResult {
const { classId, rules, subjects, classrooms, timeSlots } = params
const sortedSubjects = [...subjects].sort((a, b) => b.weeklyHours - a.weeklyHours)
const scheduled: GeneratedSchedule[] = []
const conflicts: ScheduleConflict[] = []
const usedSlots = new Set<string>()
const classroomList = classrooms
for (const subject of sortedSubjects) {
let placed = 0
let attempts = 0
const maxAttempts = timeSlots.length * 4
while (placed < subject.weeklyHours && attempts < maxAttempts) {
attempts += 1
const slot = findOptimalSlot({
candidateSlots: timeSlots,
existingSchedules: scheduled,
rules,
teacherId: subject.teacherId,
classroomId: classroomList[0]?.id ?? null,
usedSlots,
})
if (!slot) {
conflicts.push({
type: "rule_violation",
description: `Cannot find a slot for "${subject.subjectName}" (session ${placed + 1}/${subject.weeklyHours})`,
scheduleIds: [],
})
break
}
const slotKey = `${slot.weekday}-${slot.startTime}-${slot.endTime}`
usedSlots.add(slotKey)
scheduled.push({
classId,
weekday: slot.weekday,
startTime: slot.startTime,
endTime: slot.endTime,
course: subject.subjectName,
location: classroomList[0]?.name ?? null,
teacherId: subject.teacherId ?? null,
subjectId: subject.subjectId,
})
placed += 1
}
}
// Final validation pass
const validationConflicts = validateSchedule(scheduled, rules)
conflicts.push(...validationConflicts)
return {
success: conflicts.length === 0,
scheduledCount: scheduled.length,
conflictCount: conflicts.length,
conflicts,
schedules: scheduled,
}
}
/**
* Build default time slots for a week (Mon-Fri, 4 morning + 4 afternoon sessions).
* Each session is 50 minutes with a 10-minute break.
*/
export function buildDefaultTimeSlots(
morningStart: string,
afternoonEnd: string,
lunchStart: string,
lunchEnd: string
): Array<{ weekday: number; startTime: string; endTime: string }> {
const slots: Array<{ weekday: number; startTime: string; endTime: string }> = []
const toMin = (t: string) => {
const [h, m] = t.split(":").map(Number)
return (h ?? 0) * 60 + (m ?? 0)
}
const fromMin = (m: number) => {
const h = Math.floor(m / 60)
const mm = m % 60
return `${String(h).padStart(2, "0")}:${String(mm).padStart(2, "0")}`
}
const startM = toMin(morningStart)
const lunchStartM = toMin(lunchStart)
const lunchEndM = toMin(lunchEnd)
const endM = toMin(afternoonEnd)
let cur = startM
const morningSlots: Array<{ startTime: string; endTime: string }> = []
for (let i = 0; i < 4 && cur + 50 <= lunchStartM; i += 1) {
morningSlots.push({ startTime: fromMin(cur), endTime: fromMin(cur + 50) })
cur += 60
}
cur = lunchEndM
const afternoonSlots: Array<{ startTime: string; endTime: string }> = []
for (let i = 0; i < 4 && cur + 50 <= endM; i += 1) {
afternoonSlots.push({ startTime: fromMin(cur), endTime: fromMin(cur + 50) })
cur += 60
}
for (let weekday = 1; weekday <= 5; weekday += 1) {
for (const s of morningSlots) slots.push({ weekday, ...s })
for (const s of afternoonSlots) slots.push({ weekday, ...s })
}
return slots
}

View File

@@ -0,0 +1,120 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { autoScheduleAction, applyAutoScheduleAction } from "../actions"
import { AutoScheduleResultView } from "./auto-schedule-result"
import type { AutoScheduleResult } from "../types"
type ClassOption = { id: string; name: string; grade: string }
export function AutoSchedulePanel({ classes }: { classes: ClassOption[] }) {
const router = useRouter()
const [classId, setClassId] = useState(classes[0]?.id ?? "")
const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false)
const [result, setResult] = useState<AutoScheduleResult | null>(null)
const handlePreview = async () => {
if (!classId) {
toast.error("Please select a class")
return
}
setLoading(true)
try {
const formData = new FormData()
formData.set("classId", classId)
const res = await autoScheduleAction(null, formData)
if (res.success && res.data) {
setResult(res.data)
toast.success(res.message)
} else {
toast.error(res.message || "Failed to generate schedule")
}
} catch {
toast.error("Failed to generate schedule")
} finally {
setLoading(false)
}
}
const handleApply = async () => {
if (!result || !classId) return
setApplying(true)
try {
const res = await applyAutoScheduleAction(
classId,
result.schedules.map((s) => ({
weekday: s.weekday,
startTime: s.startTime,
endTime: s.endTime,
course: s.course,
location: s.location,
}))
)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to apply schedule")
}
} catch {
toast.error("Failed to apply schedule")
} finally {
setApplying(false)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Auto Schedule</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<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.grade} - {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button onClick={handlePreview} disabled={loading || !classId}>
{loading ? "Generating..." : "Preview Schedule"}
</Button>
{result && (
<Button onClick={handleApply} disabled={applying || result.schedules.length === 0} variant="default">
{applying ? "Applying..." : "Apply to Class"}
</Button>
)}
</div>
</CardContent>
</Card>
{result && <AutoScheduleResultView result={result} />}
</div>
)
}

View File

@@ -0,0 +1,94 @@
"use client"
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 { AlertTriangle, CheckCircle2 } from "lucide-react"
import type { AutoScheduleResult } from "../types"
const WEEKDAY_LABELS = ["", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
export function AutoScheduleResultView({ result }: { result: AutoScheduleResult }) {
const sortedSchedules = [...result.schedules].sort(
(a, b) => a.weekday - b.weekday || a.startTime.localeCompare(b.startTime)
)
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Generated Schedule</span>
<div className="flex items-center gap-2 text-sm font-normal">
<Badge variant="secondary">{result.scheduledCount} sessions</Badge>
<Badge variant={result.conflictCount > 0 ? "destructive" : "default"}>
{result.conflictCount} conflicts
</Badge>
</div>
</CardTitle>
</CardHeader>
<CardContent>
{sortedSchedules.length === 0 ? (
<p className="text-muted-foreground text-sm">No sessions generated.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Day</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>Course</TableHead>
<TableHead>Location</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedSchedules.map((s, idx) => (
<TableRow key={`${s.weekday}-${s.startTime}-${idx}`}>
<TableCell>{WEEKDAY_LABELS[s.weekday] ?? `Day ${s.weekday}`}</TableCell>
<TableCell>{s.startTime}</TableCell>
<TableCell>{s.endTime}</TableCell>
<TableCell className="font-medium">{s.course}</TableCell>
<TableCell>{s.location ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{result.conflicts.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="text-destructive h-5 w-5" />
<span>Conflicts & Warnings</span>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{result.conflicts.map((c, idx) => (
<li key={idx} className="text-muted-foreground">
<Badge variant="outline" className="mr-2">
{c.type}
</Badge>
{c.description}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{result.conflicts.length === 0 && result.scheduledCount > 0 && (
<Card>
<CardContent className="flex items-center gap-2 py-4 text-sm">
<CheckCircle2 className="text-primary h-5 w-5" />
<span>No conflicts detected. The schedule is ready to apply.</span>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,208 @@
"use client"
import { useState } from "react"
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 { Textarea } from "@/shared/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { requestScheduleChangeAction } from "../actions"
type ClassOption = { id: string; name: string; grade: string }
type TeacherOption = { id: string; name: string; email: string }
export function ScheduleChangeForm({
classes,
teachers,
defaultClassId,
}: {
classes: ClassOption[]
teachers: TeacherOption[]
defaultClassId?: string
}) {
const router = useRouter()
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [originalTeacherId, setOriginalTeacherId] = useState("")
const [substituteTeacherId, setSubstituteTeacherId] = useState("")
const [originalDate, setOriginalDate] = useState("")
const [newDate, setNewDate] = useState("")
const [newStartTime, setNewStartTime] = useState("")
const [newEndTime, setNewEndTime] = useState("")
const [reason, setReason] = useState("")
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (formData: FormData) => {
if (!classId) {
toast.error("Please select a class")
return
}
if (!reason.trim()) {
toast.error("Reason is required")
return
}
setSubmitting(true)
try {
formData.set("classId", classId)
if (originalTeacherId) formData.set("originalTeacherId", originalTeacherId)
if (substituteTeacherId) formData.set("substituteTeacherId", substituteTeacherId)
if (originalDate) formData.set("originalDate", originalDate)
if (newDate) formData.set("newDate", newDate)
if (newStartTime) formData.set("newStartTime", newStartTime)
if (newEndTime) formData.set("newEndTime", newEndTime)
formData.set("reason", reason)
const res = await requestScheduleChangeAction(null, formData)
if (res.success) {
toast.success(res.message)
router.refresh()
// Reset form
setOriginalDate("")
setNewDate("")
setNewStartTime("")
setNewEndTime("")
setReason("")
setOriginalTeacherId("")
setSubstituteTeacherId("")
} else {
toast.error(res.message || "Failed to submit request")
}
} catch {
toast.error("Failed to submit request")
} finally {
setSubmitting(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>Schedule Change Request</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-4">
<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.grade} - {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="originalTeacherId">Original Teacher (optional)</Label>
<Select value={originalTeacherId} onValueChange={setOriginalTeacherId}>
<SelectTrigger id="originalTeacherId">
<SelectValue placeholder="Select original teacher" />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="substituteTeacherId">Substitute Teacher (optional)</Label>
<Select value={substituteTeacherId} onValueChange={setSubstituteTeacherId}>
<SelectTrigger id="substituteTeacherId">
<SelectValue placeholder="Select substitute teacher" />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="originalDate">Original Date (optional)</Label>
<Input
id="originalDate"
type="date"
value={originalDate}
onChange={(e) => setOriginalDate(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="newDate">New Date (optional)</Label>
<Input
id="newDate"
type="date"
value={newDate}
onChange={(e) => setNewDate(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="newStartTime">New Start Time (optional)</Label>
<Input
id="newStartTime"
type="time"
value={newStartTime}
onChange={(e) => setNewStartTime(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="newEndTime">New End Time (optional)</Label>
<Input
id="newEndTime"
type="time"
value={newEndTime}
onChange={(e) => setNewEndTime(e.target.value)}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="reason">Reason</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Explain the reason for this schedule change..."
rows={3}
/>
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "Submitting..." : "Submit Request"}
</Button>
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,219 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Check, X } 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 { Textarea } from "@/shared/components/ui/textarea"
import { Label } from "@/shared/components/ui/label"
import { formatDate } from "@/shared/lib/utils"
import { approveScheduleChangeAction, rejectScheduleChangeAction } from "../actions"
import {
SCHEDULE_CHANGE_STATUS_COLORS,
SCHEDULE_CHANGE_STATUS_LABELS,
type ScheduleChangeListItem,
} from "../types"
interface ScheduleChangeListProps {
items: ScheduleChangeListItem[]
canApprove?: boolean
}
export function ScheduleChangeList({ items, canApprove = false }: ScheduleChangeListProps) {
const router = useRouter()
const [approveId, setApproveId] = useState<string | null>(null)
const [rejectId, setRejectId] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState("")
const [acting, setActing] = useState(false)
const handleApprove = async () => {
if (!approveId) return
setActing(true)
try {
const res = await approveScheduleChangeAction(approveId)
if (res.success) {
toast.success(res.message)
setApproveId(null)
router.refresh()
} else {
toast.error(res.message || "Failed to approve")
}
} catch {
toast.error("Failed to approve")
} finally {
setActing(false)
}
}
const handleReject = async () => {
if (!rejectId) return
setActing(true)
try {
const res = await rejectScheduleChangeAction(rejectId, rejectReason || undefined)
if (res.success) {
toast.success(res.message)
setRejectId(null)
setRejectReason("")
router.refresh()
} else {
toast.error(res.message || "Failed to reject")
}
} catch {
toast.error("Failed to reject")
} finally {
setActing(false)
}
}
if (items.length === 0) {
return (
<div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
No schedule change requests found.
</div>
)
}
return (
<>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Class</TableHead>
<TableHead>Original Teacher</TableHead>
<TableHead>Substitute</TableHead>
<TableHead>Original Date</TableHead>
<TableHead>New Date</TableHead>
<TableHead>New Time</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Status</TableHead>
<TableHead>Requested By</TableHead>
<TableHead>Created</TableHead>
{canApprove && <TableHead className="w-32">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.className}</TableCell>
<TableCell>{r.originalTeacherName ?? "-"}</TableCell>
<TableCell>{r.substituteTeacherName ?? "-"}</TableCell>
<TableCell>{r.originalDate ?? "-"}</TableCell>
<TableCell>{r.newDate ?? "-"}</TableCell>
<TableCell>
{r.newStartTime && r.newEndTime ? `${r.newStartTime}-${r.newEndTime}` : "-"}
</TableCell>
<TableCell className="max-w-[200px] truncate text-muted-foreground">
{r.reason ?? "-"}
</TableCell>
<TableCell>
<Badge variant={SCHEDULE_CHANGE_STATUS_COLORS[r.status]}>
{SCHEDULE_CHANGE_STATUS_LABELS[r.status]}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{r.requesterName}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
{canApprove && (
<TableCell>
{r.status === "pending" ? (
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-primary"
onClick={() => setApproveId(r.id)}
aria-label="Approve"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive"
onClick={() => setRejectId(r.id)}
aria-label="Reject"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={approveId !== null} onOpenChange={(open) => !open && setApproveId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Approve Schedule Change</DialogTitle>
<DialogDescription>
Are you sure you want to approve this schedule change request?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setApproveId(null)} disabled={acting}>
Cancel
</Button>
<Button onClick={handleApprove} disabled={acting}>
{acting ? "Approving..." : "Approve"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={rejectId !== null} onOpenChange={(open) => !open && setRejectId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Schedule Change</DialogTitle>
<DialogDescription>
Please provide a reason for rejecting this request (optional).
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
<Label htmlFor="rejectReason">Reason</Label>
<Textarea
id="rejectReason"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Reason for rejection..."
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectId(null)} disabled={acting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleReject} disabled={acting}>
{acting ? "Rejecting..." : "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,137 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { AlertTriangle, CheckCircle2, RefreshCw } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { getClassConflictsAction } from "../actions"
import type { ScheduleConflict } from "../types"
type ClassOption = { id: string; name: string; grade: string }
const CONFLICT_TYPE_LABELS: Record<ScheduleConflict["type"], string> = {
teacher_overlap: "Teacher Overlap",
classroom_overlap: "Classroom Overlap",
class_overlap: "Class Overlap",
rule_violation: "Rule Violation",
}
export function ScheduleConflictsView({ classes }: { classes: ClassOption[] }) {
const router = useRouter()
const [classId, setClassId] = useState(classes[0]?.id ?? "")
const [loading, setLoading] = useState(false)
const [conflicts, setConflicts] = useState<ScheduleConflict[] | null>(null)
const handleCheck = async () => {
if (!classId) {
toast.error("Please select a class")
return
}
setLoading(true)
try {
const res = await getClassConflictsAction(classId)
if (res.success && res.data) {
setConflicts(res.data)
if (res.data.length === 0) {
toast.success("No conflicts detected")
} else {
toast.warning(`Found ${res.data.length} conflict(s)`)
}
} else {
toast.error(res.message || "Failed to check conflicts")
}
} catch {
toast.error("Failed to check conflicts")
} finally {
setLoading(false)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Conflict Detection</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<label className="text-sm font-medium">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.grade} - {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleCheck} disabled={loading || !classId}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{loading ? "Checking..." : "Check Conflicts"}
</Button>
</CardContent>
</Card>
{conflicts && (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Results</span>
<Badge variant={conflicts.length > 0 ? "destructive" : "default"}>
{conflicts.length} conflict(s)
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{conflicts.length === 0 ? (
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-5 w-5 text-primary" />
<span>No conflicts detected for this class schedule.</span>
</div>
) : (
<ul className="space-y-3">
{conflicts.map((c, idx) => (
<li
key={idx}
className="flex flex-col gap-1 rounded-md border p-3 text-sm"
>
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-destructive" />
<Badge variant="outline">{CONFLICT_TYPE_LABELS[c.type]}</Badge>
</div>
<p className="text-muted-foreground">{c.description}</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)}
{conflicts && conflicts.length === 0 && (
<div className="flex justify-end">
<Button variant="outline" onClick={() => router.refresh()}>
Refresh Data
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,235 @@
"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 { saveSchedulingRulesAction } from "../actions"
import type { SchedulingRule } from "../types"
type ClassOption = { id: string; name: string; grade: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Rules"}
</Button>
)
}
export function SchedulingRulesForm({
classes,
existingRules,
}: {
classes: ClassOption[]
existingRules: SchedulingRule[]
}) {
const router = useRouter()
const [classId, setClassId] = useState(classes[0]?.id ?? "")
const [maxDailyHours, setMaxDailyHours] = useState("8")
const [maxContinuousHours, setMaxContinuousHours] = useState("2")
const [lunchBreakStart, setLunchBreakStart] = useState("12:00")
const [lunchBreakEnd, setLunchBreakEnd] = useState("13:00")
const [morningStart, setMorningStart] = useState("08:00")
const [afternoonEnd, setAfternoonEnd] = useState("17:00")
const [avoidBackToBack, setAvoidBackToBack] = useState(false)
const [balancedSubjects, setBalancedSubjects] = useState(true)
const handleClassChange = (id: string) => {
setClassId(id)
const rule = existingRules.find((r) => r.classId === id)
if (rule) {
setMaxDailyHours(String(rule.maxDailyHours ?? 8))
setMaxContinuousHours(String(rule.maxContinuousHours ?? 2))
setLunchBreakStart(rule.lunchBreakStart ?? "12:00")
setLunchBreakEnd(rule.lunchBreakEnd ?? "13:00")
setMorningStart(rule.morningStart ?? "08:00")
setAfternoonEnd(rule.afternoonEnd ?? "17:00")
setAvoidBackToBack(rule.avoidBackToBack ?? false)
setBalancedSubjects(rule.balancedSubjects ?? true)
} else {
const global = existingRules.find((r) => r.classId === null)
if (global) {
setMaxDailyHours(String(global.maxDailyHours ?? 8))
setMaxContinuousHours(String(global.maxContinuousHours ?? 2))
setLunchBreakStart(global.lunchBreakStart ?? "12:00")
setLunchBreakEnd(global.lunchBreakEnd ?? "13:00")
setMorningStart(global.morningStart ?? "08:00")
setAfternoonEnd(global.afternoonEnd ?? "17:00")
setAvoidBackToBack(global.avoidBackToBack ?? false)
setBalancedSubjects(global.balancedSubjects ?? true)
} else {
setMaxDailyHours("8")
setMaxContinuousHours("2")
setLunchBreakStart("12:00")
setLunchBreakEnd("13:00")
setMorningStart("08:00")
setAfternoonEnd("17:00")
setAvoidBackToBack(false)
setBalancedSubjects(true)
}
}
}
const handleSubmit = async (formData: FormData) => {
if (!classId) {
toast.error("Please select a class")
return
}
formData.set("classId", classId)
formData.set("maxDailyHours", maxDailyHours)
formData.set("maxContinuousHours", maxContinuousHours)
formData.set("lunchBreakStart", lunchBreakStart)
formData.set("lunchBreakEnd", lunchBreakEnd)
formData.set("morningStart", morningStart)
formData.set("afternoonEnd", afternoonEnd)
formData.set("avoidBackToBack", avoidBackToBack ? "true" : "false")
formData.set("balancedSubjects", balancedSubjects ? "true" : "false")
const result = await saveSchedulingRulesAction(null, formData)
if (result.success) {
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || "Failed to save rules")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Scheduling 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.grade} - {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="maxDailyHours">Max Daily Hours</Label>
<Input
id="maxDailyHours"
type="number"
min="1"
max="24"
value={maxDailyHours}
onChange={(e) => setMaxDailyHours(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="maxContinuousHours">Max Continuous Hours</Label>
<Input
id="maxContinuousHours"
type="number"
min="1"
max="12"
value={maxContinuousHours}
onChange={(e) => setMaxContinuousHours(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="morningStart">Morning Start</Label>
<Input
id="morningStart"
type="time"
value={morningStart}
onChange={(e) => setMorningStart(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="afternoonEnd">Afternoon End</Label>
<Input
id="afternoonEnd"
type="time"
value={afternoonEnd}
onChange={(e) => setAfternoonEnd(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="lunchBreakStart">Lunch Break Start</Label>
<Input
id="lunchBreakStart"
type="time"
value={lunchBreakStart}
onChange={(e) => setLunchBreakStart(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="lunchBreakEnd">Lunch Break End</Label>
<Input
id="lunchBreakEnd"
type="time"
value={lunchBreakEnd}
onChange={(e) => setLunchBreakEnd(e.target.value)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="avoidBackToBack"
checked={avoidBackToBack}
onCheckedChange={(v) => setAvoidBackToBack(v === true)}
/>
<Label htmlFor="avoidBackToBack" className="cursor-pointer">
Avoid back-to-back sessions
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="balancedSubjects"
checked={balancedSubjects}
onCheckedChange={(v) => setBalancedSubjects(v === true)}
/>
<Label htmlFor="balancedSubjects" className="cursor-pointer">
Balance subjects across the week
</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,272 @@
import "server-only"
import { and, asc, desc, eq, isNull, or, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import {
classes,
classSchedule,
classSubjectTeachers,
classrooms,
scheduleChanges,
schedulingRules,
subjects,
users,
} from "@/shared/db/schema"
import type {
ScheduleChangeListItem,
ScheduleChangeQueryParams,
ScheduleConflict,
SchedulingRule,
} from "./types"
import type { SchedulingRuleInput, ScheduleChangeInput } from "./schema"
const serializeDate = (d: Date | string | null): string | null =>
d ? new Date(d).toISOString().slice(0, 10) : null
const mapRule = (r: typeof schedulingRules.$inferSelect): SchedulingRule => ({
id: r.id,
classId: r.classId ?? null,
maxDailyHours: r.maxDailyHours ?? 8,
maxContinuousHours: r.maxContinuousHours ?? 2,
lunchBreakStart: r.lunchBreakStart ?? "12:00",
lunchBreakEnd: r.lunchBreakEnd ?? "13:00",
morningStart: r.morningStart ?? "08:00",
afternoonEnd: r.afternoonEnd ?? "17:00",
avoidBackToBack: r.avoidBackToBack ?? false,
balancedSubjects: r.balancedSubjects ?? true,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
export async function getSchedulingRules(classId?: string): Promise<SchedulingRule[]> {
const conditions: SQL[] = []
if (classId) {
const cond = or(eq(schedulingRules.classId, classId), isNull(schedulingRules.classId))
if (cond) conditions.push(cond)
}
const rows = await db
.select()
.from(schedulingRules)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(schedulingRules.classId), desc(schedulingRules.updatedAt))
return rows.map(mapRule)
}
export async function upsertSchedulingRules(data: SchedulingRuleInput): Promise<string> {
const [existing] = await db
.select()
.from(schedulingRules)
.where(eq(schedulingRules.classId, data.classId))
.limit(1)
if (existing) {
await db
.update(schedulingRules)
.set({
maxDailyHours: data.maxDailyHours ?? 8,
maxContinuousHours: data.maxContinuousHours ?? 2,
lunchBreakStart: data.lunchBreakStart ?? "12:00",
lunchBreakEnd: data.lunchBreakEnd ?? "13:00",
morningStart: data.morningStart ?? "08:00",
afternoonEnd: data.afternoonEnd ?? "17:00",
avoidBackToBack: data.avoidBackToBack ?? false,
balancedSubjects: data.balancedSubjects ?? true,
updatedAt: new Date(),
})
.where(eq(schedulingRules.id, existing.id))
return existing.id
}
const id = createId()
await db.insert(schedulingRules).values({
id,
classId: data.classId,
maxDailyHours: data.maxDailyHours ?? 8,
maxContinuousHours: data.maxContinuousHours ?? 2,
lunchBreakStart: data.lunchBreakStart ?? "12:00",
lunchBreakEnd: data.lunchBreakEnd ?? "13:00",
morningStart: data.morningStart ?? "08:00",
afternoonEnd: data.afternoonEnd ?? "17:00",
avoidBackToBack: data.avoidBackToBack ?? false,
balancedSubjects: data.balancedSubjects ?? true,
})
return id
}
export async function getScheduleChanges(
params: ScheduleChangeQueryParams
): Promise<ScheduleChangeListItem[]> {
const conditions: SQL[] = []
if (params.classId) conditions.push(eq(scheduleChanges.classId, params.classId))
if (params.status) conditions.push(eq(scheduleChanges.status, params.status))
if (params.requesterId) conditions.push(eq(scheduleChanges.requestedBy, params.requesterId))
const rows = await db
.select({
change: scheduleChanges,
className: classes.name,
originalTeacherName: users.name,
requesterName: users.name,
})
.from(scheduleChanges)
.innerJoin(classes, eq(classes.id, scheduleChanges.classId))
.leftJoin(users, eq(users.id, scheduleChanges.originalTeacherId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(scheduleChanges.createdAt))
// Resolve substitute teacher & approver names separately to avoid join ambiguity
const userIds = Array.from(
new Set(
rows.flatMap((r) => [
r.change.substituteTeacherId,
r.change.approvedBy,
r.change.requestedBy,
].filter((v): v is string => typeof v === "string" && v.length > 0))
)
)
const userMap = new Map<string, string>()
if (userIds.length > 0) {
const userRows = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(
userIds.length === 1
? eq(users.id, userIds[0]!)
: or(...userIds.map((id) => eq(users.id, id)))
)
for (const u of userRows) userMap.set(u.id, u.name ?? "Unknown")
}
return rows.map((r) => ({
id: r.change.id,
originalScheduleId: r.change.originalScheduleId ?? null,
classId: r.change.classId,
originalTeacherId: r.change.originalTeacherId ?? null,
substituteTeacherId: r.change.substituteTeacherId ?? null,
originalDate: serializeDate(r.change.originalDate),
newDate: serializeDate(r.change.newDate),
newStartTime: r.change.newStartTime ?? null,
newEndTime: r.change.newEndTime ?? null,
reason: r.change.reason ?? null,
status: r.change.status,
requestedBy: r.change.requestedBy,
approvedBy: r.change.approvedBy ?? null,
createdAt: r.change.createdAt.toISOString(),
updatedAt: r.change.updatedAt.toISOString(),
className: r.className,
originalTeacherName: r.originalTeacherName ?? null,
substituteTeacherName: r.change.substituteTeacherId
? userMap.get(r.change.substituteTeacherId) ?? null
: null,
requesterName: userMap.get(r.change.requestedBy) ?? "Unknown",
approverName: r.change.approvedBy ? userMap.get(r.change.approvedBy) ?? null : null,
}))
}
export async function createScheduleChange(
data: ScheduleChangeInput,
requestedBy: string
): Promise<string> {
const id = createId()
await db.insert(scheduleChanges).values({
id,
originalScheduleId: data.originalScheduleId ?? null,
classId: data.classId,
originalTeacherId: data.originalTeacherId ?? null,
substituteTeacherId: data.substituteTeacherId ?? null,
originalDate: data.originalDate ? new Date(data.originalDate) : null,
newDate: data.newDate ? new Date(data.newDate) : null,
newStartTime: data.newStartTime ?? null,
newEndTime: data.newEndTime ?? null,
reason: data.reason,
status: "pending",
requestedBy,
})
return id
}
export async function updateScheduleChangeStatus(
id: string,
status: "approved" | "rejected" | "completed",
approverId: string
): Promise<void> {
await db
.update(scheduleChanges)
.set({ status, approvedBy: approverId, updatedAt: new Date() })
.where(eq(scheduleChanges.id, id))
}
export async function getClassConflicts(classId: string): Promise<ScheduleConflict[]> {
const rows = await db
.select({
id: classSchedule.id,
weekday: classSchedule.weekday,
startTime: classSchedule.startTime,
endTime: classSchedule.endTime,
course: classSchedule.course,
})
.from(classSchedule)
.where(eq(classSchedule.classId, classId))
.orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))
const conflicts: ScheduleConflict[] = []
for (let i = 0; i < rows.length; i += 1) {
for (let j = i + 1; j < rows.length; j += 1) {
const a = rows[i]!
const b = rows[j]!
if (a.weekday !== b.weekday) continue
// Time overlap: a.start < b.end && b.start < a.end
if (a.startTime < b.endTime && b.startTime < a.endTime) {
conflicts.push({
type: "class_overlap",
description: `Time overlap on day ${a.weekday}: "${a.course}" (${a.startTime}-${a.endTime}) vs "${b.course}" (${b.startTime}-${b.endTime})`,
scheduleIds: [a.id, b.id],
})
}
}
}
return conflicts
}
// --- Helpers for scheduling pages ---
export async function getAdminClassesForScheduling() {
return await db
.select({ id: classes.id, name: classes.name, grade: classes.grade })
.from(classes)
.orderBy(classes.grade, classes.name)
}
export async function getTeachersForScheduling() {
return await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.innerJoin(classSubjectTeachers, eq(classSubjectTeachers.teacherId, users.id))
.groupBy(users.id, users.name, users.email)
.orderBy(users.name)
}
export async function getClassroomsForScheduling() {
return await db
.select({ id: classrooms.id, name: classrooms.name, building: classrooms.building })
.from(classrooms)
.orderBy(classrooms.name)
}
export async function getClassSubjectsForScheduling(classId: string) {
return await db
.select({
subjectId: subjects.id,
subjectName: subjects.name,
teacherId: classSubjectTeachers.teacherId,
})
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(eq(classSubjectTeachers.classId, classId))
}

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