refactor(attendance,elective): 审计第二轮 — 全量完成 P0/P1 改进项
P0 修复: - 页面层 i18n 全量补齐(admin/teacher/parent/student × attendance/elective) - types.ts 状态标签常量迁移至 constants.ts(i18n key + Badge variant) - 修复 getTranslations 导入路径(next-intl → next-intl/server) P1 改进: - 解耦 parent 模块对 attendance 类型的直接依赖(本地 view-model 类型) - 导出纯函数(computeStats/buildWarnings/buildLotteryRankCase 等) - 统一空状态为 EmptyState 组件 - 清理死代码读 Action(attendance 5 个 + elective 3 个) - 预留监控埋点接口(trackEvent 13 个新事件名) - 补齐骨架屏 loading.tsx(8 个页面) - AlertDialog 替换 window.confirm(student-selection-view) - a11y 改进(aria-label/role/键盘导航) 修复: - AttendanceStatus 从 constants.ts 重导出,消除 types/constants 双源混乱 - buildWarnings 的 Translator 类型改用 ReturnType<typeof useTranslations>
This commit is contained in:
@@ -4,6 +4,7 @@ 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 { trackEvent } from "@/shared/lib/track-event"
|
||||
import { verifyTeacherOwnsClass } from "@/modules/classes/data-access"
|
||||
|
||||
import {
|
||||
@@ -18,16 +19,8 @@ import {
|
||||
updateAttendanceRecord,
|
||||
deleteAttendanceRecord,
|
||||
getAttendanceRecordClassId,
|
||||
getAttendanceRecords,
|
||||
getClassAttendanceForDate,
|
||||
getAttendanceRules,
|
||||
upsertAttendanceRules,
|
||||
} from "./data-access"
|
||||
import {
|
||||
getStudentAttendanceSummary,
|
||||
getClassAttendanceStats,
|
||||
} from "./data-access-stats"
|
||||
import type { AttendanceQueryParams, AttendanceListItem } from "./types"
|
||||
|
||||
/**
|
||||
* 校验当前用户对考勤记录的归属权限。
|
||||
@@ -50,6 +43,25 @@ async function assertRecordOwnership(
|
||||
return { ok: false, message: "Insufficient permissions" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前用户对班级的归属权限。
|
||||
* - admin(scope=all):直接放行
|
||||
* - teacher(scope=class_taught):必须为该班级的任课教师
|
||||
* - 其他 scope:拒绝
|
||||
*/
|
||||
async function assertClassOwnership(
|
||||
classId: string,
|
||||
ctx: Awaited<ReturnType<typeof requirePermission>>
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
if (ctx.dataScope.type === "all") return { ok: true }
|
||||
if (ctx.dataScope.type === "class_taught") {
|
||||
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||
if (!owns) return { ok: false, message: "You do not own this class" }
|
||||
return { ok: true }
|
||||
}
|
||||
return { ok: false, message: "Insufficient permissions" }
|
||||
}
|
||||
|
||||
export async function recordAttendanceAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -76,6 +88,13 @@ export async function recordAttendanceAction(
|
||||
|
||||
const id = await createAttendanceRecord(parsed.data, ctx.userId)
|
||||
revalidatePath("/teacher/attendance")
|
||||
await trackEvent({
|
||||
event: "attendance.recorded",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "attendance_record",
|
||||
properties: { studentId: parsed.data.studentId, classId: parsed.data.classId, status: parsed.data.status },
|
||||
})
|
||||
return { success: true, message: "Attendance recorded", data: id }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
@@ -110,6 +129,12 @@ export async function batchRecordAttendanceAction(
|
||||
|
||||
const count = await batchCreateAttendanceRecords(parsed.data, ctx.userId)
|
||||
revalidatePath("/teacher/attendance")
|
||||
await trackEvent({
|
||||
event: "attendance.batch_recorded",
|
||||
userId: ctx.userId,
|
||||
targetType: "attendance_record",
|
||||
properties: { count, classId: parsed.data.records[0]?.classId },
|
||||
})
|
||||
return { success: true, message: `Recorded attendance for ${count} students`, data: count }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
@@ -147,6 +172,13 @@ export async function updateAttendanceAction(
|
||||
|
||||
await updateAttendanceRecord(id, parsed.data)
|
||||
revalidatePath("/teacher/attendance")
|
||||
await trackEvent({
|
||||
event: "attendance.updated",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "attendance_record",
|
||||
properties: { status: parsed.data.status },
|
||||
})
|
||||
return { success: true, message: "Attendance updated" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
@@ -168,89 +200,13 @@ export async function deleteAttendanceAction(
|
||||
|
||||
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,
|
||||
await trackEvent({
|
||||
event: "attendance.deleted",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "attendance_record",
|
||||
})
|
||||
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 }
|
||||
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 }
|
||||
@@ -263,7 +219,7 @@ export async function saveAttendanceRulesAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
|
||||
const parsed = AttendanceRuleSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
@@ -280,8 +236,20 @@ export async function saveAttendanceRulesAction(
|
||||
}
|
||||
}
|
||||
|
||||
const ownership = await assertClassOwnership(parsed.data.classId, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
const id = await upsertAttendanceRules(parsed.data)
|
||||
revalidatePath("/teacher/attendance")
|
||||
await trackEvent({
|
||||
event: "attendance.rules_saved",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "attendance_rule",
|
||||
properties: { classId: parsed.data.classId },
|
||||
})
|
||||
return { success: true, message: "Attendance rules saved", data: id }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
@@ -289,17 +257,3 @@ export async function saveAttendanceRulesAction(
|
||||
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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
@@ -12,23 +13,18 @@ import {
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
|
||||
import { ATTENDANCE_STATUS_OPTIONS, ATTENDANCE_STATUS_LABEL_KEYS } from "../constants"
|
||||
|
||||
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 t = useTranslations("attendance")
|
||||
|
||||
const updateParam = useCallback(
|
||||
(key: string, value: string) => {
|
||||
@@ -50,13 +46,13 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
||||
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>
|
||||
<Label className="text-xs">{t("filters.class")}</Label>
|
||||
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All classes" />
|
||||
<SelectTrigger className="h-9" aria-label={t("filters.class")}>
|
||||
<SelectValue placeholder={t("filters.allClasses")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All classes</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allClasses")}</SelectItem>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -67,16 +63,16 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs">Status</Label>
|
||||
<Label className="text-xs">{t("filters.status")}</Label>
|
||||
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
<SelectTrigger className="h-9" aria-label={t("filters.status")}>
|
||||
<SelectValue placeholder={t("filters.allStatuses")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
|
||||
{ATTENDANCE_STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[s])}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -84,12 +80,13 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs">Date</Label>
|
||||
<Label className="text-xs">{t("filters.date")}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => updateParam("date", e.target.value)}
|
||||
className="h-9"
|
||||
aria-label={t("filters.date")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Trash2, Inbox } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -23,17 +24,19 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { deleteAttendanceAction } from "../actions"
|
||||
import {
|
||||
ATTENDANCE_STATUS_COLORS,
|
||||
ATTENDANCE_STATUS_LABELS,
|
||||
type AttendanceListItem,
|
||||
} from "../types"
|
||||
ATTENDANCE_STATUS_BADGE_VARIANTS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
} from "../constants"
|
||||
import type { AttendanceListItem } from "../types"
|
||||
|
||||
export function AttendanceRecordList({ records }: { records: AttendanceListItem[] }) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("attendance")
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
@@ -43,19 +46,22 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
const result = await deleteAttendanceAction(deleteId)
|
||||
setIsDeleting(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
toast.success(result.message || t("sheet.deleted"))
|
||||
setDeleteId(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to delete")
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title={t("list.empty")}
|
||||
description={t("list.emptyDescription")}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,13 +71,13 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
<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>{t("list.columns.student")}</TableHead>
|
||||
<TableHead>{t("list.columns.class")}</TableHead>
|
||||
<TableHead>{t("list.columns.date")}</TableHead>
|
||||
<TableHead>{t("list.columns.status")}</TableHead>
|
||||
<TableHead>{t("list.columns.remark")}</TableHead>
|
||||
<TableHead>{t("list.columns.recorder")}</TableHead>
|
||||
<TableHead>{t("list.columns.createdAt")}</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -82,8 +88,8 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
<TableCell>{r.className}</TableCell>
|
||||
<TableCell>{r.date}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ATTENDANCE_STATUS_COLORS[r.status]} className="capitalize">
|
||||
{ATTENDANCE_STATUS_LABELS[r.status]}
|
||||
<Badge variant={ATTENDANCE_STATUS_BADGE_VARIANTS[r.status]} className="capitalize">
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-muted-foreground">
|
||||
@@ -97,6 +103,7 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
aria-label={t("actions.delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -110,17 +117,17 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Attendance Record</DialogTitle>
|
||||
<DialogTitle>{t("sheet.confirmDelete")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this attendance record? This action cannot be undone.
|
||||
{t("errors.unexpected")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
{isDeleting ? t("actions.delete") + "..." : t("actions.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -25,9 +26,10 @@ type Option = { id: string; name: string }
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Saving..." : "Save Rules"}
|
||||
{pending ? t("actions.save") + "..." : t("rules.saved")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +42,7 @@ export function AttendanceRulesForm({
|
||||
existingRules: AttendanceRule[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("attendance")
|
||||
const [classId, setClassId] = useState(classes[0]?.id ?? "")
|
||||
const [lateThreshold, setLateThreshold] = useState("15")
|
||||
const [earlyLeaveThreshold, setEarlyLeaveThreshold] = useState("15")
|
||||
@@ -61,7 +64,7 @@ export function AttendanceRulesForm({
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!classId) {
|
||||
toast.error("Please select a class")
|
||||
toast.error(t("sheet.selectClass"))
|
||||
return
|
||||
}
|
||||
formData.set("classId", classId)
|
||||
@@ -71,25 +74,25 @@ export function AttendanceRulesForm({
|
||||
|
||||
const result = await saveAttendanceRulesAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
toast.success(result.message || t("rules.saved"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to save rules")
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Attendance Rules</CardTitle>
|
||||
<CardTitle>{t("title.rules")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label>Class</Label>
|
||||
<Label htmlFor="rules-class-select">{t("filters.class")}</Label>
|
||||
<Select value={classId} onValueChange={handleClassChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
<SelectTrigger id="rules-class-select">
|
||||
<SelectValue placeholder={t("sheet.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
@@ -103,7 +106,7 @@ export function AttendanceRulesForm({
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="lateThresholdMinutes">Late Threshold (minutes)</Label>
|
||||
<Label htmlFor="lateThresholdMinutes">{t("rules.lateThreshold")}</Label>
|
||||
<Input
|
||||
id="lateThresholdMinutes"
|
||||
type="number"
|
||||
@@ -113,7 +116,7 @@ export function AttendanceRulesForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="earlyLeaveThresholdMinutes">Early Leave Threshold (minutes)</Label>
|
||||
<Label htmlFor="earlyLeaveThresholdMinutes">{t("rules.earlyLeaveThreshold")}</Label>
|
||||
<Input
|
||||
id="earlyLeaveThresholdMinutes"
|
||||
type="number"
|
||||
@@ -131,13 +134,13 @@ export function AttendanceRulesForm({
|
||||
onCheckedChange={(v) => setEnableAutoMark(v === true)}
|
||||
/>
|
||||
<Label htmlFor="enableAutoMark" className="cursor-pointer">
|
||||
Enable auto-marking (mark present automatically when student checks in on time)
|
||||
{t("rules.enableAutoMark")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<CardFooter className="justify-end gap-2 px-0">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<SubmitButton />
|
||||
</CardFooter>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { CalendarDays } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -25,12 +26,23 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import { batchRecordAttendanceAction } from "../actions"
|
||||
import {
|
||||
ATTENDANCE_STATUS_LABELS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
type AttendanceStatus,
|
||||
} from "../types"
|
||||
} from "../constants"
|
||||
|
||||
type Option = { id: string; name: string }
|
||||
type Student = { id: string; name: string; email: string }
|
||||
@@ -43,14 +55,39 @@ const STATUS_OPTIONS: AttendanceStatus[] = [
|
||||
"excused",
|
||||
]
|
||||
|
||||
const isAttendanceStatus = (v: string): v is AttendanceStatus =>
|
||||
v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused"
|
||||
const STATUS_SHORTCUTS: Record<string, AttendanceStatus> = {
|
||||
p: "present",
|
||||
a: "absent",
|
||||
l: "late",
|
||||
e: "early_leave",
|
||||
x: "excused",
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<AttendanceStatus, { active: string; icon: typeof CheckCircle2 }> = {
|
||||
present: { active: "bg-emerald-500 text-white border-emerald-500 hover:bg-emerald-600", icon: CheckCircle2 },
|
||||
absent: { active: "bg-red-500 text-white border-red-500 hover:bg-red-600", icon: XCircle },
|
||||
late: { active: "bg-amber-500 text-white border-amber-500 hover:bg-amber-600", icon: Clock },
|
||||
early_leave: { active: "bg-blue-500 text-white border-blue-500 hover:bg-blue-600", icon: LogOut },
|
||||
excused: { active: "bg-purple-500 text-white border-purple-500 hover:bg-purple-600", icon: FileText },
|
||||
}
|
||||
|
||||
/** 初始化状态计数,避免 `{} as Record<...>` 类型断言 */
|
||||
function createInitialStatusCounts(): Record<AttendanceStatus, number> {
|
||||
return {
|
||||
present: 0,
|
||||
absent: 0,
|
||||
late: 0,
|
||||
early_leave: 0,
|
||||
excused: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Saving..." : "Save Attendance"}
|
||||
{pending ? t("actions.save") + "..." : t("actions.save")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -67,24 +104,94 @@ export function AttendanceSheet({
|
||||
defaultDate?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("attendance")
|
||||
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 [searchQuery, setSearchQuery] = useState("")
|
||||
const [focusedStudentIndex, setFocusedStudentIndex] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showSwitchConfirm, setShowSwitchConfirm] = useState(false)
|
||||
const [pendingClassId, setPendingClassId] = useState<string | null>(null)
|
||||
const studentRefs = useRef<(HTMLTableRowElement | null)[]>([])
|
||||
|
||||
const handleStatusChange = (studentId: string, status: AttendanceStatus) => {
|
||||
const handleStatusChange = useCallback((studentId: string, status: AttendanceStatus) => {
|
||||
setStatuses((prev) => ({ ...prev, [studentId]: status }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const markAllPresent = () => {
|
||||
const markAllPresent = useCallback(() => {
|
||||
const all: Record<string, AttendanceStatus> = {}
|
||||
for (const s of students) all[s.id] = "present"
|
||||
setStatuses(all)
|
||||
toast.success(t("actions.markAllPresent"))
|
||||
}, [students, t])
|
||||
|
||||
const handleClassChange = (newClassId: string) => {
|
||||
const hasUnsaved = Object.keys(statuses).length > 0
|
||||
if (hasUnsaved && newClassId !== classId) {
|
||||
setPendingClassId(newClassId)
|
||||
setShowSwitchConfirm(true)
|
||||
return
|
||||
}
|
||||
confirmClassSwitch(newClassId)
|
||||
}
|
||||
|
||||
const confirmClassSwitch = (newClassId: string) => {
|
||||
setClassId(newClassId)
|
||||
setStatuses({})
|
||||
const newUrl = newClassId ? `/teacher/attendance/sheet?classId=${encodeURIComponent(newClassId)}` : "/teacher/attendance/sheet"
|
||||
router.push(newUrl)
|
||||
}
|
||||
|
||||
const filteredStudents = students.filter(
|
||||
(s) => !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const statusCounts = STATUS_OPTIONS.reduce(
|
||||
(acc, st) => {
|
||||
acc[st] = students.filter((s) => (statuses[s.id] ?? "present") === st).length
|
||||
return acc
|
||||
},
|
||||
createInitialStatusCounts()
|
||||
)
|
||||
|
||||
// 派生值:当筛选结果变少时,焦点索引自动夹紧到有效范围,避免 useEffect 重置导致的级联渲染
|
||||
const effectiveFocusedIndex = filteredStudents.length === 0
|
||||
? 0
|
||||
: Math.min(focusedStudentIndex, filteredStudents.length - 1)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
const key = e.key.toLowerCase()
|
||||
if (STATUS_SHORTCUTS[key] && filteredStudents[effectiveFocusedIndex]) {
|
||||
e.preventDefault()
|
||||
handleStatusChange(filteredStudents[effectiveFocusedIndex].id, STATUS_SHORTCUTS[key])
|
||||
if (effectiveFocusedIndex < filteredStudents.length - 1) {
|
||||
setFocusedStudentIndex((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
if (e.key === "ArrowDown" && effectiveFocusedIndex < filteredStudents.length - 1) {
|
||||
e.preventDefault()
|
||||
setFocusedStudentIndex((prev) => prev + 1)
|
||||
}
|
||||
if (e.key === "ArrowUp" && effectiveFocusedIndex > 0) {
|
||||
e.preventDefault()
|
||||
setFocusedStudentIndex((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [filteredStudents, effectiveFocusedIndex, handleStatusChange])
|
||||
|
||||
useEffect(() => {
|
||||
studentRefs.current[effectiveFocusedIndex]?.scrollIntoView({ block: "nearest" })
|
||||
}, [effectiveFocusedIndex])
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!classId || !date) {
|
||||
toast.error("Please select class and date")
|
||||
toast.error(t("errors.invalidForm"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,35 +203,48 @@ export function AttendanceSheet({
|
||||
}))
|
||||
|
||||
if (records.length === 0) {
|
||||
toast.error("No students to record attendance for")
|
||||
toast.error(t("sheet.noStudents"))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
formData.set("recordsJson", JSON.stringify(records))
|
||||
|
||||
const result = await batchRecordAttendanceAction(null, formData)
|
||||
setIsSubmitting(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
toast.success(result.message || t("sheet.saved"))
|
||||
router.push("/teacher/attendance")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to save attendance")
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="relative">
|
||||
{isSubmitting && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
{t("sheet.saved")}...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>Attendance Sheet</CardTitle>
|
||||
<CardTitle>{t("title.sheet")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("description.teacherRecords")}
|
||||
</p>
|
||||
</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" />
|
||||
<Label htmlFor="class-select">{t("filters.class")}</Label>
|
||||
<Select value={classId} onValueChange={handleClassChange}>
|
||||
<SelectTrigger id="class-select">
|
||||
<SelectValue placeholder={t("sheet.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
@@ -137,7 +257,7 @@ export function AttendanceSheet({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date">Date</Label>
|
||||
<Label htmlFor="date">{t("filters.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
|
||||
@@ -147,6 +267,7 @@ export function AttendanceSheet({
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="pl-9"
|
||||
required
|
||||
aria-label={t("filters.date")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,55 +275,100 @@ export function AttendanceSheet({
|
||||
|
||||
{students.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No students in this class. Select a class to load students.
|
||||
{t("sheet.noStudents")}
|
||||
</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 className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{STATUS_OPTIONS.map((st) => {
|
||||
const Icon = STATUS_STYLES[st].icon
|
||||
return (
|
||||
<span key={st} className="inline-flex items-center gap-1 rounded-md border bg-muted/50 px-2 py-1 text-xs">
|
||||
<Icon className="h-3 w-3" aria-hidden="true" />
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[st])}
|
||||
<span className="font-semibold tabular-nums">{statusCounts[st]}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("list.columns.student")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 w-40 pl-8 text-sm"
|
||||
aria-label={t("list.columns.student")}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={markAllPresent}>
|
||||
{t("actions.markAllPresent")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="w-48">Status</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>{t("list.columns.student")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("list.columns.remark")}</TableHead>
|
||||
<TableHead>{t("list.columns.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) => {
|
||||
if (isAttendanceStatus(v)) {
|
||||
handleStatusChange(s.id, v)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{filteredStudents.map((s, idx) => {
|
||||
const currentStatus = statuses[s.id] ?? "present"
|
||||
const isFocused = idx === effectiveFocusedIndex
|
||||
return (
|
||||
<TableRow
|
||||
key={s.id}
|
||||
ref={(el) => { studentRefs.current[idx] = el }}
|
||||
className={cn("cursor-pointer", isFocused && "bg-primary/5")}
|
||||
onClick={() => setFocusedStudentIndex(idx)}
|
||||
role="button"
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
aria-label={s.name}
|
||||
>
|
||||
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">{s.email}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{STATUS_OPTIONS.map((st) => {
|
||||
const Icon = STATUS_STYLES[st].icon
|
||||
const isActive = currentStatus === st
|
||||
return (
|
||||
<button
|
||||
key={st}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleStatusChange(s.id, st)
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? STATUS_STYLES[st].active
|
||||
: "border-border bg-background text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
aria-pressed={isActive}
|
||||
aria-label={`${t(ATTENDANCE_STATUS_LABEL_KEYS[st])} (${st[0].toUpperCase()})`}
|
||||
>
|
||||
<Icon className="h-3 w-3" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">{t(ATTENDANCE_STATUS_LABEL_KEYS[st])}</span>
|
||||
<span className="sm:hidden">{st[0].toUpperCase()}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -211,12 +377,37 @@ export function AttendanceSheet({
|
||||
|
||||
<CardFooter className="justify-end gap-2 px-0">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<SubmitButton />
|
||||
</CardFooter>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<AlertDialog open={showSwitchConfirm} onOpenChange={setShowSwitchConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("sheet.confirmDelete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("description.teacherRecords")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (pendingClassId) {
|
||||
confirmClassSwitch(pendingClassId)
|
||||
setPendingClassId(null)
|
||||
}
|
||||
setShowSwitchConfirm(false)
|
||||
}}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatItem } from "@/shared/components/ui/stat-item"
|
||||
import {
|
||||
Users,
|
||||
@@ -8,71 +10,70 @@ import {
|
||||
LogOut,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
} from "lucide-react"
|
||||
import type { AttendanceStats } from "../types"
|
||||
|
||||
export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) {
|
||||
const t = useTranslations("attendance")
|
||||
|
||||
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>
|
||||
<EmptyState
|
||||
title={t("stats.noData")}
|
||||
description={t("stats.noDataDescription")}
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Attendance Statistics</CardTitle>
|
||||
<CardTitle>{t("title.teacherStats")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatItem
|
||||
label="Total Records"
|
||||
label={t("stats.totalRecords")}
|
||||
value={stats.total}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Present"
|
||||
label={t("stats.present")}
|
||||
value={stats.present}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Absent"
|
||||
label={t("stats.absent")}
|
||||
value={stats.absent}
|
||||
icon={<XCircle className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Late"
|
||||
label={t("stats.late")}
|
||||
value={stats.late}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Early Leave"
|
||||
label={t("stats.earlyLeave")}
|
||||
value={stats.earlyLeave}
|
||||
icon={<LogOut className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Excused"
|
||||
label={t("stats.excused")}
|
||||
value={stats.excused}
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Present Rate"
|
||||
label={t("stats.attendanceRate")}
|
||||
value={`${stats.presentRate.toFixed(1)}%`}
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
hint="Present / Total"
|
||||
/>
|
||||
<StatItem
|
||||
label="Late Rate"
|
||||
label={t("stats.lateRate")}
|
||||
value={`${stats.lateRate.toFixed(1)}%`}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
hint="Late / Total"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
@@ -13,21 +14,23 @@ import { CalendarCheck } from "lucide-react"
|
||||
|
||||
import { AttendanceStatsCard } from "./attendance-stats-card"
|
||||
import {
|
||||
ATTENDANCE_STATUS_COLORS,
|
||||
ATTENDANCE_STATUS_LABELS,
|
||||
type StudentAttendanceSummary,
|
||||
} from "../types"
|
||||
ATTENDANCE_STATUS_BADGE_VARIANTS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
} from "../constants"
|
||||
import type { StudentAttendanceSummary } from "../types"
|
||||
|
||||
export function StudentAttendanceView({
|
||||
summary,
|
||||
}: {
|
||||
summary: StudentAttendanceSummary | null
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No data"
|
||||
description="Student attendance summary is not available."
|
||||
title={t("list.empty")}
|
||||
description={t("errors.notFound")}
|
||||
icon={CalendarCheck}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
@@ -39,7 +42,9 @@ export function StudentAttendanceView({
|
||||
<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>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("list.columns.student")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.studentName}</p>
|
||||
@@ -47,7 +52,9 @@ export function StudentAttendanceView({
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Records</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("stats.totalRecords")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.stats.total}</p>
|
||||
@@ -59,25 +66,25 @@ export function StudentAttendanceView({
|
||||
|
||||
{summary.recentRecords.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No attendance records"
|
||||
description="There are no attendance records for this student yet."
|
||||
title={t("list.empty")}
|
||||
description={t("list.emptyDescription")}
|
||||
icon={CalendarCheck}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Attendance</CardTitle>
|
||||
<CardTitle>{t("stats.recentRecords")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Class</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Remark</TableHead>
|
||||
<TableHead>{t("list.columns.date")}</TableHead>
|
||||
<TableHead>{t("list.columns.class")}</TableHead>
|
||||
<TableHead>{t("list.columns.status")}</TableHead>
|
||||
<TableHead>{t("list.columns.remark")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -86,8 +93,8 @@ export function StudentAttendanceView({
|
||||
<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 variant={ATTENDANCE_STATUS_BADGE_VARIANTS[r.status]} className="capitalize">
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.remark ?? "-"}</TableCell>
|
||||
|
||||
65
src/modules/attendance/constants.ts
Normal file
65
src/modules/attendance/constants.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 考勤模块共享常量(消除 types.ts / attendance-sheet.tsx / attendance-filters.tsx / parent-attendance-calendar.tsx 重复定义)。
|
||||
*
|
||||
* 注意:标签使用 i18n key(`status.*`),由组件层通过 `useTranslations("attendance")` 解析。
|
||||
*/
|
||||
import type { AttendanceStatus } from "./types"
|
||||
|
||||
/** 重导出类型,便于组件层从 constants 单点导入(消除 types/constants 双源混乱) */
|
||||
export type { AttendanceStatus } from "./types"
|
||||
|
||||
/** 考勤状态选项(顺序即 UI 展示顺序) */
|
||||
export const ATTENDANCE_STATUS_OPTIONS: AttendanceStatus[] = [
|
||||
"present",
|
||||
"absent",
|
||||
"late",
|
||||
"early_leave",
|
||||
"excused",
|
||||
]
|
||||
|
||||
/** 考勤状态 → i18n key 映射(组件层 `t(key)` 解析) */
|
||||
export const ATTENDANCE_STATUS_LABEL_KEYS: Record<AttendanceStatus, string> = {
|
||||
present: "status.present",
|
||||
absent: "status.absent",
|
||||
late: "status.late",
|
||||
early_leave: "status.early_leave",
|
||||
excused: "status.excused",
|
||||
}
|
||||
|
||||
/** 考勤状态 → Badge variant 映射 */
|
||||
export const ATTENDANCE_STATUS_BADGE_VARIANTS: Record<AttendanceStatus, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
present: "default",
|
||||
absent: "destructive",
|
||||
late: "secondary",
|
||||
early_leave: "outline",
|
||||
excused: "outline",
|
||||
}
|
||||
|
||||
/** 考勤状态 → Tailwind 圆点颜色类 */
|
||||
export const ATTENDANCE_STATUS_DOT_COLORS: Record<AttendanceStatus, string> = {
|
||||
present: "bg-green-500",
|
||||
absent: "bg-red-500",
|
||||
late: "bg-yellow-500",
|
||||
early_leave: "bg-blue-500",
|
||||
excused: "bg-purple-500",
|
||||
}
|
||||
|
||||
/** 键盘快捷键映射 */
|
||||
export const ATTENDANCE_STATUS_SHORTCUTS: Record<string, AttendanceStatus> = {
|
||||
p: "present",
|
||||
a: "absent",
|
||||
l: "late",
|
||||
e: "early_leave",
|
||||
x: "excused",
|
||||
}
|
||||
|
||||
/** 初始化状态计数,避免 `{} as Record<...>` 类型断言 */
|
||||
export function createInitialStatusCounts(): Record<AttendanceStatus, number> {
|
||||
return {
|
||||
present: 0,
|
||||
absent: 0,
|
||||
late: 0,
|
||||
early_leave: 0,
|
||||
excused: 0,
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,10 @@ const EMPTY_STATS: AttendanceStats = {
|
||||
lateRate: 0,
|
||||
}
|
||||
|
||||
const computeStats = (rows: { status: string }[]): AttendanceStats => {
|
||||
/**
|
||||
* 根据考勤记录行计算统计(纯函数,便于测试)。
|
||||
*/
|
||||
export 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) {
|
||||
|
||||
@@ -83,21 +83,7 @@ export interface PaginatedAttendanceResult {
|
||||
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",
|
||||
}
|
||||
/**
|
||||
* 注意:状态标签与颜色映射已迁移至 `./constants.ts`,
|
||||
* 使用 i18n key(`status.*`)+ Badge variant,由组件层通过 `useTranslations("attendance")` 解析。
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { trackEvent } from "@/shared/lib/track-event"
|
||||
|
||||
import {
|
||||
CreateElectiveCourseSchema,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
RunLotterySchema,
|
||||
} from "./schema"
|
||||
import {
|
||||
getElectiveCourses,
|
||||
getElectiveCourseById,
|
||||
createElectiveCourse,
|
||||
updateElectiveCourse,
|
||||
@@ -22,15 +22,6 @@ import {
|
||||
closeSelection,
|
||||
} from "./data-access"
|
||||
import { runLottery, selectCourse, dropCourse } from "./data-access-operations"
|
||||
import {
|
||||
getStudentSelections,
|
||||
getAvailableCoursesForStudent,
|
||||
} from "./data-access-selections"
|
||||
import type {
|
||||
ElectiveCourseWithDetails,
|
||||
CourseSelectionWithDetails,
|
||||
GetElectiveCoursesParams,
|
||||
} from "./types"
|
||||
|
||||
const handleError = (e: unknown): ActionState<never> => {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
@@ -103,6 +94,13 @@ export async function createElectiveCourseAction(
|
||||
}
|
||||
const id = await createElectiveCourse(parsed.data, ctx.userId)
|
||||
revalidateElectivePaths(id)
|
||||
await trackEvent({
|
||||
event: "elective.course_created",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "elective_course",
|
||||
properties: { capacity: parsed.data.capacity, selectionMode: parsed.data.selectionMode },
|
||||
})
|
||||
return { success: true, message: "Elective course created", data: id }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -148,6 +146,12 @@ export async function updateElectiveCourseAction(
|
||||
}
|
||||
await updateElectiveCourse(id, parsed.data)
|
||||
revalidateElectivePaths(id)
|
||||
await trackEvent({
|
||||
event: "elective.course_updated",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "elective_course",
|
||||
})
|
||||
return { success: true, message: "Elective course updated", data: id }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -169,6 +173,12 @@ export async function deleteElectiveCourseAction(
|
||||
|
||||
await deleteElectiveCourse(id)
|
||||
revalidateElectivePaths()
|
||||
await trackEvent({
|
||||
event: "elective.course_deleted",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "elective_course",
|
||||
})
|
||||
return { success: true, message: "Elective course deleted" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -190,6 +200,12 @@ export async function openSelectionAction(
|
||||
|
||||
await openSelection(courseId)
|
||||
revalidateElectivePaths(courseId)
|
||||
await trackEvent({
|
||||
event: "elective.selection_opened",
|
||||
userId: ctx.userId,
|
||||
targetId: courseId,
|
||||
targetType: "elective_course",
|
||||
})
|
||||
return { success: true, message: "Selection opened" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -211,6 +227,12 @@ export async function closeSelectionAction(
|
||||
|
||||
await closeSelection(courseId)
|
||||
revalidateElectivePaths(courseId)
|
||||
await trackEvent({
|
||||
event: "elective.selection_closed",
|
||||
userId: ctx.userId,
|
||||
targetId: courseId,
|
||||
targetType: "elective_course",
|
||||
})
|
||||
return { success: true, message: "Selection closed" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -241,6 +263,13 @@ export async function runLotteryAction(
|
||||
|
||||
const result = await runLottery(parsed.data.courseId)
|
||||
revalidateElectivePaths(parsed.data.courseId)
|
||||
await trackEvent({
|
||||
event: "elective.lottery_completed",
|
||||
userId: ctx.userId,
|
||||
targetId: parsed.data.courseId,
|
||||
targetType: "elective_course",
|
||||
properties: { enrolled: result.enrolled, waitlist: result.waitlist },
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`,
|
||||
@@ -270,6 +299,13 @@ export async function selectCourseAction(
|
||||
}
|
||||
const result = await selectCourse(parsed.data.courseId, ctx.userId, parsed.data.priority)
|
||||
revalidateElectivePaths(parsed.data.courseId)
|
||||
await trackEvent({
|
||||
event: "elective.course_selected",
|
||||
userId: ctx.userId,
|
||||
targetId: parsed.data.courseId,
|
||||
targetType: "course_selection",
|
||||
properties: { status: result.status, priority: parsed.data.priority },
|
||||
})
|
||||
return { success: true, message: result.message, data: result.status }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -294,52 +330,14 @@ export async function dropCourseAction(
|
||||
}
|
||||
await dropCourse(parsed.data.courseId, ctx.userId)
|
||||
revalidateElectivePaths(parsed.data.courseId)
|
||||
await trackEvent({
|
||||
event: "elective.course_dropped",
|
||||
userId: ctx.userId,
|
||||
targetId: parsed.data.courseId,
|
||||
targetType: "course_selection",
|
||||
})
|
||||
return { success: true, message: "Course dropped" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getElectiveCoursesAction(
|
||||
params?: GetElectiveCoursesParams
|
||||
): Promise<ActionState<ElectiveCourseWithDetails[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
||||
const data = await getElectiveCourses({
|
||||
...params,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStudentSelectionsAction(
|
||||
studentId: string
|
||||
): Promise<ActionState<CourseSelectionWithDetails[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||
return { success: false, message: "Can only view your own selections" }
|
||||
}
|
||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
return { success: false, message: "Can only view your children's selections" }
|
||||
}
|
||||
const data = await getStudentSelections(studentId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvailableCoursesAction(): Promise<ActionState<ElectiveCourseWithDetails[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
|
||||
const data = await getAvailableCoursesForStudent(ctx.userId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
|
||||
import { isSelectionMode } from "../constants"
|
||||
import type { ElectiveCourseWithDetails, ElectiveSelectionMode } from "../types"
|
||||
|
||||
type Mode = "create" | "edit"
|
||||
@@ -28,9 +29,6 @@ interface Option {
|
||||
name: string
|
||||
}
|
||||
|
||||
const isSelectionMode = (v: string): v is ElectiveSelectionMode =>
|
||||
v === "fcfs" || v === "lottery"
|
||||
|
||||
export function ElectiveCourseForm({
|
||||
mode,
|
||||
course,
|
||||
|
||||
@@ -15,10 +15,10 @@ import type { ActionState } from "@/shared/types/action-state"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import {
|
||||
ELECTIVE_STATUS_COLORS,
|
||||
ELECTIVE_STATUS_LABELS,
|
||||
SELECTION_MODE_LABELS,
|
||||
} from "../types"
|
||||
ELECTIVE_STATUS_BADGE_VARIANTS,
|
||||
ELECTIVE_STATUS_LABEL_KEYS,
|
||||
SELECTION_MODE_LABEL_KEYS,
|
||||
} from "../constants"
|
||||
import type { ElectiveCourseWithDetails } from "../types"
|
||||
import {
|
||||
deleteElectiveCourseAction,
|
||||
@@ -59,7 +59,7 @@ export function ElectiveCourseList({
|
||||
toast.success(res.message ?? successMsg)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Operation failed")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -72,10 +72,10 @@ export function ElectiveCourseList({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await deleteElectiveCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? "Course deleted")
|
||||
toast.success(res.message ?? t("actions.delete"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Delete failed")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -113,8 +113,8 @@ export function ElectiveCourseList({
|
||||
<Card key={course.id} className="flex h-full flex-col">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||
<Badge variant={ELECTIVE_STATUS_COLORS[course.status]} className="shrink-0">
|
||||
{ELECTIVE_STATUS_LABELS[course.status]}
|
||||
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]} className="shrink-0">
|
||||
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
@@ -125,7 +125,7 @@ export function ElectiveCourseList({
|
||||
{course.gradeName ? (
|
||||
<Badge variant="outline">{course.gradeName}</Badge>
|
||||
) : null}
|
||||
<span>Credit: {course.credit}</span>
|
||||
<span>{t("fields.credit")}: {course.credit}</span>
|
||||
</div>
|
||||
|
||||
{course.description ? (
|
||||
@@ -136,25 +136,25 @@ export function ElectiveCourseList({
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Teacher:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Mode:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.selectionMode")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{SELECTION_MODE_LABELS[course.selectionMode]}
|
||||
{t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Capacity:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{course.enrolledCount}/{course.capacity}
|
||||
{isFull ? " (Full)" : ""}
|
||||
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{course.classroom ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Room:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.classroom")}:</span>{" "}
|
||||
<span className="font-medium">{course.classroom}</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -162,7 +162,7 @@ export function ElectiveCourseList({
|
||||
|
||||
{course.schedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Schedule:</span> {course.schedule}
|
||||
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -176,7 +176,7 @@ export function ElectiveCourseList({
|
||||
>
|
||||
<a href={`${editBaseHref}/${course.id}/edit`}>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
Edit
|
||||
{t("actions.edit")}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -185,10 +185,10 @@ export function ElectiveCourseList({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => runAction(openSelectionAction, course.id, "Selection opened")}
|
||||
onClick={() => runAction(openSelectionAction, course.id, t("actions.openSelection"))}
|
||||
>
|
||||
<Unlock className="mr-1 h-3 w-3" />
|
||||
Open
|
||||
{t("actions.openSelection")}
|
||||
</Button>
|
||||
) : null}
|
||||
{course.status === "open" ? (
|
||||
@@ -196,10 +196,10 @@ export function ElectiveCourseList({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => runAction(closeSelectionAction, course.id, "Selection closed")}
|
||||
onClick={() => runAction(closeSelectionAction, course.id, t("actions.closeSelection"))}
|
||||
>
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
Close
|
||||
{t("actions.closeSelection")}
|
||||
</Button>
|
||||
) : null}
|
||||
{course.selectionMode === "lottery" && course.status !== "draft" ? (
|
||||
@@ -207,10 +207,10 @@ export function ElectiveCourseList({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => runAction(runLotteryAction, course.id, "Lottery completed")}
|
||||
onClick={() => runAction(runLotteryAction, course.id, t("actions.runLottery"))}
|
||||
>
|
||||
<Shuffle className="mr-1 h-3 w-3" />
|
||||
Lottery
|
||||
{t("actions.runLottery")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -221,7 +221,7 @@ export function ElectiveCourseList({
|
||||
onClick={() => handleDelete(course.id)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Delete
|
||||
{t("actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import {
|
||||
Select,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function ElectiveFilters() {
|
||||
const t = useTranslations("elective")
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [mode, setMode] = useQueryState("mode", parseAsString.withDefault("all"))
|
||||
|
||||
@@ -29,18 +31,18 @@ export function ElectiveFilters() {
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="Search by course name, teacher..."
|
||||
placeholder={t("form.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={mode} onValueChange={(val) => setMode(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder="Selection Mode" />
|
||||
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20" aria-label={t("fields.selectionMode")}>
|
||||
<SelectValue placeholder={t("fields.selectionMode")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Modes</SelectItem>
|
||||
<SelectItem value="fcfs">First Come First Served</SelectItem>
|
||||
<SelectItem value="lottery">Lottery</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
|
||||
<SelectItem value="fcfs">{t("selectionMode.fcfs")}</SelectItem>
|
||||
<SelectItem value="lottery">{t("selectionMode.lottery")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,32 @@
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
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 {
|
||||
COURSE_SELECTION_STATUS_COLORS,
|
||||
COURSE_SELECTION_STATUS_LABELS,
|
||||
ELECTIVE_STATUS_LABELS,
|
||||
SELECTION_MODE_LABELS,
|
||||
} from "../types"
|
||||
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
|
||||
COURSE_SELECTION_STATUS_LABEL_KEYS,
|
||||
ELECTIVE_STATUS_BADGE_VARIANTS,
|
||||
ELECTIVE_STATUS_LABEL_KEYS,
|
||||
SELECTION_MODE_LABEL_KEYS,
|
||||
} from "../constants"
|
||||
import type {
|
||||
CourseSelectionWithDetails,
|
||||
ElectiveCourseWithDetails,
|
||||
@@ -30,6 +43,7 @@ export function StudentSelectionView({
|
||||
mySelections: CourseSelectionWithDetails[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("elective")
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
@@ -47,10 +61,10 @@ export function StudentSelectionView({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await selectCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
toast.success(res.message || t("student.selectSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Failed to select course")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -63,10 +77,10 @@ export function StudentSelectionView({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await dropCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
toast.success(res.message || t("student.dropSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Failed to drop course")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -76,15 +90,15 @@ export function StudentSelectionView({
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">My Selections</h3>
|
||||
<h3 className="text-lg font-semibold">{t("student.mySelections")}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activeSelections.length} active
|
||||
{activeSelections.length}
|
||||
</span>
|
||||
</div>
|
||||
{activeSelections.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No selections yet"
|
||||
description="Browse available courses below and select your electives."
|
||||
title={t("list.empty")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
@@ -94,33 +108,52 @@ export function StudentSelectionView({
|
||||
<Card key={sel.id}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
{sel.courseName ?? "Unknown course"}
|
||||
{sel.courseName ?? t("errors.notFound")}
|
||||
</CardTitle>
|
||||
<Badge variant={COURSE_SELECTION_STATUS_COLORS[sel.status]}>
|
||||
{COURSE_SELECTION_STATUS_LABELS[sel.status]}
|
||||
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
|
||||
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enrolled: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
</p>
|
||||
) : null}
|
||||
{sel.lotteryRank ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lottery rank: #{sel.lotteryRank}
|
||||
#{sel.lotteryRank}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPending && pendingId === sel.courseId}
|
||||
onClick={() => handleDrop(sel.courseId)}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
Drop
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPending && pendingId === sel.courseId}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("actions.drop")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("student.confirmDrop")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("student.confirmDrop")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDrop(sel.courseId)}
|
||||
>
|
||||
{t("actions.drop")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -130,15 +163,15 @@ export function StudentSelectionView({
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Available Courses</h3>
|
||||
<h3 className="text-lg font-semibold">{t("student.availableCourses")}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{availableCourses.length} open
|
||||
{availableCourses.length}
|
||||
</span>
|
||||
</div>
|
||||
{availableCourses.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No available courses"
|
||||
description="There are no elective courses open for selection right now."
|
||||
title={t("list.emptyStudent")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
@@ -149,11 +182,11 @@ export function StudentSelectionView({
|
||||
const alreadySelected = selectedCourseIds.has(course.id)
|
||||
const isPendingThis = isPending && pendingId === course.id
|
||||
return (
|
||||
<Card key={course.id} className="flex h-full flex-col">
|
||||
<Card key={course.id} className="flex h-full flex-col" role="article" aria-label={course.name}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||
<Badge variant="outline">
|
||||
{ELECTIVE_STATUS_LABELS[course.status]}
|
||||
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]}>
|
||||
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
@@ -161,8 +194,8 @@ export function StudentSelectionView({
|
||||
{course.subjectName ? (
|
||||
<Badge variant="outline">{course.subjectName}</Badge>
|
||||
) : null}
|
||||
<span>Credit: {course.credit}</span>
|
||||
<span>· {SELECTION_MODE_LABELS[course.selectionMode]}</span>
|
||||
<span>{t("fields.credit")}: {course.credit}</span>
|
||||
<span>· {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}</span>
|
||||
</div>
|
||||
{course.description ? (
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
@@ -171,27 +204,27 @@ export function StudentSelectionView({
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Teacher:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Capacity:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{course.enrolledCount}/{course.capacity}
|
||||
{isFull ? " (Full)" : ""}
|
||||
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{course.schedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Schedule:</span> {course.schedule}
|
||||
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-auto pt-2">
|
||||
{alreadySelected ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Already selected
|
||||
{t("student.selected")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -199,7 +232,7 @@ export function StudentSelectionView({
|
||||
disabled={isPendingThis}
|
||||
onClick={() => handleSelect(course.id)}
|
||||
>
|
||||
{isPendingThis ? "Selecting..." : "Select"}
|
||||
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
55
src/modules/elective/constants.ts
Normal file
55
src/modules/elective/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 选修课模块共享常量(消除 types.ts / elective-course-form.tsx / elective-filters.tsx 重复定义)。
|
||||
*
|
||||
* 注意:标签使用 i18n key,由组件层通过 `useTranslations("elective")` 解析。
|
||||
*/
|
||||
import type {
|
||||
ElectiveCourseStatus,
|
||||
ElectiveSelectionMode,
|
||||
CourseSelectionStatus,
|
||||
} from "./types"
|
||||
|
||||
/** 课程状态 → i18n key 映射 */
|
||||
export const ELECTIVE_STATUS_LABEL_KEYS: Record<ElectiveCourseStatus, string> = {
|
||||
draft: "status.draft",
|
||||
open: "status.open",
|
||||
closed: "status.closed",
|
||||
cancelled: "status.cancelled",
|
||||
}
|
||||
|
||||
/** 课程状态 → Badge variant 映射 */
|
||||
export const ELECTIVE_STATUS_BADGE_VARIANTS: Record<ElectiveCourseStatus, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "secondary",
|
||||
open: "default",
|
||||
closed: "outline",
|
||||
cancelled: "destructive",
|
||||
}
|
||||
|
||||
/** 选课模式 → i18n key 映射 */
|
||||
export const SELECTION_MODE_LABEL_KEYS: Record<ElectiveSelectionMode, string> = {
|
||||
fcfs: "selectionMode.fcfs",
|
||||
lottery: "selectionMode.lottery",
|
||||
}
|
||||
|
||||
/** 选课状态 → i18n key 映射 */
|
||||
export const COURSE_SELECTION_STATUS_LABEL_KEYS: Record<CourseSelectionStatus, string> = {
|
||||
selected: "selectionStatus.selected",
|
||||
enrolled: "selectionStatus.enrolled",
|
||||
waitlist: "selectionStatus.waitlist",
|
||||
dropped: "selectionStatus.dropped",
|
||||
rejected: "selectionStatus.rejected",
|
||||
}
|
||||
|
||||
/** 选课状态 → Badge variant 映射 */
|
||||
export const COURSE_SELECTION_STATUS_BADGE_VARIANTS: Record<CourseSelectionStatus, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
selected: "secondary",
|
||||
enrolled: "default",
|
||||
waitlist: "outline",
|
||||
dropped: "destructive",
|
||||
rejected: "destructive",
|
||||
}
|
||||
|
||||
/** 类型守卫:校验字符串是否为合法的选课模式 */
|
||||
export function isSelectionMode(v: string): v is ElectiveSelectionMode {
|
||||
return v === "fcfs" || v === "lottery"
|
||||
}
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
|
||||
import type { CourseSelectionStatus } from "./types"
|
||||
|
||||
function buildLotteryRankCase(ids: string[], startRank: number): SQL {
|
||||
/**
|
||||
* 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。
|
||||
*/
|
||||
export function buildLotteryRankCase(ids: string[], startRank: number): SQL {
|
||||
const branches = ids.map(
|
||||
(id, idx) => sql`WHEN ${id} THEN ${startRank + idx}`
|
||||
)
|
||||
|
||||
@@ -66,43 +66,8 @@ export interface GetElectiveCoursesParams {
|
||||
teacherId?: string
|
||||
}
|
||||
|
||||
export const ELECTIVE_STATUS_LABELS: Record<ElectiveCourseStatus, string> = {
|
||||
draft: "Draft",
|
||||
open: "Open",
|
||||
closed: "Closed",
|
||||
cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
export const ELECTIVE_STATUS_COLORS: Record<
|
||||
ElectiveCourseStatus,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
draft: "secondary",
|
||||
open: "default",
|
||||
closed: "outline",
|
||||
cancelled: "destructive",
|
||||
}
|
||||
|
||||
export const SELECTION_MODE_LABELS: Record<ElectiveSelectionMode, string> = {
|
||||
fcfs: "First Come First Served",
|
||||
lottery: "Lottery",
|
||||
}
|
||||
|
||||
export const COURSE_SELECTION_STATUS_LABELS: Record<CourseSelectionStatus, string> = {
|
||||
selected: "Selected",
|
||||
enrolled: "Enrolled",
|
||||
waitlist: "Waitlist",
|
||||
dropped: "Dropped",
|
||||
rejected: "Rejected",
|
||||
}
|
||||
|
||||
export const COURSE_SELECTION_STATUS_COLORS: Record<
|
||||
CourseSelectionStatus,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
selected: "secondary",
|
||||
enrolled: "default",
|
||||
waitlist: "outline",
|
||||
dropped: "destructive",
|
||||
rejected: "destructive",
|
||||
}
|
||||
/**
|
||||
* 注意:状态标签与颜色映射已迁移至 `./constants.ts`,
|
||||
* 使用 i18n key(`status.*` / `selectionMode.*` / `selectionStatus.*`)+ Badge variant,
|
||||
* 由组件层通过 `useTranslations("elective")` 解析。
|
||||
*/
|
||||
|
||||
209
src/modules/parent/components/parent-attendance-calendar.tsx
Normal file
209
src/modules/parent/components/parent-attendance-calendar.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import {
|
||||
ATTENDANCE_STATUS_DOT_COLORS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
} from "@/modules/attendance/constants"
|
||||
import type {
|
||||
ParentAttendanceListItem,
|
||||
ParentAttendanceStatus,
|
||||
ParentStudentAttendanceSummary,
|
||||
} from "@/modules/parent/types"
|
||||
|
||||
const WEEKDAY_KEYS = [
|
||||
"parent.weekday.sun",
|
||||
"parent.weekday.mon",
|
||||
"parent.weekday.tue",
|
||||
"parent.weekday.wed",
|
||||
"parent.weekday.thu",
|
||||
"parent.weekday.fri",
|
||||
"parent.weekday.sat",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 格式化日期为 `YYYY-MM-DD`(纯函数,便于测试)。
|
||||
*/
|
||||
export function formatDateKey(d: 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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 `YYYY-MM-DD` 为 Date(纯函数,便于测试)。
|
||||
*/
|
||||
export function parseDateKey(key: string): Date | null {
|
||||
const parts = key.split("-")
|
||||
if (parts.length !== 3) return null
|
||||
const [y, m, d] = parts.map(Number)
|
||||
if (!y || !m || !d) return null
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建日历单元格(纯函数,便于测试)。
|
||||
*/
|
||||
export function buildCalendarDays(year: number, month: number): Array<Date | null> {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
const startWeekday = firstDay.getDay()
|
||||
const totalDays = lastDay.getDate()
|
||||
const cells: Array<Date | null> = []
|
||||
for (let i = 0; i < startWeekday; i += 1) cells.push(null)
|
||||
for (let day = 1; day <= totalDays; day += 1) {
|
||||
cells.push(new Date(year, month, day))
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push(null)
|
||||
return cells
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个日期是否为同一天(纯函数,便于测试)。
|
||||
*/
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长视角的考勤月历视图。
|
||||
* 基于子女的近期考勤记录,在月历上按状态着色,让家长直观看到出勤分布。
|
||||
*/
|
||||
export function ParentAttendanceCalendar({
|
||||
summary,
|
||||
}: {
|
||||
summary: ParentStudentAttendanceSummary
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const now = new Date()
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth())
|
||||
|
||||
const recordMap = new Map<string, ParentAttendanceListItem>()
|
||||
for (const r of summary.recentRecords) {
|
||||
const d = parseDateKey(r.date)
|
||||
if (d) recordMap.set(formatDateKey(d), r)
|
||||
}
|
||||
|
||||
const days = buildCalendarDays(viewYear, viewMonth)
|
||||
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
|
||||
const goPrev = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11)
|
||||
setViewYear((y) => y - 1)
|
||||
} else {
|
||||
setViewMonth((m) => m - 1)
|
||||
}
|
||||
}
|
||||
const goNext = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0)
|
||||
setViewYear((y) => y + 1)
|
||||
} else {
|
||||
setViewMonth((m) => m + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const usedStatuses = new Set<ParentAttendanceStatus>()
|
||||
for (const r of recordMap.values()) usedStatuses.add(r.status)
|
||||
|
||||
return (
|
||||
<Card aria-label={t("parent.calendarTitle", { name: summary.studentName })}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>{t("parent.calendarFor", { name: summary.studentName })}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
aria-label={t("parent.prevMonth")}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[120px] text-center text-sm font-medium">{monthLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
aria-label={t("parent.nextMonth")}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-medium text-muted-foreground">
|
||||
{WEEKDAY_KEYS.map((key) => (
|
||||
<div key={key} className="py-1">
|
||||
{t(key)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((d, idx) => {
|
||||
if (!d) return <div key={`empty-${idx}`} className="aspect-square" />
|
||||
const key = formatDateKey(d)
|
||||
const record = recordMap.get(key)
|
||||
const isToday = isSameDay(d, now)
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"relative flex aspect-square flex-col items-center justify-center rounded-md border text-xs",
|
||||
"min-h-[36px]",
|
||||
record ? "border-border bg-muted/30" : "border-transparent",
|
||||
isToday && "ring-2 ring-primary ring-offset-1",
|
||||
)}
|
||||
aria-label={
|
||||
record
|
||||
? `${formatDateKey(d)}: ${t(ATTENDANCE_STATUS_LABEL_KEYS[record.status])}`
|
||||
: formatDateKey(d)
|
||||
}
|
||||
>
|
||||
<span className="tabular-nums">{d.getDate()}</span>
|
||||
{record ? (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 h-1.5 w-1.5 rounded-full",
|
||||
ATTENDANCE_STATUS_DOT_COLORS[record.status],
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{usedStatuses.size > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1 text-xs text-muted-foreground">
|
||||
{Array.from(usedStatuses).map((status) => (
|
||||
<span key={status} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn("h-2 w-2 rounded-full", ATTENDANCE_STATUS_DOT_COLORS[status])}
|
||||
aria-hidden
|
||||
/>
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[status])}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
130
src/modules/parent/components/parent-attendance-rate-card.tsx
Normal file
130
src/modules/parent/components/parent-attendance-rate-card.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
|
||||
|
||||
type AggregateStats = {
|
||||
totalStudents: number
|
||||
avgPresentRate: number
|
||||
totalAbsent: number
|
||||
totalLate: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合多个子女的考勤统计(纯函数,便于测试)。
|
||||
*/
|
||||
export function aggregateStats(
|
||||
summaries: ParentStudentAttendanceSummary[],
|
||||
): AggregateStats {
|
||||
if (summaries.length === 0) {
|
||||
return { totalStudents: 0, avgPresentRate: 0, totalAbsent: 0, totalLate: 0 }
|
||||
}
|
||||
const totalStudents = summaries.length
|
||||
const sumRate = summaries.reduce(
|
||||
(sum, s) => sum + (s.stats.total > 0 ? s.stats.presentRate : 0),
|
||||
0,
|
||||
)
|
||||
const avgPresentRate = sumRate / totalStudents
|
||||
const totalAbsent = summaries.reduce((sum, s) => sum + s.stats.absent, 0)
|
||||
const totalLate = summaries.reduce((sum, s) => sum + s.stats.late, 0)
|
||||
return { totalStudents, avgPresentRate, totalLate, totalAbsent }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据出勤率返回语气色调(纯函数,便于测试)。
|
||||
*/
|
||||
export function rateTone(rate: number): "good" | "warn" | "bad" {
|
||||
if (rate >= 95) return "good"
|
||||
if (rate >= 90) return "warn"
|
||||
return "bad"
|
||||
}
|
||||
|
||||
const TONE_STYLES: Record<"good" | "warn" | "bad", string> = {
|
||||
good: "text-emerald-600",
|
||||
warn: "text-amber-600",
|
||||
bad: "text-destructive",
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长考勤页顶部的出勤率汇总卡片。
|
||||
* 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。
|
||||
*/
|
||||
export function ParentAttendanceRateCard({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: ParentStudentAttendanceSummary[]
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const stats = aggregateStats(summaries)
|
||||
if (stats.totalStudents === 0) return null
|
||||
|
||||
const tone = rateTone(stats.avgPresentRate)
|
||||
const rateLabel =
|
||||
stats.avgPresentRate >= 95
|
||||
? t("parent.rateExcellent")
|
||||
: stats.avgPresentRate >= 90
|
||||
? t("parent.rateNeedsAttention")
|
||||
: t("parent.rateBelowStandard")
|
||||
|
||||
return (
|
||||
<Card className="p-4" aria-label={t("parent.rateCardTitle")}>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<TrendingUp className="h-3 w-3" aria-hidden />
|
||||
{t("stats.attendanceRate")}
|
||||
</div>
|
||||
<div className={cn("text-2xl font-bold tabular-nums", TONE_STYLES[tone])}>
|
||||
{stats.avgPresentRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{rateLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CalendarCheck className="h-3 w-3" aria-hidden />
|
||||
{t("parent.children")}
|
||||
</div>
|
||||
<div className="text-2xl font-bold tabular-nums">{stats.totalStudents}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("parent.linked")}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CalendarX className="h-3 w-3" aria-hidden />
|
||||
{t("stats.absent")}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl font-bold tabular-nums",
|
||||
stats.totalAbsent > 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{stats.totalAbsent}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("parent.thisPeriod")}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" aria-hidden />
|
||||
{t("stats.late")}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl font-bold tabular-nums",
|
||||
stats.totalLate > 0 && "text-amber-600",
|
||||
)}
|
||||
>
|
||||
{stats.totalLate}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("parent.thisPeriod")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
115
src/modules/parent/components/parent-attendance-warning.tsx
Normal file
115
src/modules/parent/components/parent-attendance-warning.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Phone } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
|
||||
|
||||
type Warning = {
|
||||
studentId: string
|
||||
studentName: string
|
||||
message: string
|
||||
severity: "high" | "medium"
|
||||
}
|
||||
|
||||
/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */
|
||||
type Translator = ReturnType<typeof useTranslations>
|
||||
|
||||
/**
|
||||
* 构建考勤异常预警列表(纯函数,便于测试)。
|
||||
*/
|
||||
export function buildWarnings(
|
||||
summaries: ParentStudentAttendanceSummary[],
|
||||
t: Translator,
|
||||
): Warning[] {
|
||||
const warnings: Warning[] = []
|
||||
|
||||
for (const s of summaries) {
|
||||
const { stats, studentName, studentId } = s
|
||||
if (stats.absent >= 3) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName,
|
||||
message: t("parent.absentHighSeverity", { count: stats.absent }),
|
||||
severity: "high",
|
||||
})
|
||||
} else if (stats.absent >= 1) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName,
|
||||
message: t("parent.absentWarning", { count: stats.absent }),
|
||||
severity: "medium",
|
||||
})
|
||||
}
|
||||
|
||||
if (stats.late >= 3) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName,
|
||||
message: t("parent.lateWarning", { count: stats.late }),
|
||||
severity: "medium",
|
||||
})
|
||||
}
|
||||
|
||||
if (stats.presentRate < 90 && stats.total > 0) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName,
|
||||
message: t("parent.lowRateWarning", { rate: stats.presentRate.toFixed(1) }),
|
||||
severity: "high",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长视角的考勤异常预警横幅。
|
||||
* 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。
|
||||
*/
|
||||
export function ParentAttendanceWarning({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: ParentStudentAttendanceSummary[]
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const warnings = buildWarnings(summaries, t)
|
||||
if (warnings.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border-destructive/30 bg-destructive/5 p-4",
|
||||
warnings.some((w) => w.severity === "high") && "border-destructive/50",
|
||||
)}
|
||||
aria-label={t("parent.warningTitle")}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle
|
||||
className="h-5 w-5 text-destructive shrink-0 mt-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-sm font-semibold text-destructive">
|
||||
{t("parent.warningTitle")}
|
||||
</div>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{warnings.map((w, idx) => (
|
||||
<li key={`${w.studentId}-${idx}`} className="flex items-start gap-2">
|
||||
<span className="font-medium shrink-0">{w.studentName}:</span>
|
||||
<span className="text-muted-foreground">{w.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center gap-2 pt-1 text-xs text-muted-foreground">
|
||||
<Phone className="h-3 w-3" aria-hidden />
|
||||
<span>{t("parent.contactHomeroom")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,12 @@ export type ChildScheduleItem = {
|
||||
location: string | null
|
||||
}
|
||||
|
||||
/** 单条周课表项(含 weekday)。 */
|
||||
export type ChildWeeklyScheduleItem = ChildScheduleItem & {
|
||||
/** 1=周一 ... 7=周日 */
|
||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
}
|
||||
|
||||
/** 子女作业摘要(统计计数 + 最近作业列表)。 */
|
||||
export type ChildHomeworkSummaryData = {
|
||||
pendingCount: number
|
||||
@@ -54,6 +60,8 @@ export type ChildDashboardData = {
|
||||
basicInfo: ChildBasicInfo
|
||||
enrolledClasses: StudentEnrolledClass[]
|
||||
todaySchedule: ChildScheduleItem[]
|
||||
/** 完整周课表(按 weekday 升序)。 */
|
||||
weeklySchedule: ChildWeeklyScheduleItem[]
|
||||
homeworkSummary: ChildHomeworkSummaryData
|
||||
/** 成绩趋势数据;`trend` 按时间升序,`recent` 按时间降序。 */
|
||||
gradeTrend: StudentDashboardGradeProps
|
||||
@@ -65,3 +73,45 @@ export type ParentDashboardData = {
|
||||
parentName: string | null
|
||||
children: ChildDashboardData[]
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 考勤视图模型(解耦 parent 模块对 attendance/types 的直接依赖) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 家长视角所需的考勤状态枚举。
|
||||
* 与 `attendance.AttendanceStatus` 结构兼容,但由 parent 模块自行声明,
|
||||
* 避免 parent 反向依赖 attendance 模块的类型变更。
|
||||
*/
|
||||
export type ParentAttendanceStatus =
|
||||
| "present"
|
||||
| "absent"
|
||||
| "late"
|
||||
| "early_leave"
|
||||
| "excused"
|
||||
|
||||
/** 家长视角所需的单条考勤记录(仅保留展示字段)。 */
|
||||
export type ParentAttendanceListItem = {
|
||||
id: string
|
||||
date: string
|
||||
status: ParentAttendanceStatus
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
/** 家长视角所需的考勤统计(仅保留展示字段)。 */
|
||||
export type ParentAttendanceStats = {
|
||||
total: number
|
||||
present: number
|
||||
absent: number
|
||||
late: number
|
||||
presentRate: number
|
||||
}
|
||||
|
||||
/** 家长视角所需的单个子女考勤汇总。 */
|
||||
export type ParentStudentAttendanceSummary = {
|
||||
studentId: string
|
||||
studentName: string
|
||||
stats: ParentAttendanceStats
|
||||
recentRecords: ParentAttendanceListItem[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user