feat: enhance textbook reader with anchor text support and improve knowledge point management

This commit is contained in:
SpecialX
2026-01-16 10:22:16 +08:00
parent 9bfc621d3f
commit bb4555f611
44 changed files with 6284 additions and 2090 deletions

View File

@@ -1,259 +0,0 @@
import Link from "next/link"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { getClassHomeworkInsights, getTeacherClasses } from "@/modules/classes/data-access"
import { InsightsFilters } from "@/modules/classes/components/insights-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
function InsightsResultsFallback() {
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card">
<div className="p-6">
<Skeleton className="h-5 w-28" />
<Skeleton className="mt-3 h-8 w-20" />
</div>
</div>
))}
</div>
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}
async function InsightsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const classId = getParam(params, "classId")
if (!classId || classId === "all") {
return (
<EmptyState
icon={BarChart3}
title="Select a class to view insights"
description="Pick a class to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
)
}
const insights = await getClassHomeworkInsights({ classId, limit: 50 })
if (!insights) {
return (
<EmptyState
icon={BarChart3}
title="Class not found"
description="This class may not exist or is not accessible."
className="h-[360px] bg-card"
/>
)
}
const hasAssignments = insights.assignments.length > 0
if (!hasAssignments) {
return (
<EmptyState
icon={BarChart3}
title="No homework data for this class"
description="No homework assignments were targeted to students in this class yet."
className="h-[360px] bg-card"
/>
)
}
const latest = insights.latest
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.assignments.length}</div>
<div className="text-xs text-muted-foreground">Latest: {latest ? formatDate(latest.createdAt) : "-"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall scores</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
<div className="text-xs text-muted-foreground">
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
</div>
</CardContent>
</Card>
</div>
{latest && (
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-base">Latest assignment</CardTitle>
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{latest.title}</span>
<Badge variant="outline" className="capitalize">
{latest.status}
</Badge>
<span>·</span>
<span>{formatDate(latest.createdAt)}</span>
{latest.dueAt ? (
<>
<span>·</span>
<span>Due {formatDate(latest.dueAt)}</span>
</>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-5">
<div>
<div className="text-sm text-muted-foreground">Targeted</div>
<div className="text-lg font-semibold">{latest.targetCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold">{latest.submittedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{latest.gradedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Average</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Median</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
</div>
</CardContent>
</Card>
)}
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
<TableHead className="text-right">Min</TableHead>
<TableHead className="text-right">Max</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.min, 0)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.max, 0)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default async function ClassInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<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">Class Insights</h2>
<p className="text-muted-foreground">Latest homework and historical score statistics for a class.</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<InsightsFilters classes={classes} />
</Suspense>
<Suspense fallback={<InsightsResultsFallback />}>
<InsightsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -1,39 +1,18 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { BookOpen, Calendar, ChevronRight, Clock, Users } from "lucide-react"
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
import { ScheduleView } from "@/modules/classes/components/schedule-view"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { cn, formatDate } from "@/shared/lib/utils"
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
export default async function ClassDetailPage({
params,
searchParams,
@@ -42,335 +21,96 @@ export default async function ClassDetailPage({
searchParams: Promise<SearchParams>
}) {
const { id } = await params
const sp = await searchParams
const hw = getParam(sp, "hw")
const hwFilter = hw === "active" || hw === "overdue" ? hw : "all"
// Parallel data fetching
const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 50 }),
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
])
if (!insights) return notFound()
const latest = insights.latest
const filteredAssignments = insights.assignments.filter((a) => {
if (hwFilter === "all") return true
if (hwFilter === "overdue") return a.isOverdue
if (hwFilter === "active") return a.isActive
return true
})
const hasAssignments = filteredAssignments.length > 0
const scheduleBuilderClasses = [
{
id: insights.class.id,
name: insights.class.name,
grade: insights.class.grade,
homeroom: insights.class.homeroom ?? null,
room: insights.class.room ?? null,
studentCount: insights.studentCounts.total,
},
]
// Fetch subject scores
const studentScores = await getClassStudentSubjectScoresV2(id)
// Data mapping for widgets
const assignmentSummaries = insights.assignments.map(a => ({
id: a.assignmentId,
title: a.title,
status: a.status,
subject: a.subject,
isActive: a.isActive,
isOverdue: a.isOverdue,
dueAt: a.dueAt ? new Date(a.dueAt) : null,
submittedCount: a.submittedCount,
targetCount: a.targetCount,
avgScore: a.scoreStats.avg,
medianScore: a.scoreStats.median
}))
const studentSummaries = students.map(s => ({
id: s.id,
name: s.name,
email: s.email,
image: s.image,
status: s.status,
subjectScores: studentScores.get(s.id) ?? {}
}))
// Calculate advanced stats
const activeAssignments = insights.assignments.filter(a => a.isActive)
const papersToGrade = activeAssignments.reduce((acc, a) => acc + (a.submittedCount - a.gradedCount), 0)
const overdueCount = activeAssignments.filter(a => a.isOverdue).length
const totalSubmissionRate = activeAssignments.length > 0
? activeAssignments.reduce((acc, a) => acc + (a.targetCount > 0 ? a.submittedCount / a.targetCount : 0), 0) / activeAssignments.length
: 0
return (
<div className="flex flex-col min-h-full space-y-8 p-8">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<Link href="/teacher/classes/my" className="hover:text-foreground transition-colors">
My Classes
</Link>
<ChevronRight className="h-4 w-4" />
<span className="text-foreground font-medium">{insights.class.name}</span>
<div className="flex min-h-screen flex-col bg-muted/10">
<ClassHeader
classId={insights.class.id}
name={insights.class.name}
grade={insights.class.grade}
homeroom={insights.class.homeroom}
room={insights.class.room}
schoolName={insights.class.schoolName}
studentCount={insights.studentCounts.total}
/>
<div className="flex-1 space-y-6 p-6">
{/* Key Metrics */}
<ClassOverviewStats
averageScore={insights.overallScores.avg}
submissionRate={totalSubmissionRate * 100}
papersToGrade={papersToGrade}
overdueCount={overdueCount}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content Area (Left 2/3) */}
<div className="space-y-6 lg:col-span-2">
<ClassTrendsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
<ClassStudentsWidget
classId={insights.class.id}
students={studentSummaries}
/>
</div>
<h2 className="text-3xl font-bold tracking-tight">{insights.class.name}</h2>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Badge variant="secondary" className="rounded-sm font-normal">
{insights.class.grade}
</Badge>
{insights.class.homeroom && (
<>
<span className="w-1 h-1 rounded-full bg-border" />
<span>Homeroom: {insights.class.homeroom}</span>
</>
)}
{insights.class.room && (
<>
<span className="w-1 h-1 rounded-full bg-border" />
<span>Room: {insights.class.room}</span>
</>
)}
{/* Sidebar Area (Right 1/3) */}
<div className="space-y-6">
{/* <ClassQuickActions classId={insights.class.id} /> */}
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
<ClassAssignmentsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
<Users className="mr-2 h-4 w-4" />
Students
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
<Calendar className="mr-2 h-4 w-4" />
Schedule
</Link>
</Button>
<Button asChild>
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>
Create Homework
</Link>
</Button>
</div>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Students</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
{insights.studentCounts.active} active · {insights.studentCounts.inactive} inactive
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Schedule Items</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{schedule.length}</div>
<div className="text-xs text-muted-foreground">Weekly sessions</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Assignments</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{insights.assignments.filter((a) => a.isActive).length}
</div>
<div className="text-xs text-muted-foreground">
{insights.assignments.filter((a) => a.isOverdue).length} overdue
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Class Average</CardTitle>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}%</div>
<div className="text-xs text-muted-foreground">
Based on {insights.overallScores.count} graded submissions
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-7">
{/* Main Content Area */}
<div className="lg:col-span-4 space-y-6">
{/* Latest Homework */}
{latest && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle>Latest Homework</CardTitle>
<CardDescription>Most recent assignment activity</CardDescription>
</div>
<Badge variant={latest.isActive ? "default" : "secondary"}>
{latest.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Link
href={`/teacher/homework/assignments/${latest.assignmentId}`}
className="font-semibold hover:underline"
>
{latest.title}
</Link>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Due {latest.dueAt ? formatDate(latest.dueAt) : "No due date"}</span>
<span>·</span>
<span>{latest.submittedCount}/{latest.targetCount} Submitted</span>
</div>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>
Grade
</Link>
</Button>
</div>
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="text-center">
<div className="text-2xl font-bold">{latest.gradedCount}</div>
<div className="text-xs text-muted-foreground">Graded</div>
</div>
<div className="text-center border-l border-r">
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.avg, 1)}</div>
<div className="text-xs text-muted-foreground">Average</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.median, 1)}</div>
<div className="text-xs text-muted-foreground">Median</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Students Preview */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="space-y-1">
<CardTitle>Students</CardTitle>
<CardDescription>Recently active students</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No students enrolled yet.
</div>
) : (
<div className="space-y-4">
{students.slice(0, 5).map((s) => (
<div key={s.id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={s.image || undefined} />
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium text-sm">{s.name}</div>
<div className="text-xs text-muted-foreground">{s.email}</div>
</div>
</div>
<Badge variant={s.status === "active" ? "outline" : "secondary"} className="text-xs font-normal">
{s.status}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar Area */}
<div className="lg:col-span-3 space-y-6">
{/* Schedule Widget */}
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Schedule</CardTitle>
<Button variant="ghost" size="icon" asChild>
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
</CardContent>
</Card>
{/* Homework History */}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
<div className="flex gap-2 mt-2">
<Button
size="sm"
variant={hwFilter === "all" ? "secondary" : "ghost"}
asChild
className="h-7 px-2 text-xs"
>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
</Button>
<Button
size="sm"
variant={hwFilter === "active" ? "secondary" : "ghost"}
asChild
className="h-7 px-2 text-xs"
>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
</Button>
<Button
size="sm"
variant={hwFilter === "overdue" ? "secondary" : "ghost"}
asChild
className="h-7 px-2 text-xs"
>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{filteredAssignments.slice(0, 5).map((a) => (
<div key={a.assignmentId} className="p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between gap-4 mb-2">
<Link
href={`/teacher/homework/assignments/${a.assignmentId}`}
className="text-sm font-medium hover:underline line-clamp-1"
>
{a.title}
</Link>
<Badge variant={a.isActive ? "default" : "secondary"} className="shrink-0 text-[10px] h-5">
{a.status}
</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<div className="flex gap-3">
<span>{a.submittedCount} submitted</span>
<span>{formatNumber(a.scoreStats.avg, 0)}% avg</span>
</div>
</div>
</div>
))}
{filteredAssignments.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
No assignments found
</div>
)}
</div>
{filteredAssignments.length > 5 && (
<div className="p-2 border-t text-center">
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" asChild>
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>
View All Assignments
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)

View File

@@ -15,16 +15,7 @@ async function MyClassesPageImpl() {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<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">My Classes</h2>
<p className="text-muted-foreground">
Overview of your classes.
</p>
</div>
</div>
<div className="flex h-full flex-col space-y-4 p-8">
<MyClassesGrid classes={classes} canCreateClass={false} />
</div>
)

View File

@@ -67,16 +67,7 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro
return (
<div className="flex h-full flex-col space-y-8 p-8">
<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">Schedule</h2>
<p className="text-muted-foreground">
View class schedule.
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-6">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ScheduleFilters classes={classes} />
</Suspense>

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react"
import { User } from "lucide-react"
import { getClassStudents, getTeacherClasses } from "@/modules/classes/data-access"
import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
import { StudentsFilters } from "@/modules/classes/components/students-filters"
import { StudentsTable } from "@/modules/classes/components/students-table"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -16,19 +16,34 @@ const getParam = (params: SearchParams, key: string) => {
return Array.isArray(v) ? v[0] : v
}
async function StudentsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }) {
const params = await searchParams
const q = getParam(params, "q") || undefined
const classId = getParam(params, "classId")
const status = getParam(params, "status")
// If classId is explicit in URL, use it (unless "all"). If not, use defaultClassId.
// If user explicitly selects "all", classId will be "all".
// However, the requirement is "Default to showing the first class".
// If classId param is missing, we use defaultClassId.
const targetClassId = classId ? (classId !== "all" ? classId : undefined) : defaultClassId
const filteredStudents = await getClassStudents({
q,
classId: classId && classId !== "all" ? classId : undefined,
classId: targetClassId,
status: status && status !== "all" ? status : undefined,
})
// Fetch subject scores for all filtered students
if (filteredStudents.length > 0) {
const studentIds = filteredStudents.map(s => s.id)
const scores = await getStudentsSubjectScores(studentIds)
for (const student of filteredStudents) {
student.subjectScores = scores.get(student.id)
}
}
const hasFilters = Boolean(q || (classId && classId !== "all") || (status && status !== "all"))
if (filteredStudents.length === 0) {
@@ -67,25 +82,20 @@ function StudentsResultsFallback() {
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
const params = await searchParams
// Logic to determine default class (first one available)
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
return (
<div className="flex h-full flex-col space-y-8 p-8">
<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">Students</h2>
<p className="text-muted-foreground">
Manage student list.
</p>
</div>
</div>
<div className="flex h-full flex-col space-y-4 p-8">
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<StudentsFilters classes={classes} />
<StudentsFilters classes={classes} defaultClassId={defaultClassId} />
</Suspense>
<Suspense fallback={<StudentsResultsFallback />}>
<StudentsResults searchParams={searchParams} />
<StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
</Suspense>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout";
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader";
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
export const dynamic = "force-dynamic"
@@ -51,10 +51,11 @@ export default async function TextbookDetailPage({
{/* Main Content Layout (Flex grow) */}
<div className="flex-1 overflow-hidden pt-6">
<TextbookContentLayout
<TextbookReader
chapters={chapters}
knowledgePoints={knowledgePoints}
textbookId={id}
canEdit={true}
/>
</div>
</div>