refactor(dashboard): V2 审计重构 — i18n 补齐 + 共享抽象 + 单测 + a11y
V2 审计报告(docs/architecture/audit/dashboard-audit-report-v2.md)发现并修复: - P0 i18n:10 个子组件硬编码字符串全部接入 next-intl(teacher-quick-actions / teacher-classes-card / teacher-homework-card / teacher-schedule / recent-submissions / teacher-grade-trends / student-grades-card / student-today-schedule-card / student-upcoming-assignments-card / admin-dashboard),新增 ~50 个翻译键 - P1 共享抽象:新增 DashboardGreetingHeader 组件,消除 teacher/student 头部 90% 重复代码,两个 Header 改为薄包装 - P2 单测:为 6 个纯函数添加 31 个单元测试 (tests/integration/dashboard/dashboard-utils.test.ts) - P2 a11y:admin 表格 caption、teacher/student 视图语义化标签 (header / section aria-label / aside aria-label) - 同步架构图 004/005
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { Inbox, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Inbox, ArrowRight } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -13,54 +14,57 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table";
|
||||
import { formatDate } from "@/shared/lib/utils";
|
||||
import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
|
||||
} from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { HomeworkSubmissionListItem } from "@/modules/homework/types"
|
||||
|
||||
export function RecentSubmissions({
|
||||
submissions,
|
||||
title = "Recent Submissions",
|
||||
emptyTitle = "No New Submissions",
|
||||
emptyDescription = "All caught up! There are no new submissions to review."
|
||||
}: {
|
||||
submissions: HomeworkSubmissionListItem[],
|
||||
title?: string,
|
||||
emptyTitle?: string,
|
||||
interface RecentSubmissionsProps {
|
||||
submissions: HomeworkSubmissionListItem[]
|
||||
title?: string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}) {
|
||||
const hasSubmissions = submissions.length > 0;
|
||||
}
|
||||
|
||||
export async function RecentSubmissions({
|
||||
submissions,
|
||||
title,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
}: RecentSubmissionsProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasSubmissions = submissions.length > 0
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
{title}
|
||||
{title ?? t("sections.recentSubmissions")}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-primary" asChild>
|
||||
<Link href="/teacher/homework/submissions" className="flex items-center gap-1">
|
||||
View All <ArrowRight className="h-3 w-3" />
|
||||
{t("quickActions.viewAllSubmissions")} <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
{!hasSubmissions ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={{ label: "View submissions", href: "/teacher/homework/submissions" }}
|
||||
className="border-none h-full min-h-[200px]"
|
||||
/>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title={emptyTitle ?? t("empty.noNewSubmissions")}
|
||||
description={emptyDescription ?? t("empty.noNewSubmissionsDesc")}
|
||||
action={{ label: t("quickActions.viewAllSubmissions"), href: "/teacher/homework/submissions" }}
|
||||
className="border-none h-full min-h-[200px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[200px]">Student</TableHead>
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead className="w-[140px]">Submitted</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Action</TableHead>
|
||||
<TableHead className="w-[200px]">{t("table.student")}</TableHead>
|
||||
<TableHead>{t("table.assignment")}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("table.submitted")}</TableHead>
|
||||
<TableHead className="w-[100px] text-right">{t("table.action")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -78,7 +82,7 @@ export function RecentSubmissions({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
<Link
|
||||
href={`/teacher/homework/submissions/${item.id}`}
|
||||
className="font-medium hover:text-primary hover:underline transition-colors block truncate max-w-[240px]"
|
||||
title={item.assignmentTitle}
|
||||
@@ -93,7 +97,7 @@ export function RecentSubmissions({
|
||||
</span>
|
||||
{item.isLate && (
|
||||
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
|
||||
Late
|
||||
{t("badge.late")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -101,7 +105,7 @@ export function RecentSubmissions({
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="secondary" className="h-8 px-3" asChild>
|
||||
<Link href={`/teacher/homework/submissions/${item.id}`}>
|
||||
Grade
|
||||
{t("action.grade")}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
@@ -113,5 +117,5 @@ export function RecentSubmissions({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import Link from "next/link"
|
||||
import { Users } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
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 type { TeacherClass } from "@/modules/classes/types"
|
||||
|
||||
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
export async function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
My Classes
|
||||
{t("sections.myClasses")}
|
||||
</CardTitle>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/classes/my">View all</Link>
|
||||
<Link href="/teacher/classes/my">{t("quickActions.viewAll")}</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{classes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No classes yet"
|
||||
description="Create a class to start managing students and schedules."
|
||||
action={{ label: "Create class", href: "/teacher/classes/my" }}
|
||||
title={t("empty.noClassesYet")}
|
||||
description={t("empty.noClassesDesc")}
|
||||
action={{ label: t("quickActions.createClass"), href: "/teacher/classes/my" }}
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
@@ -44,13 +47,13 @@ export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
{c.homeroom && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>Homeroom: {c.homeroom}</span>
|
||||
<span>{t("schedule.homeroom")}: {c.homeroom}</span>
|
||||
</>
|
||||
)}
|
||||
{c.room && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>Room {c.room}</span>
|
||||
<span>{t("schedule.room")} {c.room}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { formatLongDate } from "@/shared/lib/utils"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||
|
||||
interface TeacherDashboardHeaderProps {
|
||||
@@ -10,19 +8,10 @@ interface TeacherDashboardHeaderProps {
|
||||
}
|
||||
|
||||
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
|
||||
const t = useTranslations("dashboard")
|
||||
const today = formatLongDate(new Date())
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t(`greeting.${greetingKey}`)},{teacherName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||
</div>
|
||||
<TeacherQuickActions />
|
||||
</div>
|
||||
<DashboardGreetingHeader
|
||||
userName={teacherName}
|
||||
actions={<TeacherQuickActions />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||
<header>
|
||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||
</header>
|
||||
|
||||
<DashboardSection variant="stats">
|
||||
<TeacherStats
|
||||
@@ -57,8 +59,7 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
||||
</DashboardSection>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
{/* 移动端优先展示:今日课表 → 待办 → 待批改 */}
|
||||
<div className="flex flex-col gap-6 lg:col-span-8">
|
||||
<section aria-label={t("sections.pendingGrading")} className="flex flex-col gap-6 lg:col-span-8">
|
||||
<div className="lg:hidden">
|
||||
<DashboardSection variant="card">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
@@ -78,9 +79,9 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
||||
emptyDescription={t("empty.allGradedDesc")}
|
||||
/>
|
||||
</DashboardSection>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:col-span-4">
|
||||
<aside aria-label={t("sections.myClasses")} className="flex flex-col gap-6 lg:col-span-4">
|
||||
<div className="hidden lg:block">
|
||||
<DashboardSection variant="card">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
@@ -92,7 +93,7 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
||||
<DashboardSection variant="list">
|
||||
<TeacherClassesCard classes={data.classes} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { TrendingUp } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
import type { TeacherGradeTrendItem } from "@/modules/homework/types"
|
||||
|
||||
export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] }) {
|
||||
const t = useTranslations("dashboard")
|
||||
const hasTrends = trends.length > 0
|
||||
|
||||
const chartData = trends.map((item) => {
|
||||
@@ -22,14 +24,14 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title="Class Performance"
|
||||
description={`Average scores for the last ${trends.length} assignments`}
|
||||
title={t("sections.classPerformance")}
|
||||
description={t("chart.classPerformanceDesc", { count: trends.length })}
|
||||
icon={TrendingUp}
|
||||
iconClassName="text-primary"
|
||||
titleClassName="text-base font-medium"
|
||||
isEmpty={!hasTrends}
|
||||
emptyTitle="No data available"
|
||||
emptyDescription="Publish assignments to see class performance trends."
|
||||
emptyTitle={t("empty.noData")}
|
||||
emptyDescription={t("empty.noDataDesc")}
|
||||
emptyClassName="h-[200px] p-0"
|
||||
className="col-span-1"
|
||||
>
|
||||
@@ -39,7 +41,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
||||
series={[
|
||||
{
|
||||
dataKey: "score",
|
||||
name: "Average Score (%)",
|
||||
name: t("chart.averageScorePercent"),
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 4,
|
||||
activeDotRadius: 6,
|
||||
@@ -65,7 +67,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
||||
<span className="text-xl font-bold tabular-nums">{item.score}%</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{item.submissionCount}/{item.totalStudents} submitted
|
||||
{t("chart.submittedCount", { submitted: item.submissionCount, total: item.totalStudents })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { PenTool, Calendar, Plus } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -8,16 +9,18 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||
|
||||
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
||||
export async function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||
Homework
|
||||
{t("sections.homework")}
|
||||
</CardTitle>
|
||||
<Button asChild size="icon" variant="ghost" className="h-8 w-8">
|
||||
<Link href="/teacher/homework/assignments/create" title="Create new assignment">
|
||||
<Button asChild size="icon" variant="ghost" className="h-8 w-8" title={t("quickActions.createNewAssignment")}>
|
||||
<Link href="/teacher/homework/assignments/create">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -26,9 +29,9 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
{assignments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PenTool}
|
||||
title="No assignments"
|
||||
description="Create an assignment to get started."
|
||||
action={{ label: "Create", href: "/teacher/homework/assignments/create" }}
|
||||
title={t("empty.noAssignments")}
|
||||
description={t("empty.noAssignmentsDesc")}
|
||||
action={{ label: t("quickActions.create"), href: "/teacher/homework/assignments/create" }}
|
||||
className="border-none h-48"
|
||||
/>
|
||||
) : (
|
||||
@@ -36,7 +39,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
{assignments.slice(0, 6).map((a) => {
|
||||
const isPublished = a.status === "published"
|
||||
const isDraft = a.status === "draft"
|
||||
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={a.id}
|
||||
@@ -47,7 +50,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<div className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
isPublished ? "bg-emerald-500" :
|
||||
isPublished ? "bg-emerald-500" :
|
||||
isDraft ? "bg-amber-400" : "bg-muted-foreground"
|
||||
)} />
|
||||
<div className="font-medium truncate text-sm group-hover:text-primary transition-colors">
|
||||
@@ -58,7 +61,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
{a.sourceExamTitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{a.dueAt ? (
|
||||
<div className="flex items-center text-xs text-muted-foreground tabular-nums">
|
||||
@@ -66,10 +69,10 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
{formatDate(a.dueAt)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground italic">No due date</span>
|
||||
<span className="text-[10px] text-muted-foreground italic">{t("schedule.noDueDate")}</span>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] h-4 px-1.5 capitalize font-normal border-transparent bg-muted/50",
|
||||
isPublished && "text-emerald-600 bg-emerald-500/10",
|
||||
@@ -84,7 +87,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
})}
|
||||
<div className="pt-2">
|
||||
<Button asChild variant="link" size="sm" className="w-full text-muted-foreground h-auto py-1 text-xs">
|
||||
<Link href="/teacher/homework/assignments">View all assignments</Link>
|
||||
<Link href="/teacher/homework/assignments">{t("quickActions.viewAllAssignments")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import Link from "next/link";
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { PlusCircle, CheckSquare, Users } from "lucide-react";
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, CheckSquare, Users } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export function TeacherQuickActions() {
|
||||
const t = useTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/teacher/homework/assignments/create">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Create Assignment
|
||||
{t("quickActions.createAssignment")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/homework/submissions">
|
||||
<CheckSquare className="mr-2 h-4 w-4" />
|
||||
Grade
|
||||
{t("quickActions.grade")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/classes/my">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
My Classes
|
||||
{t("quickActions.myClasses")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,150 +1,147 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { CalendarDays, CalendarX, MapPin } from "lucide-react";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { CalendarDays, CalendarX, MapPin } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
|
||||
type TeacherTodayScheduleItem = {
|
||||
id: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
course: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
};
|
||||
id: string
|
||||
classId: string
|
||||
className: string
|
||||
course: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
location: string | null
|
||||
}
|
||||
|
||||
export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasSchedule = items.length > 0
|
||||
|
||||
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
||||
const hasSchedule = items.length > 0;
|
||||
|
||||
const getStatus = (start: string, end: string) => {
|
||||
const now = new Date();
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const [startH, startM] = start.split(":").map(Number);
|
||||
const [endH, endM] = end.split(":").map(Number);
|
||||
const startTime = startH * 60 + startM;
|
||||
const endTime = endH * 60 + endM;
|
||||
const now = new Date()
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes()
|
||||
|
||||
if (currentTime >= startTime && currentTime <= endTime) return "live";
|
||||
if (currentTime < startTime) return "upcoming";
|
||||
return "past";
|
||||
};
|
||||
const [startH, startM] = start.split(":").map(Number)
|
||||
const [endH, endM] = end.split(":").map(Number)
|
||||
const startTime = (startH ?? 0) * 60 + (startM ?? 0)
|
||||
const endTime = (endH ?? 0) * 60 + (endM ?? 0)
|
||||
|
||||
if (currentTime >= startTime && currentTime <= endTime) return "live"
|
||||
if (currentTime < startTime) return "upcoming"
|
||||
return "past"
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||
Today's Schedule
|
||||
{t("sections.todaySchedule")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{!hasSchedule ? (
|
||||
<EmptyState
|
||||
icon={CalendarX}
|
||||
title="No Classes Today"
|
||||
description="No timetable entries."
|
||||
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
|
||||
className="border-none h-[200px]"
|
||||
/>
|
||||
<EmptyState
|
||||
icon={CalendarX}
|
||||
title={t("empty.noClassesToday")}
|
||||
description={t("empty.noClassesTodayDesc")}
|
||||
action={{ label: t("quickActions.viewSchedule"), href: "/teacher/classes/schedule" }}
|
||||
className="border-none h-[200px]"
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea className="h-[240px] px-6 py-2">
|
||||
<div className="relative space-y-0 ml-1">
|
||||
{/* Vertical Timeline Line */}
|
||||
<div className="absolute left-[11px] -top-2 -bottom-2 w-px bg-border/50" />
|
||||
|
||||
{/* Top Fade Hint */}
|
||||
|
||||
<div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" />
|
||||
|
||||
{items.map((item, index) => {
|
||||
const status = getStatus(item.startTime, item.endTime);
|
||||
const isLive = status === "live";
|
||||
const isPast = status === "past";
|
||||
const isLast = index === items.length - 1;
|
||||
const status = getStatus(item.startTime, item.endTime)
|
||||
const isLive = status === "live"
|
||||
const isPast = status === "past"
|
||||
const isLast = index === items.length - 1
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
|
||||
{/* Timeline Dot */}
|
||||
<div className={cn(
|
||||
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
|
||||
isLive ? "bg-primary border-primary" :
|
||||
isPast ? "bg-muted border-muted-foreground/30" :
|
||||
"bg-background border-primary"
|
||||
)} />
|
||||
return (
|
||||
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
|
||||
<div className={cn(
|
||||
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
|
||||
isLive ? "bg-primary border-primary" :
|
||||
isPast ? "bg-muted border-muted-foreground/30" :
|
||||
"bg-background border-primary"
|
||||
)} />
|
||||
|
||||
<Link
|
||||
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
|
||||
className={cn(
|
||||
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
|
||||
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
|
||||
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
|
||||
"bg-card"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"font-medium text-sm truncate",
|
||||
isLive ? "text-primary" : "text-foreground"
|
||||
)}>
|
||||
{item.course}
|
||||
</span>
|
||||
{isLive && (
|
||||
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
|
||||
LIVE
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||
<span>{item.className}</span>
|
||||
{item.location && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center">
|
||||
<MapPin className="mr-0.5 h-2.5 w-2.5" />
|
||||
{item.location}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
|
||||
className={cn(
|
||||
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
|
||||
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
|
||||
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
|
||||
"bg-card"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"font-medium text-sm truncate",
|
||||
isLive ? "text-primary" : "text-foreground"
|
||||
)}>
|
||||
{item.course}
|
||||
</span>
|
||||
{isLive && (
|
||||
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
|
||||
{t("badge.live")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
|
||||
isLive ? "text-primary" : "text-muted-foreground"
|
||||
)}>
|
||||
{item.startTime}
|
||||
<span className="text-[10px] opacity-70 ml-0.5">– {item.endTime}</span>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||
<span>{item.className}</span>
|
||||
{item.location && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center">
|
||||
<MapPin className="mr-0.5 h-2.5 w-2.5" />
|
||||
{item.location}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Connection Line to Next (if not last) */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className={cn(
|
||||
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
|
||||
isLive ? "text-primary" : "text-muted-foreground"
|
||||
)}>
|
||||
{item.startTime}
|
||||
<span className="text-[10px] opacity-70 ml-0.5">– {item.endTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{!isLast && (
|
||||
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Bottom Hint */}
|
||||
|
||||
{items.length > 3 ? (
|
||||
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
|
||||
Scroll for more
|
||||
</div>
|
||||
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
|
||||
{t("schedule.scrollForMore")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
|
||||
No more classes today
|
||||
</div>
|
||||
<div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
|
||||
{t("schedule.noMoreClasses")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user