feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates

parent:

- Add parent-student-attendance-detail component

auth:

- Add actions, data-access, schema, services, types

onboarding:

- Add parent-children-form and hooks directory

files:

- Add actions, schema, hooks directory

notifications:

- Add schema and schema test

adaptive-practice:

- Add answer-input, answer-result, practice-result-view, practice-starter-with-nav

- Add question-card, question-content, lib and services directories

ai:

- Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
SpecialX
2026-07-03 10:26:12 +08:00
parent f3c223d914
commit e9a5264fe7
84 changed files with 6060 additions and 2530 deletions

View File

@@ -9,6 +9,7 @@ import {
Mail,
Stethoscope,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
@@ -26,7 +27,7 @@ export type ChildDetailTab = "overview" | "homework" | "grades" | "exams" | "sch
const VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"]
const isTab = (v: string | undefined | null): v is ChildDetailTab =>
typeof v === "string" && (VALID_TABS as string[]).includes(v)
typeof v === "string" && VALID_TABS.some((tab) => tab === v)
const resolveTab = (v: string | undefined | null): ChildDetailTab =>
isTab(v) ? v : "overview"
@@ -40,40 +41,47 @@ export function ChildDetailPanel({
initialTab?: string
siblingSwitcher?: React.ReactNode
}) {
const t = useTranslations("parent")
const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child
const childName = basicInfo.name ?? "Child"
const childName = basicInfo.name ?? t("childDetail.defaultName")
const [tab, setTab] = useState<ChildDetailTab>(resolveTab(initialTab))
const tabs = useMemo(
() => [
{ id: "overview" as const, label: "Overview", icon: ClipboardList },
{ id: "homework" as const, label: "Homework", icon: ClipboardList },
{ id: "grades" as const, label: "Grades", icon: BarChart3 },
{ id: "exams" as const, label: "Exams", icon: GraduationCap },
{ id: "schedule" as const, label: "Schedule", icon: CalendarDays },
{ id: "attendance" as const, label: "Attendance", icon: CalendarDays },
{ id: "diagnostic" as const, label: "Diagnostic", icon: Stethoscope },
{ id: "overview" as const, label: t("childDetail.tabs.overview"), icon: ClipboardList },
{ id: "homework" as const, label: t("childDetail.tabs.homework"), icon: ClipboardList },
{ id: "grades" as const, label: t("childDetail.tabs.grades"), icon: BarChart3 },
{ id: "exams" as const, label: t("childDetail.tabs.exams"), icon: GraduationCap },
{ id: "schedule" as const, label: t("childDetail.tabs.schedule"), icon: CalendarDays },
{ id: "attendance" as const, label: t("childDetail.tabs.attendance"), icon: CalendarDays },
{ id: "diagnostic" as const, label: t("childDetail.tabs.diagnostic"), icon: Stethoscope },
],
[],
[t],
)
return (
<div className="space-y-6">
{siblingSwitcher}
<Tabs value={tab} onValueChange={(v) => setTab(v as ChildDetailTab)} className="w-full">
<Tabs
value={tab}
onValueChange={(v) => {
if (isTab(v)) setTab(v)
}}
className="w-full"
>
<div className="overflow-x-auto">
<TabsList className="w-full justify-start">
{tabs.map((t) => (
{tabs.map((tab) => (
<TabsTrigger
key={t.id}
value={t.id}
key={tab.id}
value={tab.id}
className="gap-1.5"
aria-label={`${t.label} tab`}
aria-label={t("childDetail.tabAriaLabel", { label: tab.label })}
>
<t.icon className="h-3.5 w-3.5" />
{t.label}
<tab.icon className="h-3.5 w-3.5" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
@@ -108,7 +116,7 @@ export function ChildDetailPanel({
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
<div>
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
Subject Analysis
{t("childDetail.subjectAnalysis")}
</h3>
<ChildGradeDetail grades={gradeTrend} />
</div>
@@ -134,14 +142,16 @@ export function ChildDetailPanel({
<TabsContent value="attendance" className="mt-6">
<div className="rounded-md border bg-muted/30 p-6 text-center">
<p className="text-sm text-muted-foreground">
Attendance details are available on the{" "}
<a
href="/parent/attendance"
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Attendance page
</a>
.
{t.rich("childDetail.attendanceHint", {
link: (chunks) => (
<a
href="/parent/attendance"
className="font-medium text-foreground underline-offset-4 hover:underline"
>
{chunks}
</a>
),
})}
</p>
</div>
</TabsContent>
@@ -149,7 +159,7 @@ export function ChildDetailPanel({
<TabsContent value="diagnostic" className="mt-6">
<div className="rounded-md border bg-muted/30 p-6 text-center">
<p className="text-sm text-muted-foreground">
Diagnostic reports will be available here once published by the school.
{t("childDetail.diagnosticHint")}
</p>
</div>
</TabsContent>
@@ -157,9 +167,12 @@ export function ChildDetailPanel({
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm" className="gap-2">
<a href={`/messages?studentId=${basicInfo.id}`} aria-label={`Contact teacher about ${childName}`}>
<a
href={`/messages?studentId=${basicInfo.id}`}
aria-label={t("childDetail.contactTeacherAria", { name: childName })}
>
<Mail className="h-4 w-4" />
Contact Teacher
{t("childDetail.contactTeacher")}
</a>
</Button>
</div>
@@ -175,21 +188,24 @@ export function SiblingSwitcher({
current: { id: string; name: string | null }
siblings: Array<{ id: string; name: string | null }>
}) {
const t = useTranslations("parent")
if (siblings.length <= 1) return null
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">Switch child</span>
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">
{t("childDetail.switchChild")}
</span>
<div className="flex flex-wrap gap-1">
{siblings.map((s) => {
const isActive = s.id === current.id
const label = s.name ?? "Child"
const label = s.name ?? t("childDetail.defaultName")
return (
<a
key={s.id}
href={`/parent/children/${s.id}`}
aria-current={isActive ? "page" : undefined}
aria-label={`View ${label}'s details`}
aria-label={t("childDetail.viewDetailsAria", { name: label })}
className={cn(
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",

View File

@@ -1,6 +1,7 @@
import type { JSX } from "react"
import Link from "next/link"
import { GraduationCap, TrendingUp, Award, BookOpen } from "lucide-react"
import { useTranslations } from "next-intl"
import { GraduationCap, TrendingUp, Award, BookOpen, ChevronRight } from "lucide-react"
import {
Card,
CardContent,
@@ -32,14 +33,19 @@ interface ChildExamDetailProps {
}
/**
* V3-11: 家长端子女考试详情视图
* V3-11 / P3-4: 家长端子女考试详情视图
*
* 对标智学网家长端,展示:
* - 考试成绩汇总卡片(已参加考试数、平均分、最高分)
* - 考试成绩列表(考试标题、分数、得分率、提交时间)
* - 成绩趋势可视化
*
* P3-4 改进:
* - 所有用户可见文本使用 i18n 翻译键next-intl
* - 触控目标 ≥ 44pxmin-h-[44px]
* - 语义化标签 + ARIA 属性
*/
export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element {
const t = useTranslations("examHomework")
const hasResults = examResults.length > 0
const examCount = examResults.length
@@ -60,33 +66,39 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<GraduationCap className="h-5 w-5 text-primary" />
<GraduationCap className="h-5 w-5 text-primary" aria-hidden />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{examCount}</p>
<p className="text-xs text-muted-foreground">Exams Taken</p>
<p className="text-xs text-muted-foreground">
{t("homework.parentExam.examsTaken")}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
<TrendingUp className="h-5 w-5 text-blue-500" />
<TrendingUp className="h-5 w-5 text-blue-500" aria-hidden />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">Average Score</p>
<p className="text-xs text-muted-foreground">
{t("homework.parentExam.averageScore")}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
<Award className="h-5 w-5 text-green-500" />
<Award className="h-5 w-5 text-green-500" aria-hidden />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">Best Score</p>
<p className="text-xs text-muted-foreground">
{t("homework.parentExam.bestScore")}
</p>
</div>
</CardContent>
</Card>
@@ -97,54 +109,60 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden />
{childName}&apos;s Exam Results
{t("homework.parentExam.examResults", { name: childName })}
</CardTitle>
<CardDescription>Recent exam scores and performance trends</CardDescription>
<CardDescription>
{t("homework.parentExam.examResultsDescription")}
</CardDescription>
</CardHeader>
<CardContent>
{!hasResults ? (
<EmptyState
icon={GraduationCap}
title="No exam results"
description="Exam results will appear here once available."
title={t("homework.parentExam.noResults")}
description={t("homework.parentExam.noResultsHint")}
className="border-none h-48"
/>
) : (
<div className="space-y-3">
<ul className="space-y-3" aria-label={t("homework.parentExam.examResults", { name: childName })}>
{examResults.map((r) => {
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
const isPass = scoreRate >= 60
return (
<Link
key={r.submissionId}
href={`/parent/children/${childId}?tab=grades`}
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium text-sm truncate">{r.examTitle}</div>
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
{isPass ? "Pass" : "Below 60%"}
</Badge>
<li key={r.submissionId}>
<Link
href={`/parent/children/${childId}?tab=grades`}
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium text-sm truncate">{r.examTitle}</div>
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
{isPass ? t("homework.parentExam.pass") : t("homework.parentExam.belowPass")}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{r.submittedAt ? (
<span>{formatDate(r.submittedAt)}</span>
) : null}
<span aria-hidden="true"></span>
<span className="tabular-nums">
{r.score} / {r.maxScore}
</span>
</div>
<Progress value={scoreRate} className="h-1.5 mt-1" />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{r.submittedAt ? (
<span>{formatDate(r.submittedAt)}</span>
) : null}
<span aria-hidden="true"></span>
<span className="tabular-nums">
{r.score} / {r.maxScore}
<div className="flex items-center gap-2 shrink-0 ml-2">
<span className="text-sm font-semibold tabular-nums">
{scoreRate.toFixed(0)}%
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden />
</div>
<Progress value={scoreRate} className="h-1.5 mt-1" />
</div>
<div className="text-sm font-semibold tabular-nums shrink-0 ml-2">
{scoreRate.toFixed(0)}%
</div>
</Link>
</Link>
</li>
)
})}
</div>
</ul>
)}
</CardContent>
</Card>

View File

@@ -2,14 +2,14 @@
import { ChevronLeft, ChevronRight } from "lucide-react"
import { useState, useRef, type KeyboardEvent } from "react"
import { useTranslations } from "next-intl"
import { useTranslations, useLocale } 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"
} from "@/shared/constants/attendance-status"
import type {
ParentAttendanceListItem,
ParentAttendanceStatus,
@@ -86,6 +86,7 @@ export function ParentAttendanceCalendar({
summary: ParentStudentAttendanceSummary
}) {
const t = useTranslations("attendance")
const locale = useLocale()
const now = new Date()
const [viewYear, setViewYear] = useState(now.getFullYear())
const [viewMonth, setViewMonth] = useState(now.getMonth())
@@ -99,7 +100,7 @@ export function ParentAttendanceCalendar({
}
const days = buildCalendarDays(viewYear, viewMonth)
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", {
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString(locale, {
year: "numeric",
month: "long",
})

View File

@@ -1,7 +1,5 @@
"use client"
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react"
import { useTranslations } from "next-intl"
import { getTranslations } from "next-intl/server"
import { Card } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
@@ -50,15 +48,17 @@ const TONE_STYLES: Record<"good" | "warn" | "bad", string> = {
}
/**
* 家长考勤页顶部的出勤率汇总卡片。
* 家长考勤页顶部的出勤率汇总卡片RSC
* 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。
*
* P2-8 修复:从 client component 降级为 RSC使用 `getTranslations`。
*/
export function ParentAttendanceRateCard({
export async function ParentAttendanceRateCard({
summaries,
}: {
summaries: ParentStudentAttendanceSummary[]
}) {
const t = useTranslations("attendance")
const t = await getTranslations("attendance")
const stats = aggregateStats(summaries)
if (stats.totalStudents === 0) return null

View File

@@ -1,7 +1,5 @@
"use client"
import { AlertTriangle, Phone } from "lucide-react"
import { useTranslations } from "next-intl"
import { getTranslations } from "next-intl/server"
import { Card } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
@@ -14,8 +12,11 @@ type Warning = {
severity: "high" | "medium"
}
/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */
type Translator = ReturnType<typeof useTranslations>
/** 翻译函数类型(与 useTranslations / getTranslations 返回值兼容) */
type Translator = (
key: string,
values?: Record<string, string | number | Date>,
) => string
/**
* 构建考勤异常预警列表(纯函数,便于测试)。
@@ -67,15 +68,17 @@ export function buildWarnings(
}
/**
* 家长视角的考勤异常预警横幅。
* 家长视角的考勤异常预警横幅RSC
* 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。
*
* P2-8 修复:从 client component 降级为 RSC使用 `getTranslations`。
*/
export function ParentAttendanceWarning({
export async function ParentAttendanceWarning({
summaries,
}: {
summaries: ParentStudentAttendanceSummary[]
}) {
const t = useTranslations("attendance")
const t = await getTranslations("attendance")
const warnings = buildWarnings(summaries, t)
if (warnings.length === 0) return null

View File

@@ -1,107 +0,0 @@
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import {
CalendarCheck,
CalendarDays,
GraduationCap,
Megaphone,
Users,
} from "lucide-react"
import { Card, CardContent } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { ParentDashboardData } from "@/modules/parent/types"
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
import { ChildCard } from "./child-card"
import { ParentAttentionBanner } from "./parent-attention-banner"
export async function ParentDashboard({ data }: { data: ParentDashboardData }) {
const t = await getTranslations("dashboard")
const { parentName, children } = data
const hasChildren = children.length > 0
const greetingKey = getGreetingKey(new Date())
const QUICK_ENTRIES = [
{ href: "/parent/grades", label: t("quickActions.grades"), icon: GraduationCap },
{ href: "/parent/attendance", label: t("quickActions.attendance"), icon: CalendarCheck },
{ href: "/announcements", label: t("quickActions.announcements"), icon: Megaphone },
{ href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays },
] as const
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
<div className="text-sm text-muted-foreground">
{t(`greeting.${greetingKey}`)}
{parentName ? `, ${parentName}` : ""}. {t("description.parent")}
</div>
</div>
{hasChildren ? (
<>
<ParentAttentionBanner data={data} />
<nav
aria-label={t("quickActions.announcements")}
className="grid grid-cols-2 gap-3 sm:grid-cols-4"
>
{QUICK_ENTRIES.map((entry) => (
<Link
key={entry.href}
href={entry.href}
className="group"
aria-label={entry.label}
>
<Card className="h-full transition-colors hover:bg-muted/50 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<CardContent className="flex flex-col items-center justify-center gap-2 p-4 text-center">
<entry.icon
className="h-6 w-6 text-muted-foreground group-hover:text-foreground"
aria-hidden
/>
<span className="text-sm font-medium">{entry.label}</span>
</CardContent>
</Card>
</Link>
))}
</nav>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" aria-hidden />
<span>
{t("badge.childrenLinked", { count: children.length })}
</span>
</div>
{/* 移动端水平滑动卡片,桌面端网格布局 */}
<div
className="flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory sm:hidden"
aria-label={t("title.parent")}
>
{children.map((child) => (
<div key={child.basicInfo.id} className="snap-start shrink-0 w-[85%]">
<ChildCard child={child} />
</div>
))}
</div>
<div className="hidden sm:grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{children.map((child) => (
<ChildCard key={child.basicInfo.id} child={child} />
))}
</div>
</>
) : (
<EmptyState
icon={Users}
title={t("empty.noChildren")}
description={t("empty.noChildrenDesc")}
className="border-none shadow-none"
action={{
label: t("empty.contactSupport"),
href: "/messages",
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,163 @@
import { useTranslations } from "next-intl"
import {
CalendarCheck,
CheckCircle2,
Clock,
TrendingUp,
Users,
XCircle,
Inbox,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatItem } from "@/shared/components/ui/stat-item"
import {
ATTENDANCE_STATUS_BADGE_VARIANTS,
ATTENDANCE_STATUS_LABEL_KEYS,
} from "@/shared/constants/attendance-status"
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
/**
* 家长视角的单个子女考勤详情组件(统计卡片 + 最近记录表)。
*
* 解耦说明P1-2 修复):
* - 此组件替代原先从 `@/modules/attendance/components/student-attendance-view` 直接导入的 `StudentAttendanceView`。
* - parent 模块不再依赖 attendance 模块的 UI 组件,仅依赖自身类型与 shared 常量。
* - 数据通过 props 注入(由 parent page 通过 `AttendanceReadService` 接口获取后传入)。
*/
export function ParentStudentAttendanceDetail({
summary,
}: {
summary: ParentStudentAttendanceSummary
}) {
const t = useTranslations("attendance")
const { stats, recentRecords } = summary
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("list.columns.student")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentName}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("stats.totalRecords")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stats.total}</p>
</CardContent>
</Card>
</div>
{stats.total === 0 ? (
<EmptyState
title={t("stats.noData")}
description={t("stats.noDataDescription")}
icon={CalendarCheck}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>{t("title.teacherStats")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label={t("stats.totalRecords")}
value={stats.total}
icon={<Users className="h-4 w-4" />}
/>
<StatItem
label={t("stats.present")}
value={stats.present}
icon={<CheckCircle2 className="h-4 w-4" />}
/>
<StatItem
label={t("stats.absent")}
value={stats.absent}
icon={<XCircle className="h-4 w-4" />}
/>
<StatItem
label={t("stats.late")}
value={stats.late}
icon={<Clock className="h-4 w-4" />}
/>
<StatItem
label={t("stats.attendanceRate")}
value={`${stats.presentRate.toFixed(1)}%`}
icon={<TrendingUp className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
)}
{recentRecords.length === 0 ? (
<EmptyState
title={t("list.empty")}
description={t("list.emptyDescription")}
icon={Inbox}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>{t("stats.recentRecords")}</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("list.columns.date")}</TableHead>
<TableHead>{t("list.columns.status")}</TableHead>
<TableHead>{t("list.columns.remark")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentRecords.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.date}</TableCell>
<TableCell>
<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>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
)
}