完整性更新
现在已经实现了大部分基础功能
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { formatDate } from "@/shared/lib/utils";
|
||||
import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
|
||||
|
||||
export function RecentSubmissions({ submissions }: { submissions: HomeworkSubmissionListItem[] }) {
|
||||
const hasSubmissions = submissions.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="col-span-4 lg:col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4 text-muted-foreground" />
|
||||
Recent Submissions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasSubmissions ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No New Submissions"
|
||||
description="All caught up! There are no new submissions to review."
|
||||
action={{ label: "View submissions", href: "/teacher/homework/submissions" }}
|
||||
className="border-none h-[300px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{submissions.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between group">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={undefined} alt={item.studentName} />
|
||||
<AvatarFallback>{item.studentName.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{item.studentName}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Link
|
||||
href={`/teacher/homework/submissions/${item.id}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{item.assignmentTitle}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{item.submittedAt ? formatDate(item.submittedAt) : "-"}
|
||||
</div>
|
||||
{item.isLate && (
|
||||
<span className="inline-flex items-center rounded-full border border-destructive px-2 py-0.5 text-xs font-semibold text-destructive transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
Late
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Link from "next/link"
|
||||
import { Users } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import type { TeacherClass } from "@/modules/classes/types"
|
||||
|
||||
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
|
||||
const topClassesByStudents = [...classes].sort((a, b) => (b.studentCount ?? 0) - (a.studentCount ?? 0)).slice(0, 8)
|
||||
const maxStudentCount = Math.max(1, ...topClassesByStudents.map((c) => c.studentCount ?? 0))
|
||||
|
||||
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
|
||||
</CardTitle>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/classes/my">View all</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" }}
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{topClassesByStudents.length > 0 ? (
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Students by class</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">Total {totalStudents}</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{topClassesByStudents.map((c) => {
|
||||
const count = c.studentCount ?? 0
|
||||
const pct = Math.max(0, Math.min(100, (count / maxStudentCount) * 100))
|
||||
return (
|
||||
<div key={c.id} className="grid grid-cols-[minmax(0,1fr)_120px_52px] items-center gap-3">
|
||||
<div className="truncate text-sm">{c.name}</div>
|
||||
<div className="h-2 rounded-full bg-muted">
|
||||
<div className="h-2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="text-right text-xs tabular-nums text-muted-foreground">{count}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{classes.slice(0, 6).map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/teacher/classes/my/${encodeURIComponent(c.id)}`}
|
||||
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{c.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{c.grade}
|
||||
{c.homeroom ? ` · ${c.homeroom}` : ""}
|
||||
{c.room ? ` · ${c.room}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{c.studentCount} students
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||
|
||||
export function TeacherDashboardHeader() {
|
||||
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">Teacher</h2>
|
||||
<p className="text-muted-foreground">Overview of today's work and your classes.</p>
|
||||
</div>
|
||||
<TeacherQuickActions />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { TeacherDashboardData, TeacherTodayScheduleItem } from "@/modules/dashboard/types"
|
||||
|
||||
import { TeacherClassesCard } from "./teacher-classes-card"
|
||||
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
|
||||
import { TeacherHomeworkCard } from "./teacher-homework-card"
|
||||
import { RecentSubmissions } from "./recent-submissions"
|
||||
import { TeacherSchedule } from "./teacher-schedule"
|
||||
import { TeacherStats } from "./teacher-stats"
|
||||
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
const day = d.getDay()
|
||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
}
|
||||
|
||||
export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
||||
const totalStudents = data.classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
|
||||
const todayWeekday = toWeekday(new Date())
|
||||
|
||||
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))
|
||||
const todayScheduleItems: TeacherTodayScheduleItem[] = data.schedule
|
||||
.filter((s) => s.weekday === todayWeekday)
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
.map((s): TeacherTodayScheduleItem => ({
|
||||
id: s.id,
|
||||
classId: s.classId,
|
||||
className: classNameById.get(s.classId) ?? "Class",
|
||||
course: s.course,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
location: s.location ?? null,
|
||||
}))
|
||||
|
||||
const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt))
|
||||
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
||||
const recentSubmissions = submittedSubmissions.slice(0, 6)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<TeacherDashboardHeader />
|
||||
|
||||
<TeacherStats
|
||||
totalStudents={totalStudents}
|
||||
classCount={data.classes.length}
|
||||
toGradeCount={toGradeCount}
|
||||
todayScheduleCount={todayScheduleItems.length}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<TeacherSchedule items={todayScheduleItems} />
|
||||
<RecentSubmissions submissions={recentSubmissions} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TeacherClassesCard classes={data.classes} />
|
||||
<TeacherHomeworkCard assignments={data.assignments} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Link from "next/link"
|
||||
import { PenTool } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||
|
||||
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||
Homework
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/homework/assignments">Open list</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/teacher/homework/assignments/create">New</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{assignments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PenTool}
|
||||
title="No homework assignments yet"
|
||||
description="Create an assignment from an exam and publish it to students."
|
||||
action={{ label: "Create assignment", href: "/teacher/homework/assignments/create" }}
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
assignments.slice(0, 6).map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/teacher/homework/assignments/${encodeURIComponent(a.id)}`}
|
||||
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{a.title}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">{a.sourceExamTitle}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{a.status}
|
||||
</Badge>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { PlusCircle, CheckSquare, Users } from "lucide-react";
|
||||
|
||||
export function TeacherQuickActions() {
|
||||
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
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/homework/submissions">
|
||||
<CheckSquare className="mr-2 h-4 w-4" />
|
||||
Grade
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/classes/my">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
My Classes
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Clock, MapPin, CalendarDays, CalendarX } from "lucide-react";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
|
||||
type TeacherTodayScheduleItem = {
|
||||
id: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
course: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
};
|
||||
|
||||
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
||||
const hasSchedule = items.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||
Today's Schedule
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasSchedule ? (
|
||||
<EmptyState
|
||||
icon={CalendarX}
|
||||
title="No Classes Today"
|
||||
description="No timetable entries for today."
|
||||
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
|
||||
className="border-none h-[300px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
href={`/teacher/classes/schedule?classId=${encodeURIComponent(item.classId)}`}
|
||||
className="font-medium leading-none hover:underline"
|
||||
>
|
||||
{item.course}
|
||||
</Link>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<span className="mr-3">{item.startTime}–{item.endTime}</span>
|
||||
{item.location ? (
|
||||
<>
|
||||
<MapPin className="mr-1 h-3 w-3" />
|
||||
<span>{item.location}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{item.className}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Users, BookOpen, FileCheck, Calendar } from "lucide-react";
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
|
||||
interface TeacherStatsProps {
|
||||
totalStudents: number;
|
||||
classCount: number;
|
||||
toGradeCount: number;
|
||||
todayScheduleCount: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function TeacherStats({
|
||||
totalStudents,
|
||||
classCount,
|
||||
toGradeCount,
|
||||
todayScheduleCount,
|
||||
isLoading = false,
|
||||
}: TeacherStatsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-[60px] mb-2" />
|
||||
<Skeleton className="h-3 w-[140px]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Total Students",
|
||||
value: String(totalStudents),
|
||||
description: "Across all your classes",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "My Classes",
|
||||
value: String(classCount),
|
||||
description: "Active classes you manage",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
title: "To Grade",
|
||||
value: String(toGradeCount),
|
||||
description: "Submitted homework waiting for grading",
|
||||
icon: FileCheck,
|
||||
},
|
||||
{
|
||||
title: "Today",
|
||||
value: String(todayScheduleCount),
|
||||
description: "Scheduled items today",
|
||||
icon: Calendar,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stat.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user