feat: enhance textbook reader with anchor text support and improve knowledge point management
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user