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:
242
src/modules/announcements/actions.ts
Normal file
242
src/modules/announcements/actions.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
79
src/modules/announcements/components/announcement-card.tsx
Normal file
79
src/modules/announcements/components/announcement-card.tsx
Normal 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
|
||||
}
|
||||
206
src/modules/announcements/components/announcement-detail.tsx
Normal file
206
src/modules/announcements/components/announcement-detail.tsx
Normal 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 "{announcement.title}".
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
201
src/modules/announcements/components/announcement-form.tsx
Normal file
201
src/modules/announcements/components/announcement-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
src/modules/announcements/components/announcement-list.tsx
Normal file
108
src/modules/announcements/components/announcement-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
src/modules/announcements/data-access.ts
Normal file
120
src/modules/announcements/data-access.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
45
src/modules/announcements/schema.ts
Normal file
45
src/modules/announcements/schema.ts
Normal 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>
|
||||
27
src/modules/announcements/types.ts
Normal file
27
src/modules/announcements/types.ts
Normal 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
|
||||
}
|
||||
271
src/modules/attendance/actions.ts
Normal file
271
src/modules/attendance/actions.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
97
src/modules/attendance/components/attendance-filters.tsx
Normal file
97
src/modules/attendance/components/attendance-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
src/modules/attendance/components/attendance-record-list.tsx
Normal file
130
src/modules/attendance/components/attendance-record-list.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
148
src/modules/attendance/components/attendance-rules-form.tsx
Normal file
148
src/modules/attendance/components/attendance-rules-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
src/modules/attendance/components/attendance-sheet.tsx
Normal file
215
src/modules/attendance/components/attendance-sheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
src/modules/attendance/components/attendance-stats-card.tsx
Normal file
100
src/modules/attendance/components/attendance-stats-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
src/modules/attendance/components/student-attendance-view.tsx
Normal file
104
src/modules/attendance/components/student-attendance-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
src/modules/attendance/data-access-stats.ts
Normal file
145
src/modules/attendance/data-access-stats.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
271
src/modules/attendance/data-access.ts
Normal file
271
src/modules/attendance/data-access.ts
Normal 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
|
||||
}
|
||||
43
src/modules/attendance/schema.ts
Normal file
43
src/modules/attendance/schema.ts
Normal 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>
|
||||
103
src/modules/attendance/types.ts
Normal file
103
src/modules/attendance/types.ts
Normal 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",
|
||||
}
|
||||
212
src/modules/audit/actions.ts
Normal file
212
src/modules/audit/actions.ts
Normal 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}`
|
||||
}
|
||||
89
src/modules/audit/components/audit-log-export-button.tsx
Normal file
89
src/modules/audit/components/audit-log-export-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
src/modules/audit/components/audit-log-filters.tsx
Normal file
100
src/modules/audit/components/audit-log-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
src/modules/audit/components/audit-log-table.tsx
Normal file
156
src/modules/audit/components/audit-log-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
src/modules/audit/components/audit-log-view.tsx
Normal file
61
src/modules/audit/components/audit-log-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
325
src/modules/audit/components/data-change-log-table.tsx
Normal file
325
src/modules/audit/components/data-change-log-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
src/modules/audit/components/login-log-filters.tsx
Normal file
85
src/modules/audit/components/login-log-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
src/modules/audit/components/login-log-table.tsx
Normal file
150
src/modules/audit/components/login-log-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/modules/audit/components/login-log-view.tsx
Normal file
59
src/modules/audit/components/login-log-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
260
src/modules/audit/data-access.ts
Normal file
260
src/modules/audit/data-access.ts
Normal 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
|
||||
}
|
||||
91
src/modules/audit/types.ts
Normal file
91
src/modules/audit/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
265
src/modules/course-plans/actions.ts
Normal file
265
src/modules/course-plans/actions.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import {
|
||||
CreateCoursePlanSchema,
|
||||
UpdateCoursePlanSchema,
|
||||
CreateCoursePlanItemSchema,
|
||||
UpdateCoursePlanItemSchema,
|
||||
} from "./schema"
|
||||
import {
|
||||
getCoursePlans,
|
||||
getCoursePlanById,
|
||||
createCoursePlan,
|
||||
updateCoursePlan,
|
||||
deleteCoursePlan,
|
||||
createCoursePlanItem,
|
||||
updateCoursePlanItem,
|
||||
deleteCoursePlanItem,
|
||||
} from "./data-access"
|
||||
import type { CoursePlanWithItems, GetCoursePlansParams, CoursePlanListItem } from "./types"
|
||||
|
||||
const handleError = (e: unknown): ActionState<never> => {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
|
||||
const revalidatePlanPaths = (id?: string) => {
|
||||
revalidatePath("/admin/course-plans")
|
||||
revalidatePath("/teacher/course-plans")
|
||||
if (id) {
|
||||
revalidatePath(`/admin/course-plans/${id}`)
|
||||
revalidatePath(`/teacher/course-plans/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCoursePlanAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const parsed = CreateCoursePlanSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
subjectId: formData.get("subjectId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
academicYearId: formData.get("academicYearId") || undefined,
|
||||
semester: formData.get("semester") || undefined,
|
||||
totalHours: formData.get("totalHours") || undefined,
|
||||
weeklyHours: formData.get("weeklyHours") || undefined,
|
||||
startDate: formData.get("startDate") || undefined,
|
||||
endDate: formData.get("endDate") || undefined,
|
||||
syllabus: formData.get("syllabus") || undefined,
|
||||
objectives: formData.get("objectives") || undefined,
|
||||
status: formData.get("status") || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const id = await createCoursePlan(parsed.data, ctx.userId)
|
||||
revalidatePlanPaths(id)
|
||||
return { success: true, message: "Course plan created", data: id }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCoursePlanAction(
|
||||
id: string,
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const existing = await getCoursePlanById(id)
|
||||
if (!existing) return { success: false, message: "Course plan not found" }
|
||||
|
||||
const parsed = UpdateCoursePlanSchema.safeParse({
|
||||
classId: formData.get("classId") || undefined,
|
||||
subjectId: formData.get("subjectId") || undefined,
|
||||
teacherId: formData.get("teacherId") || undefined,
|
||||
academicYearId: formData.get("academicYearId") || undefined,
|
||||
semester: formData.get("semester") || undefined,
|
||||
totalHours: formData.get("totalHours") || undefined,
|
||||
completedHours: formData.get("completedHours") || undefined,
|
||||
weeklyHours: formData.get("weeklyHours") || undefined,
|
||||
startDate: formData.get("startDate") || undefined,
|
||||
endDate: formData.get("endDate") || undefined,
|
||||
syllabus: formData.get("syllabus") || undefined,
|
||||
objectives: formData.get("objectives") || undefined,
|
||||
status: formData.get("status") || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await updateCoursePlan(id, parsed.data)
|
||||
revalidatePlanPaths(id)
|
||||
return { success: true, message: "Course plan updated", data: id }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCoursePlanAction(
|
||||
id: string
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const existing = await getCoursePlanById(id)
|
||||
if (!existing) return { success: false, message: "Course plan not found" }
|
||||
|
||||
await deleteCoursePlan(id)
|
||||
revalidatePlanPaths()
|
||||
return { success: true, message: "Course plan deleted" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCoursePlansAction(
|
||||
params?: GetCoursePlansParams
|
||||
): Promise<ActionState<CoursePlanListItem[]>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const data = await getCoursePlans(params)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCoursePlanAction(
|
||||
id: string
|
||||
): Promise<ActionState<CoursePlanWithItems>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const data = await getCoursePlanById(id)
|
||||
if (!data) return { success: false, message: "Course plan not found" }
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCoursePlanItemAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const parsed = CreateCoursePlanItemSchema.safeParse({
|
||||
planId: formData.get("planId"),
|
||||
week: formData.get("week") || undefined,
|
||||
topic: formData.get("topic"),
|
||||
content: formData.get("content") || undefined,
|
||||
hours: formData.get("hours") || undefined,
|
||||
textbookChapter: formData.get("textbookChapter") || undefined,
|
||||
notes: formData.get("notes") || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const itemId = await createCoursePlanItem(parsed.data)
|
||||
revalidatePlanPaths(parsed.data.planId)
|
||||
return { success: true, message: "Week plan added", data: itemId }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCoursePlanItemAction(
|
||||
id: string,
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const parsed = UpdateCoursePlanItemSchema.safeParse({
|
||||
week: formData.get("week") || undefined,
|
||||
topic: formData.get("topic") || undefined,
|
||||
content: formData.get("content") || undefined,
|
||||
hours: formData.get("hours") || undefined,
|
||||
textbookChapter: formData.get("textbookChapter") || undefined,
|
||||
notes: formData.get("notes") || undefined,
|
||||
isCompleted:
|
||||
formData.get("isCompleted") === "true"
|
||||
? true
|
||||
: formData.get("isCompleted") === "false"
|
||||
? false
|
||||
: undefined,
|
||||
completedAt: formData.get("completedAt") || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await updateCoursePlanItem(id, parsed.data)
|
||||
return { success: true, message: "Week plan updated", data: id }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCoursePlanItemAction(
|
||||
id: string
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
await deleteCoursePlanItem(id)
|
||||
return { success: true, message: "Week plan deleted" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleCoursePlanItemCompletedAction(
|
||||
id: string,
|
||||
completed: boolean
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
await updateCoursePlanItem(id, {
|
||||
isCompleted: completed,
|
||||
completedAt: completed ? new Date().toISOString().slice(0, 10) : null,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
message: completed ? "Marked as completed" : "Marked as incomplete",
|
||||
}
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
258
src/modules/course-plans/components/course-plan-detail.tsx
Normal file
258
src/modules/course-plans/components/course-plan-detail.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { CoursePlanProgress } from "./course-plan-progress"
|
||||
import { CoursePlanItemEditor } from "./course-plan-item-editor"
|
||||
import { deleteCoursePlanAction } from "../actions"
|
||||
import type { CoursePlanWithItems, CoursePlanStatus } from "../types"
|
||||
|
||||
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
|
||||
planning: "Planning",
|
||||
active: "Active",
|
||||
completed: "Completed",
|
||||
paused: "Paused",
|
||||
}
|
||||
|
||||
export function CoursePlanDetail({
|
||||
plan,
|
||||
editHref,
|
||||
backHref,
|
||||
}: {
|
||||
plan: CoursePlanWithItems
|
||||
editHref?: string
|
||||
backHref?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const canManage = hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<CoursePlanWithItems["items"][number] | undefined>()
|
||||
|
||||
const completedItems = plan.items.filter((i) => i.isCompleted).length
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await deleteCoursePlanAction(plan.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
const base = backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"
|
||||
router.push(base)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
setDeleteOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateEditor = () => {
|
||||
setEditingItem(undefined)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEditEditor = (item: CoursePlanWithItems["items"][number]) => {
|
||||
setEditingItem(item)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{backHref ? (
|
||||
<Button asChild variant="ghost" size="icon">
|
||||
<a href={backHref}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<h2 className="text-2xl font-bold tracking-tight">Course Plan</h2>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{editHref ? (
|
||||
<Button asChild variant="outline">
|
||||
<a href={editHref}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={() => setDeleteOpen(true)} disabled={isWorking} variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{plan.className ?? "No class"}</Badge>
|
||||
<Badge variant="outline">{plan.subjectName ?? "Unknown subject"}</Badge>
|
||||
<Badge>{STATUS_LABEL[plan.status]}</Badge>
|
||||
<Badge variant="outline">Semester {plan.semester}</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
{plan.subjectName ?? "Course Plan"} — {plan.className ?? "No Class"}
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Teacher: {plan.teacherName ?? "Unassigned"}</span>
|
||||
<span>· Created {formatDate(plan.createdAt)}</span>
|
||||
{plan.startDate ? <span>· Start {formatDate(plan.startDate)}</span> : null}
|
||||
{plan.endDate ? <span>· End {formatDate(plan.endDate)}</span> : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CoursePlanProgress
|
||||
completedHours={plan.completedHours}
|
||||
totalHours={plan.totalHours}
|
||||
completedItems={completedItems}
|
||||
totalItems={plan.items.length}
|
||||
/>
|
||||
{plan.syllabus ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">Syllabus</h4>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.syllabus}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{plan.objectives ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">Objectives</h4>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.objectives}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Weekly Plan</CardTitle>
|
||||
{canManage ? (
|
||||
<Button onClick={openCreateEditor} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Week
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{plan.items.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No weekly plans yet. {canManage ? "Click \"Add Week\" to create one." : ""}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Week</TableHead>
|
||||
<TableHead>Topic</TableHead>
|
||||
<TableHead className="w-20">Hours</TableHead>
|
||||
<TableHead className="w-32">Chapter</TableHead>
|
||||
<TableHead className="w-28">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plan.items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={canManage ? "cursor-pointer" : ""}
|
||||
onClick={canManage ? () => openEditEditor(item) : undefined}
|
||||
>
|
||||
<TableCell className="font-medium">{item.week}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{item.topic}</p>
|
||||
{item.content ? (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{item.content}
|
||||
</p>
|
||||
) : null}
|
||||
{item.notes ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Note: {item.notes}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.hours}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.textbookChapter ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.isCompleted ? "default" : "secondary"}>
|
||||
{item.isCompleted ? "Done" : "Pending"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CoursePlanItemEditor
|
||||
planId={plan.id}
|
||||
item={editingItem}
|
||||
mode={editingItem ? "edit" : "create"}
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete course plan</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this course plan and all its weekly plans.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
src/modules/course-plans/components/course-plan-form.tsx
Normal file
284
src/modules/course-plans/components/course-plan-form.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
import { createCoursePlanAction, updateCoursePlanAction } from "../actions"
|
||||
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
||||
|
||||
type Mode = "create" | "edit"
|
||||
|
||||
interface Option {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function CoursePlanForm({
|
||||
mode,
|
||||
plan,
|
||||
classes = [],
|
||||
subjects = [],
|
||||
teachers = [],
|
||||
academicYears = [],
|
||||
backHref,
|
||||
}: {
|
||||
mode: Mode
|
||||
plan?: CoursePlanListItem
|
||||
classes?: Option[]
|
||||
subjects?: Option[]
|
||||
teachers?: Option[]
|
||||
academicYears?: Option[]
|
||||
backHref?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const [classId, setClassId] = useState(plan?.classId ?? "")
|
||||
const [subjectId, setSubjectId] = useState(plan?.subjectId ?? "")
|
||||
const [teacherId, setTeacherId] = useState(plan?.teacherId ?? "")
|
||||
const [semester, setSemester] = useState(plan?.semester ?? "1")
|
||||
const [status, setStatus] = useState(plan?.status ?? "planning")
|
||||
const [academicYearId, setAcademicYearId] = useState(plan?.academicYearId ?? "")
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("classId", classId)
|
||||
formData.set("subjectId", subjectId)
|
||||
formData.set("teacherId", teacherId)
|
||||
formData.set("semester", semester)
|
||||
formData.set("status", status)
|
||||
if (academicYearId) formData.set("academicYearId", academicYearId)
|
||||
|
||||
const res =
|
||||
mode === "create"
|
||||
? await createCoursePlanAction(null, formData)
|
||||
: plan
|
||||
? await updateCoursePlanAction(plan.id, null, formData)
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error("Invalid form state")
|
||||
return
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"
|
||||
router.push(redirectBase)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to save course plan")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save course plan")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{mode === "create" ? "New Course Plan" : "Edit Course Plan"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Class</Label>
|
||||
<Select value={classId} onValueChange={setClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="classId" value={classId} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Subject</Label>
|
||||
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a subject" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="subjectId" value={subjectId} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Teacher</Label>
|
||||
<Select value={teacherId} onValueChange={setTeacherId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a teacher" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="teacherId" value={teacherId} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Academic Year</Label>
|
||||
<Select value={academicYearId} onValueChange={setAcademicYearId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Optional" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{academicYears.map((y) => (
|
||||
<SelectItem key={y.id} value={y.id}>
|
||||
{y.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="academicYearId" value={academicYearId} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Semester</Label>
|
||||
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select semester" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Semester 1</SelectItem>
|
||||
<SelectItem value="2">Semester 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="semester" value={semester} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as CoursePlanStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="planning">Planning</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="status" value={status} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalHours">Total Hours</Label>
|
||||
<Input
|
||||
id="totalHours"
|
||||
name="totalHours"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={plan?.totalHours ?? 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="weeklyHours">Weekly Hours</Label>
|
||||
<Input
|
||||
id="weeklyHours"
|
||||
name="weeklyHours"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={plan?.weeklyHours ?? 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Start Date</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
type="date"
|
||||
defaultValue={plan?.startDate ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">End Date</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="date"
|
||||
defaultValue={plan?.endDate ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="syllabus">Syllabus</Label>
|
||||
<Textarea
|
||||
id="syllabus"
|
||||
name="syllabus"
|
||||
placeholder="Teaching syllabus..."
|
||||
className="min-h-[100px]"
|
||||
defaultValue={plan?.syllabus ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="objectives">Objectives</Label>
|
||||
<Textarea
|
||||
id="objectives"
|
||||
name="objectives"
|
||||
placeholder="Teaching objectives..."
|
||||
className="min-h-[100px]"
|
||||
defaultValue={plan?.objectives ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardFooter className="justify-end gap-2 px-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(backHref ?? "/admin/course-plans")}
|
||||
disabled={isWorking}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
248
src/modules/course-plans/components/course-plan-item-editor.tsx
Normal file
248
src/modules/course-plans/components/course-plan-item-editor.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Check, Trash2, X } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import {
|
||||
createCoursePlanItemAction,
|
||||
updateCoursePlanItemAction,
|
||||
deleteCoursePlanItemAction,
|
||||
toggleCoursePlanItemCompletedAction,
|
||||
} from "../actions"
|
||||
import type { CoursePlanItem } from "../types"
|
||||
|
||||
interface CoursePlanItemEditorProps {
|
||||
planId: string
|
||||
item?: CoursePlanItem
|
||||
mode: "create" | "edit"
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CoursePlanItemEditor({
|
||||
planId,
|
||||
item,
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CoursePlanItemEditorProps) {
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("planId", planId)
|
||||
|
||||
const res =
|
||||
mode === "create"
|
||||
? await createCoursePlanItemAction(null, formData)
|
||||
: item
|
||||
? await updateCoursePlanItemAction(item.id, null, formData)
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error("Invalid form state")
|
||||
return
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to save week plan")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save week plan")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!item) return
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await deleteCoursePlanItemAction(item.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleComplete = async () => {
|
||||
if (!item) return
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await toggleCoursePlanItemCompletedAction(item.id, !item.isCompleted)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create" ? "Add Week Plan" : "Edit Week Plan"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="week">Week</Label>
|
||||
<Input
|
||||
id="week"
|
||||
name="week"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={item?.week ?? 1}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="hours">Hours</Label>
|
||||
<Input
|
||||
id="hours"
|
||||
name="hours"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={item?.hours ?? 2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="topic">Topic</Label>
|
||||
<Input
|
||||
id="topic"
|
||||
name="topic"
|
||||
placeholder="Week topic"
|
||||
defaultValue={item?.topic ?? ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="content">Content</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
name="content"
|
||||
placeholder="Teaching content..."
|
||||
className="min-h-[100px]"
|
||||
defaultValue={item?.content ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="textbookChapter">Textbook Chapter</Label>
|
||||
<Input
|
||||
id="textbookChapter"
|
||||
name="textbookChapter"
|
||||
placeholder="e.g. Chapter 3"
|
||||
defaultValue={item?.textbookChapter ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="completedAt">Completed At</Label>
|
||||
<Input
|
||||
id="completedAt"
|
||||
name="completedAt"
|
||||
type="date"
|
||||
defaultValue={item?.completedAt ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
placeholder="Notes..."
|
||||
defaultValue={item?.notes ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{mode === "edit" && item ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleToggleComplete}
|
||||
disabled={isWorking}
|
||||
>
|
||||
{item.isCompleted ? (
|
||||
<>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Mark Incomplete
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Mark Complete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isWorking}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
160
src/modules/course-plans/components/course-plan-list.tsx
Normal file
160
src/modules/course-plans/components/course-plan-list.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus, CalendarRange } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { CoursePlanProgress } from "./course-plan-progress"
|
||||
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
||||
|
||||
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
|
||||
planning: "Planning",
|
||||
active: "Active",
|
||||
completed: "Completed",
|
||||
paused: "Paused",
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<CoursePlanStatus, "default" | "secondary" | "outline"> = {
|
||||
planning: "secondary",
|
||||
active: "default",
|
||||
completed: "outline",
|
||||
paused: "outline",
|
||||
}
|
||||
|
||||
type Filter = "all" | CoursePlanStatus
|
||||
|
||||
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "planning", label: "Planning" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
]
|
||||
|
||||
export function CoursePlanList({
|
||||
plans,
|
||||
canManage,
|
||||
createHref,
|
||||
detailHrefBuilder,
|
||||
initialStatus,
|
||||
}: {
|
||||
plans: CoursePlanListItem[]
|
||||
canManage?: boolean
|
||||
createHref?: string
|
||||
detailHrefBuilder?: (id: string) => string
|
||||
initialStatus?: Filter
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const canManageResolved = canManage ?? hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
const [filter, setFilter] = useState<Filter>(initialStatus ?? "all")
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === "all") return plans
|
||||
return plans.filter((p) => p.status === filter)
|
||||
}, [plans, filter])
|
||||
|
||||
const handleFilterChange = (value: string) => {
|
||||
setFilter(value as Filter)
|
||||
const params = new URLSearchParams()
|
||||
if (value !== "all") params.set("status", value)
|
||||
const qs = params.toString()
|
||||
router.replace(qs ? `?${qs}` : "?")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Select value={filter} onValueChange={handleFilterChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{canManageResolved && createHref ? (
|
||||
<Button asChild>
|
||||
<a href={createHref}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Course Plan
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No course plans"
|
||||
description={
|
||||
plans.length === 0
|
||||
? "There are no course plans yet."
|
||||
: "No course plans match the current filter."
|
||||
}
|
||||
icon={CalendarRange}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((plan) => {
|
||||
const href = detailHrefBuilder ? detailHrefBuilder(plan.id) : undefined
|
||||
const card = (
|
||||
<Card className="h-full transition-colors hover:bg-accent/50">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">
|
||||
{plan.subjectName ?? "Unknown Subject"}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[plan.status]} className="shrink-0">
|
||||
{STATUS_LABEL[plan.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{plan.className ?? "No class"}</Badge>
|
||||
<span>Semester {plan.semester}</span>
|
||||
{plan.teacherName ? <span>· {plan.teacherName}</span> : null}
|
||||
</div>
|
||||
<CoursePlanProgress
|
||||
completedHours={plan.completedHours}
|
||||
totalHours={plan.totalHours}
|
||||
showDetails={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created {formatDate(plan.createdAt)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return href ? (
|
||||
<a key={plan.id} href={href} className="block h-full">
|
||||
{card}
|
||||
</a>
|
||||
) : (
|
||||
<div key={plan.id}>{card}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/modules/course-plans/components/course-plan-progress.tsx
Normal file
38
src/modules/course-plans/components/course-plan-progress.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
|
||||
interface CoursePlanProgressProps {
|
||||
completedHours: number
|
||||
totalHours: number
|
||||
completedItems?: number
|
||||
totalItems?: number
|
||||
showDetails?: boolean
|
||||
}
|
||||
|
||||
export function CoursePlanProgress({
|
||||
completedHours,
|
||||
totalHours,
|
||||
completedItems,
|
||||
totalItems,
|
||||
showDetails = true,
|
||||
}: CoursePlanProgressProps) {
|
||||
const hoursPercent = totalHours > 0 ? Math.round((completedHours / totalHours) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">Progress</span>
|
||||
<span className="text-muted-foreground">
|
||||
{completedHours} / {totalHours} hours ({hoursPercent}%)
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={hoursPercent} className="h-2" />
|
||||
{showDetails && typeof completedItems === "number" && typeof totalItems === "number" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{completedItems} of {totalItems} week plans completed
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
320
src/modules/course-plans/data-access.ts
Normal file
320
src/modules/course-plans/data-access.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
coursePlanItems,
|
||||
coursePlans,
|
||||
subjects,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanItem,
|
||||
CoursePlanListItem,
|
||||
CoursePlanStatus,
|
||||
CoursePlanWithItems,
|
||||
GetCoursePlansParams,
|
||||
ReorderCoursePlanItemInput,
|
||||
} from "./types"
|
||||
import type {
|
||||
CreateCoursePlanInput,
|
||||
CreateCoursePlanItemInput,
|
||||
UpdateCoursePlanInput,
|
||||
UpdateCoursePlanItemInput,
|
||||
} from "./schema"
|
||||
|
||||
const toIso = (d: Date | null | undefined): string | null =>
|
||||
d ? d.toISOString() : null
|
||||
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapPlanRow = (
|
||||
row: {
|
||||
id: string
|
||||
classId: string
|
||||
subjectId: string
|
||||
teacherId: string
|
||||
academicYearId: string | null
|
||||
semester: "1" | "2"
|
||||
totalHours: number
|
||||
completedHours: number
|
||||
weeklyHours: number
|
||||
startDate: Date | null
|
||||
endDate: Date | null
|
||||
syllabus: string | null
|
||||
objectives: string | null
|
||||
status: CoursePlanStatus
|
||||
createdBy: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
className: string | null
|
||||
subjectName: string | null
|
||||
teacherName: string | null
|
||||
}
|
||||
): CoursePlanListItem => ({
|
||||
id: row.id,
|
||||
classId: row.classId,
|
||||
subjectId: row.subjectId,
|
||||
teacherId: row.teacherId,
|
||||
academicYearId: row.academicYearId,
|
||||
semester: row.semester,
|
||||
totalHours: Number(row.totalHours),
|
||||
completedHours: Number(row.completedHours),
|
||||
weeklyHours: Number(row.weeklyHours),
|
||||
startDate: toIso(row.startDate),
|
||||
endDate: toIso(row.endDate),
|
||||
syllabus: row.syllabus,
|
||||
objectives: row.objectives,
|
||||
status: row.status,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: toIsoRequired(row.createdAt),
|
||||
updatedAt: toIsoRequired(row.updatedAt),
|
||||
className: row.className,
|
||||
subjectName: row.subjectName,
|
||||
teacherName: row.teacherName,
|
||||
})
|
||||
|
||||
const mapItemRow = (
|
||||
row: {
|
||||
id: string
|
||||
planId: string
|
||||
week: number
|
||||
topic: string
|
||||
content: string | null
|
||||
hours: number
|
||||
textbookChapter: string | null
|
||||
notes: string | null
|
||||
isCompleted: boolean
|
||||
completedAt: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
): CoursePlanItem => ({
|
||||
id: row.id,
|
||||
planId: row.planId,
|
||||
week: Number(row.week),
|
||||
topic: row.topic,
|
||||
content: row.content,
|
||||
hours: Number(row.hours),
|
||||
textbookChapter: row.textbookChapter,
|
||||
notes: row.notes,
|
||||
isCompleted: Boolean(row.isCompleted),
|
||||
completedAt: toIso(row.completedAt),
|
||||
createdAt: toIsoRequired(row.createdAt),
|
||||
updatedAt: toIsoRequired(row.updatedAt),
|
||||
})
|
||||
|
||||
const buildPlanSelect = () =>
|
||||
db
|
||||
.select({
|
||||
id: coursePlans.id,
|
||||
classId: coursePlans.classId,
|
||||
subjectId: coursePlans.subjectId,
|
||||
teacherId: coursePlans.teacherId,
|
||||
academicYearId: coursePlans.academicYearId,
|
||||
semester: coursePlans.semester,
|
||||
totalHours: coursePlans.totalHours,
|
||||
completedHours: coursePlans.completedHours,
|
||||
weeklyHours: coursePlans.weeklyHours,
|
||||
startDate: coursePlans.startDate,
|
||||
endDate: coursePlans.endDate,
|
||||
syllabus: coursePlans.syllabus,
|
||||
objectives: coursePlans.objectives,
|
||||
status: coursePlans.status,
|
||||
createdBy: coursePlans.createdBy,
|
||||
createdAt: coursePlans.createdAt,
|
||||
updatedAt: coursePlans.updatedAt,
|
||||
className: classes.name,
|
||||
subjectName: subjects.name,
|
||||
teacherName: users.name,
|
||||
})
|
||||
.from(coursePlans)
|
||||
.leftJoin(classes, eq(classes.id, coursePlans.classId))
|
||||
.leftJoin(subjects, eq(subjects.id, coursePlans.subjectId))
|
||||
.leftJoin(users, eq(users.id, coursePlans.teacherId))
|
||||
|
||||
export const getCoursePlans = cache(
|
||||
async (params?: GetCoursePlansParams): Promise<CoursePlanListItem[]> => {
|
||||
try {
|
||||
const conditions: SQL[] = []
|
||||
if (params?.classId) conditions.push(eq(coursePlans.classId, params.classId))
|
||||
if (params?.teacherId) conditions.push(eq(coursePlans.teacherId, params.teacherId))
|
||||
if (params?.subjectId) conditions.push(eq(coursePlans.subjectId, params.subjectId))
|
||||
if (params?.status)
|
||||
conditions.push(eq(coursePlans.status, params.status as CoursePlanStatus))
|
||||
|
||||
const query = buildPlanSelect()
|
||||
const rows = await (conditions.length > 0
|
||||
? query.where(and(...conditions))
|
||||
: query
|
||||
).orderBy(desc(coursePlans.createdAt))
|
||||
|
||||
return rows.map(mapPlanRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const getCoursePlanById = cache(
|
||||
async (id: string): Promise<CoursePlanWithItems | null> => {
|
||||
try {
|
||||
const [planRow] = await buildPlanSelect()
|
||||
.where(eq(coursePlans.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!planRow) return null
|
||||
|
||||
const itemRows = await db
|
||||
.select()
|
||||
.from(coursePlanItems)
|
||||
.where(eq(coursePlanItems.planId, id))
|
||||
.orderBy(asc(coursePlanItems.week), asc(coursePlanItems.createdAt))
|
||||
|
||||
return {
|
||||
...mapPlanRow(planRow),
|
||||
items: itemRows.map(mapItemRow),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export async function createCoursePlan(
|
||||
data: CreateCoursePlanInput,
|
||||
createdBy: string
|
||||
): Promise<string> {
|
||||
const id = createId()
|
||||
await db.insert(coursePlans).values({
|
||||
id,
|
||||
classId: data.classId,
|
||||
subjectId: data.subjectId,
|
||||
teacherId: data.teacherId,
|
||||
academicYearId: data.academicYearId,
|
||||
semester: data.semester,
|
||||
totalHours: data.totalHours,
|
||||
completedHours: 0,
|
||||
weeklyHours: data.weeklyHours,
|
||||
startDate: data.startDate ? new Date(data.startDate) : null,
|
||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
||||
syllabus: data.syllabus,
|
||||
objectives: data.objectives,
|
||||
status: data.status,
|
||||
createdBy,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function updateCoursePlan(
|
||||
id: string,
|
||||
data: Partial<UpdateCoursePlanInput>
|
||||
): Promise<void> {
|
||||
const update: Partial<typeof coursePlans.$inferSelect> = {}
|
||||
if (data.classId !== undefined) update.classId = data.classId
|
||||
if (data.subjectId !== undefined) update.subjectId = data.subjectId
|
||||
if (data.teacherId !== undefined) update.teacherId = data.teacherId
|
||||
if (data.academicYearId !== undefined) update.academicYearId = data.academicYearId
|
||||
if (data.semester !== undefined) update.semester = data.semester
|
||||
if (data.totalHours !== undefined) update.totalHours = data.totalHours
|
||||
if (data.completedHours !== undefined) update.completedHours = data.completedHours
|
||||
if (data.weeklyHours !== undefined) update.weeklyHours = data.weeklyHours
|
||||
if (data.startDate !== undefined)
|
||||
update.startDate = data.startDate ? new Date(data.startDate) : null
|
||||
if (data.endDate !== undefined)
|
||||
update.endDate = data.endDate ? new Date(data.endDate) : null
|
||||
if (data.syllabus !== undefined) update.syllabus = data.syllabus
|
||||
if (data.objectives !== undefined) update.objectives = data.objectives
|
||||
if (data.status !== undefined) update.status = data.status
|
||||
|
||||
if (Object.keys(update).length === 0) return
|
||||
|
||||
await db.update(coursePlans).set(update).where(eq(coursePlans.id, id))
|
||||
}
|
||||
|
||||
export async function deleteCoursePlan(id: string): Promise<void> {
|
||||
await db.delete(coursePlans).where(eq(coursePlans.id, id))
|
||||
}
|
||||
|
||||
export async function createCoursePlanItem(
|
||||
data: CreateCoursePlanItemInput
|
||||
): Promise<string> {
|
||||
const id = createId()
|
||||
await db.insert(coursePlanItems).values({
|
||||
id,
|
||||
planId: data.planId,
|
||||
week: data.week,
|
||||
topic: data.topic,
|
||||
content: data.content,
|
||||
hours: data.hours,
|
||||
textbookChapter: data.textbookChapter,
|
||||
notes: data.notes,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function updateCoursePlanItem(
|
||||
id: string,
|
||||
data: Partial<UpdateCoursePlanItemInput>
|
||||
): Promise<void> {
|
||||
const update: Partial<typeof coursePlanItems.$inferSelect> = {}
|
||||
if (data.week !== undefined) update.week = data.week
|
||||
if (data.topic !== undefined) update.topic = data.topic
|
||||
if (data.content !== undefined) update.content = data.content
|
||||
if (data.hours !== undefined) update.hours = data.hours
|
||||
if (data.textbookChapter !== undefined) update.textbookChapter = data.textbookChapter
|
||||
if (data.notes !== undefined) update.notes = data.notes
|
||||
if (data.isCompleted !== undefined) update.isCompleted = data.isCompleted
|
||||
if (data.completedAt !== undefined)
|
||||
update.completedAt = data.completedAt ? new Date(data.completedAt) : null
|
||||
|
||||
if (Object.keys(update).length === 0) return
|
||||
|
||||
await db.update(coursePlanItems).set(update).where(eq(coursePlanItems.id, id))
|
||||
}
|
||||
|
||||
export async function deleteCoursePlanItem(id: string): Promise<void> {
|
||||
await db.delete(coursePlanItems).where(eq(coursePlanItems.id, id))
|
||||
}
|
||||
|
||||
export async function reorderCoursePlanItems(
|
||||
planId: string,
|
||||
items: ReorderCoursePlanItemInput[]
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return
|
||||
|
||||
const itemIds = items.map((i) => i.id)
|
||||
const [existing] = await db
|
||||
.select({ id: coursePlanItems.id })
|
||||
.from(coursePlanItems)
|
||||
.where(and(eq(coursePlanItems.planId, planId), inArray(coursePlanItems.id, itemIds)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) return
|
||||
|
||||
for (const item of items) {
|
||||
await db
|
||||
.update(coursePlanItems)
|
||||
.set({ week: item.week })
|
||||
.where(eq(coursePlanItems.id, item.id))
|
||||
}
|
||||
}
|
||||
|
||||
export type { CoursePlan, CoursePlanItem, CoursePlanWithItems }
|
||||
|
||||
export const getSubjectOptions = cache(async (): Promise<{ id: string; name: string }[]> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.orderBy(asc(subjects.order), asc(subjects.name))
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
149
src/modules/course-plans/schema.ts
Normal file
149
src/modules/course-plans/schema.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const CreateCoursePlanSchema = z
|
||||
.object({
|
||||
classId: z.string().trim().min(1),
|
||||
subjectId: z.string().trim().min(1),
|
||||
teacherId: z.string().trim().min(1),
|
||||
academicYearId: z.string().trim().optional().nullable(),
|
||||
semester: z.enum(["1", "2"]).optional(),
|
||||
totalHours: z.coerce.number().int().min(0).optional(),
|
||||
weeklyHours: z.coerce.number().int().min(0).optional(),
|
||||
startDate: z.string().trim().optional().nullable(),
|
||||
endDate: z.string().trim().optional().nullable(),
|
||||
syllabus: z.string().trim().optional().nullable(),
|
||||
objectives: z.string().trim().optional().nullable(),
|
||||
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
|
||||
})
|
||||
.transform((v) => ({
|
||||
classId: v.classId,
|
||||
subjectId: v.subjectId,
|
||||
teacherId: v.teacherId,
|
||||
academicYearId: v.academicYearId && v.academicYearId.length > 0 ? v.academicYearId : null,
|
||||
semester: v.semester ?? "1",
|
||||
totalHours: v.totalHours ?? 0,
|
||||
weeklyHours: v.weeklyHours ?? 0,
|
||||
startDate: v.startDate && v.startDate.length > 0 ? v.startDate : null,
|
||||
endDate: v.endDate && v.endDate.length > 0 ? v.endDate : null,
|
||||
syllabus: v.syllabus && v.syllabus.length > 0 ? v.syllabus : null,
|
||||
objectives: v.objectives && v.objectives.length > 0 ? v.objectives : null,
|
||||
status: v.status ?? "planning",
|
||||
}))
|
||||
|
||||
export type CreateCoursePlanInput = z.infer<typeof CreateCoursePlanSchema>
|
||||
|
||||
export const UpdateCoursePlanSchema = z
|
||||
.object({
|
||||
classId: z.string().trim().min(1).optional(),
|
||||
subjectId: z.string().trim().min(1).optional(),
|
||||
teacherId: z.string().trim().min(1).optional(),
|
||||
academicYearId: z.string().trim().optional().nullable(),
|
||||
semester: z.enum(["1", "2"]).optional(),
|
||||
totalHours: z.coerce.number().int().min(0).optional(),
|
||||
completedHours: z.coerce.number().int().min(0).optional(),
|
||||
weeklyHours: z.coerce.number().int().min(0).optional(),
|
||||
startDate: z.string().trim().optional().nullable(),
|
||||
endDate: z.string().trim().optional().nullable(),
|
||||
syllabus: z.string().trim().optional().nullable(),
|
||||
objectives: z.string().trim().optional().nullable(),
|
||||
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
|
||||
})
|
||||
.transform((v) => ({
|
||||
...v,
|
||||
academicYearId:
|
||||
v.academicYearId !== undefined
|
||||
? v.academicYearId && v.academicYearId.length > 0
|
||||
? v.academicYearId
|
||||
: null
|
||||
: undefined,
|
||||
startDate:
|
||||
v.startDate !== undefined
|
||||
? v.startDate && v.startDate.length > 0
|
||||
? v.startDate
|
||||
: null
|
||||
: undefined,
|
||||
endDate:
|
||||
v.endDate !== undefined
|
||||
? v.endDate && v.endDate.length > 0
|
||||
? v.endDate
|
||||
: null
|
||||
: undefined,
|
||||
syllabus:
|
||||
v.syllabus !== undefined
|
||||
? v.syllabus && v.syllabus.length > 0
|
||||
? v.syllabus
|
||||
: null
|
||||
: undefined,
|
||||
objectives:
|
||||
v.objectives !== undefined
|
||||
? v.objectives && v.objectives.length > 0
|
||||
? v.objectives
|
||||
: null
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
export type UpdateCoursePlanInput = z.infer<typeof UpdateCoursePlanSchema>
|
||||
|
||||
export const CreateCoursePlanItemSchema = z
|
||||
.object({
|
||||
planId: z.string().trim().min(1),
|
||||
week: z.coerce.number().int().min(1),
|
||||
topic: z.string().trim().min(1).max(255),
|
||||
content: z.string().trim().optional().nullable(),
|
||||
hours: z.coerce.number().int().min(1).optional(),
|
||||
textbookChapter: z.string().trim().optional().nullable(),
|
||||
notes: z.string().trim().optional().nullable(),
|
||||
})
|
||||
.transform((v) => ({
|
||||
planId: v.planId,
|
||||
week: v.week,
|
||||
topic: v.topic,
|
||||
content: v.content && v.content.length > 0 ? v.content : null,
|
||||
hours: v.hours ?? 2,
|
||||
textbookChapter:
|
||||
v.textbookChapter && v.textbookChapter.length > 0 ? v.textbookChapter : null,
|
||||
notes: v.notes && v.notes.length > 0 ? v.notes : null,
|
||||
}))
|
||||
|
||||
export type CreateCoursePlanItemInput = z.infer<typeof CreateCoursePlanItemSchema>
|
||||
|
||||
export const UpdateCoursePlanItemSchema = z
|
||||
.object({
|
||||
week: z.coerce.number().int().min(1).optional(),
|
||||
topic: z.string().trim().min(1).max(255).optional(),
|
||||
content: z.string().trim().optional().nullable(),
|
||||
hours: z.coerce.number().int().min(1).optional(),
|
||||
textbookChapter: z.string().trim().optional().nullable(),
|
||||
notes: z.string().trim().optional().nullable(),
|
||||
isCompleted: z.boolean().optional(),
|
||||
completedAt: z.string().trim().optional().nullable(),
|
||||
})
|
||||
.transform((v) => ({
|
||||
...v,
|
||||
content:
|
||||
v.content !== undefined
|
||||
? v.content && v.content.length > 0
|
||||
? v.content
|
||||
: null
|
||||
: undefined,
|
||||
textbookChapter:
|
||||
v.textbookChapter !== undefined
|
||||
? v.textbookChapter && v.textbookChapter.length > 0
|
||||
? v.textbookChapter
|
||||
: null
|
||||
: undefined,
|
||||
notes:
|
||||
v.notes !== undefined
|
||||
? v.notes && v.notes.length > 0
|
||||
? v.notes
|
||||
: null
|
||||
: undefined,
|
||||
completedAt:
|
||||
v.completedAt !== undefined
|
||||
? v.completedAt && v.completedAt.length > 0
|
||||
? v.completedAt
|
||||
: null
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
export type UpdateCoursePlanItemInput = z.infer<typeof UpdateCoursePlanItemSchema>
|
||||
60
src/modules/course-plans/types.ts
Normal file
60
src/modules/course-plans/types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type CoursePlanStatus = "planning" | "active" | "completed" | "paused"
|
||||
|
||||
export type CoursePlanSemester = "1" | "2"
|
||||
|
||||
export interface CoursePlan {
|
||||
id: string
|
||||
classId: string
|
||||
subjectId: string
|
||||
teacherId: string
|
||||
academicYearId: string | null
|
||||
semester: CoursePlanSemester
|
||||
totalHours: number
|
||||
completedHours: number
|
||||
weeklyHours: number
|
||||
startDate: string | null
|
||||
endDate: string | null
|
||||
syllabus: string | null
|
||||
objectives: string | null
|
||||
status: CoursePlanStatus
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CoursePlanItem {
|
||||
id: string
|
||||
planId: string
|
||||
week: number
|
||||
topic: string
|
||||
content: string | null
|
||||
hours: number
|
||||
textbookChapter: string | null
|
||||
notes: string | null
|
||||
isCompleted: boolean
|
||||
completedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CoursePlanListItem extends CoursePlan {
|
||||
className: string | null
|
||||
subjectName: string | null
|
||||
teacherName: string | null
|
||||
}
|
||||
|
||||
export interface CoursePlanWithItems extends CoursePlanListItem {
|
||||
items: CoursePlanItem[]
|
||||
}
|
||||
|
||||
export interface GetCoursePlansParams {
|
||||
classId?: string
|
||||
teacherId?: string
|
||||
subjectId?: string
|
||||
status?: CoursePlanStatus
|
||||
}
|
||||
|
||||
export interface ReorderCoursePlanItemInput {
|
||||
id: string
|
||||
week: number
|
||||
}
|
||||
289
src/modules/files/components/admin-files-view.tsx
Normal file
289
src/modules/files/components/admin-files-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
src/modules/files/components/file-icon.tsx
Normal file
86
src/modules/files/components/file-icon.tsx
Normal 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" />
|
||||
)
|
||||
}
|
||||
126
src/modules/files/components/file-list.tsx
Normal file
126
src/modules/files/components/file-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/modules/files/components/file-preview-dialog.tsx
Normal file
56
src/modules/files/components/file-preview-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
234
src/modules/files/components/file-preview.tsx
Normal file
234
src/modules/files/components/file-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
249
src/modules/files/components/file-upload.tsx
Normal file
249
src/modules/files/components/file-upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
src/modules/files/data-access.ts
Normal file
267
src/modules/files/data-access.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
63
src/modules/files/types.ts
Normal file
63
src/modules/files/types.ts
Normal 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[]
|
||||
}
|
||||
133
src/modules/grades/actions-analytics.ts
Normal file
133
src/modules/grades/actions-analytics.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
312
src/modules/grades/actions.ts
Normal file
312
src/modules/grades/actions.ts
Normal 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: "导出失败" }
|
||||
}
|
||||
}
|
||||
219
src/modules/grades/components/batch-grade-entry.tsx
Normal file
219
src/modules/grades/components/batch-grade-entry.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
src/modules/grades/components/class-comparison-chart.tsx
Normal file
132
src/modules/grades/components/class-comparison-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/modules/grades/components/class-grade-report.tsx
Normal file
92
src/modules/grades/components/class-grade-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
src/modules/grades/components/export-button.tsx
Normal file
101
src/modules/grades/components/export-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
src/modules/grades/components/grade-distribution-chart.tsx
Normal file
138
src/modules/grades/components/grade-distribution-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
src/modules/grades/components/grade-query-filters.tsx
Normal file
104
src/modules/grades/components/grade-query-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
src/modules/grades/components/grade-record-form.tsx
Normal file
184
src/modules/grades/components/grade-record-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
src/modules/grades/components/grade-record-list.tsx
Normal file
136
src/modules/grades/components/grade-record-list.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
93
src/modules/grades/components/grade-stats-card.tsx
Normal file
93
src/modules/grades/components/grade-stats-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
src/modules/grades/components/grade-trend-chart.tsx
Normal file
137
src/modules/grades/components/grade-trend-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
src/modules/grades/components/student-grade-summary.tsx
Normal file
117
src/modules/grades/components/student-grade-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/modules/grades/components/subject-comparison-chart.tsx
Normal file
116
src/modules/grades/components/subject-comparison-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
src/modules/grades/data-access-analytics.ts
Normal file
293
src/modules/grades/data-access-analytics.ts
Normal 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 }
|
||||
}
|
||||
121
src/modules/grades/data-access-ranking.ts
Normal file
121
src/modules/grades/data-access-ranking.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
419
src/modules/grades/data-access.ts
Normal file
419
src/modules/grades/data-access.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
214
src/modules/grades/export.ts
Normal file
214
src/modules/grades/export.ts
Normal 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 }
|
||||
52
src/modules/grades/schema.ts
Normal file
52
src/modules/grades/schema.ts
Normal 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
176
src/modules/grades/types.ts
Normal 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[]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
245
src/modules/messaging/actions.ts
Normal file
245
src/modules/messaging/actions.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
146
src/modules/messaging/components/message-compose.tsx
Normal file
146
src/modules/messaging/components/message-compose.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
src/modules/messaging/components/message-detail.tsx
Normal file
153
src/modules/messaging/components/message-detail.tsx
Normal 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 "{message.subject ?? "(no subject)"}".
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
src/modules/messaging/components/message-list.tsx
Normal file
117
src/modules/messaging/components/message-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
src/modules/messaging/components/notification-dropdown.tsx
Normal file
159
src/modules/messaging/components/notification-dropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
src/modules/messaging/components/notification-list.tsx
Normal file
141
src/modules/messaging/components/notification-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
252
src/modules/messaging/data-access.ts
Normal file
252
src/modules/messaging/data-access.ts
Normal 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 []
|
||||
}
|
||||
)
|
||||
166
src/modules/messaging/notification-preferences.ts
Normal file
166
src/modules/messaging/notification-preferences.ts
Normal 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
|
||||
}
|
||||
}
|
||||
18
src/modules/messaging/schema.ts
Normal file
18
src/modules/messaging/schema.ts
Normal 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>
|
||||
108
src/modules/messaging/types.ts
Normal file
108
src/modules/messaging/types.ts
Normal 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
|
||||
}
|
||||
89
src/modules/parent/components/child-card.tsx
Normal file
89
src/modules/parent/components/child-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/modules/parent/components/child-detail-header.tsx
Normal file
49
src/modules/parent/components/child-detail-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/modules/parent/components/child-detail-panel.tsx
Normal file
27
src/modules/parent/components/child-detail-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
163
src/modules/parent/components/child-grade-summary.tsx
Normal file
163
src/modules/parent/components/child-grade-summary.tsx
Normal 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}'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>
|
||||
)
|
||||
}
|
||||
131
src/modules/parent/components/child-homework-summary.tsx
Normal file
131
src/modules/parent/components/child-homework-summary.tsx
Normal 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}'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>
|
||||
)
|
||||
}
|
||||
67
src/modules/parent/components/child-schedule-card.tsx
Normal file
67
src/modules/parent/components/child-schedule-card.tsx
Normal 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}'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>
|
||||
)
|
||||
}
|
||||
68
src/modules/parent/components/parent-dashboard.tsx
Normal file
68
src/modules/parent/components/parent-dashboard.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
234
src/modules/parent/data-access.ts
Normal file
234
src/modules/parent/data-access.ts
Normal 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),
|
||||
}
|
||||
},
|
||||
)
|
||||
57
src/modules/parent/types.ts
Normal file
57
src/modules/parent/types.ts
Normal 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[]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
302
src/modules/scheduling/actions.ts
Normal file
302
src/modules/scheduling/actions.ts
Normal 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,
|
||||
}
|
||||
310
src/modules/scheduling/auto-scheduler.ts
Normal file
310
src/modules/scheduling/auto-scheduler.ts
Normal 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
|
||||
}
|
||||
120
src/modules/scheduling/components/auto-schedule-panel.tsx
Normal file
120
src/modules/scheduling/components/auto-schedule-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
src/modules/scheduling/components/auto-schedule-result.tsx
Normal file
94
src/modules/scheduling/components/auto-schedule-result.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
src/modules/scheduling/components/schedule-change-form.tsx
Normal file
208
src/modules/scheduling/components/schedule-change-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
219
src/modules/scheduling/components/schedule-change-list.tsx
Normal file
219
src/modules/scheduling/components/schedule-change-list.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
137
src/modules/scheduling/components/schedule-conflicts-view.tsx
Normal file
137
src/modules/scheduling/components/schedule-conflicts-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
235
src/modules/scheduling/components/scheduling-rules-form.tsx
Normal file
235
src/modules/scheduling/components/scheduling-rules-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
272
src/modules/scheduling/data-access.ts
Normal file
272
src/modules/scheduling/data-access.ts
Normal 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
Reference in New Issue
Block a user