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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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