完整性更新

现在已经实现了大部分基础功能
This commit is contained in:
SpecialX
2026-01-08 11:14:03 +08:00
parent 0da2eac0b4
commit 57807def37
155 changed files with 26421 additions and 1036 deletions

View File

@@ -1,20 +1,9 @@
"use client"
import { useEffect } from "react"
import { Button } from "@/shared/components/ui/button"
import { AlertCircle } from "lucide-react"
export default function AuthError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
export default function AuthError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">

View File

@@ -1,5 +1,9 @@
import { AdminDashboard } from "@/modules/dashboard/components/admin-view"
import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard"
import { getAdminDashboardData } from "@/modules/dashboard/data-access"
export default function AdminDashboardPage() {
return <AdminDashboard />
export const dynamic = "force-dynamic"
export default async function AdminDashboardPage() {
const data = await getAdminDashboardData()
return <AdminDashboardView data={data} />
}

View File

@@ -0,0 +1,18 @@
import { AcademicYearClient } from "@/modules/school/components/academic-year-view"
import { getAcademicYears } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminAcademicYearPage() {
const years = await getAcademicYears()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Academic Year</h2>
<p className="text-muted-foreground">Manage academic year ranges and the active year.</p>
</div>
<AcademicYearClient years={years} />
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access"
import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view"
export const dynamic = "force-dynamic"
export default async function AdminSchoolClassesPage() {
const [classes, teachers] = await Promise.all([getAdminClasses(), getTeacherOptions()])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Classes</h2>
<p className="text-muted-foreground">Manage classes and assign teachers.</p>
</div>
<AdminClassesClient classes={classes} teachers={teachers} />
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { DepartmentsClient } from "@/modules/school/components/departments-view"
import { getDepartments } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminDepartmentsPage() {
const departments = await getDepartments()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Departments</h2>
<p className="text-muted-foreground">Manage school departments.</p>
</div>
<DepartmentsClient departments={departments} />
</div>
)
}

View File

@@ -0,0 +1,231 @@
import Link from "next/link"
import { getGrades } from "@/modules/school/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
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"
import { BarChart3 } from "lucide-react"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
if (typeof v === "string") return v
if (Array.isArray(v)) return v[0]
return undefined
}
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
export default async function AdminGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const grades = await getGrades()
const selected = gradeId && gradeId !== "all" ? gradeId : ""
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/school/grades">Manage grades</Link>
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Filters</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<form action="/admin/school/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label className="text-sm font-medium">Grade</label>
<select
name="gradeId"
defaultValue={selected || "all"}
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
>
<option value="all">Select a grade</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.school.name} / {g.name}
</option>
))}
</select>
<Button type="submit" className="md:ml-2">
Apply
</Button>
</form>
</CardContent>
</Card>
{!selected ? (
<EmptyState
icon={BarChart3}
title="Select a grade to view insights"
description="Pick a grade to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title="Grade not found"
description="This grade may not exist or has no accessible data."
className="h-[360px] bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No homework data for this grade"
description="No homework assignments were targeted to students in this grade yet."
className="h-[360px] bg-card"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Classes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
<div className="text-xs text-muted-foreground">
{insights.grade.school.name} / {insights.grade.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{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">Overall Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
<div className="text-xs text-muted-foreground">Across graded homework</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Latest homework</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.assignments.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</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>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Class ranking</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.classes.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Class</TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead className="text-right">Latest Avg</TableHead>
<TableHead className="text-right">Prev Avg</TableHead>
<TableHead className="text-right">Δ</TableHead>
<TableHead className="text-right">Overall Avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">
{c.class.name}
{c.class.homeroom ? <span className="text-muted-foreground"> {c.class.homeroom}</span> : null}
</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { GradesClient } from "@/modules/school/components/grades-view"
import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminGradesPage() {
const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grades</h2>
<p className="text-muted-foreground">Manage grades and assign grade heads.</p>
</div>
<GradesClient grades={grades} schools={schools} staff={staff} />
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function AdminSchoolPage() {
redirect("/admin/school/classes")
}

View File

@@ -0,0 +1,18 @@
import { SchoolsClient } from "@/modules/school/components/schools-view"
import { getSchools } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminSchoolsPage() {
const schools = await getSchools()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Schools</h2>
<p className="text-muted-foreground">Manage schools for multi-school setups.</p>
</div>
<SchoolsClient schools={schools} />
</div>
)
}

View File

@@ -1,72 +1,16 @@
"use client"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
import { Shield, GraduationCap, Users, User } from "lucide-react"
export const dynamic = "force-dynamic"
// In a real app, this would be a server component that redirects based on session
// But for this demo/dev environment, we keep the manual selection or add auto-redirect logic if we had auth state.
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/login")
export default function DashboardPage() {
// Mock Auth Logic (Optional: Uncomment to test auto-redirect)
/*
const router = useRouter();
useEffect(() => {
// const role = "teacher"; // Fetch from auth hook
// if (role) router.push(`/${role}/dashboard`);
}, []);
*/
const role = String(session.user.role ?? "teacher")
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] space-y-8">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold">Welcome to Next_Edu</h1>
<p className="text-muted-foreground">Select your role to view the corresponding dashboard.</p>
<p className="text-xs text-muted-foreground bg-muted p-2 rounded inline-block">
[DEV MODE] In production, you would be redirected automatically based on your login session.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Link href="/admin/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-primary hover:bg-primary/5 transition-all">
<Shield className="h-10 w-10 text-primary" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Admin</span>
<span className="text-xs text-muted-foreground font-normal">System Management</span>
</div>
</Button>
</Link>
<Link href="/teacher/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-indigo-500 hover:bg-indigo-50 transition-all">
<GraduationCap className="h-10 w-10 text-indigo-600" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Teacher</span>
<span className="text-xs text-muted-foreground font-normal">Class & Exams</span>
</div>
</Button>
</Link>
<Link href="/student/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-emerald-500 hover:bg-emerald-50 transition-all">
<Users className="h-10 w-10 text-emerald-600" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Student</span>
<span className="text-xs text-muted-foreground font-normal">My Learning</span>
</div>
</Button>
</Link>
<Link href="/parent/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-amber-500 hover:bg-amber-50 transition-all">
<User className="h-10 w-10 text-amber-600" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Parent</span>
<span className="text-xs text-muted-foreground font-normal">Family Overview</span>
</div>
</Button>
</Link>
</div>
</div>
)
if (role === "admin") redirect("/admin/dashboard")
if (role === "student") redirect("/student/dashboard")
if (role === "parent") redirect("/parent/dashboard")
redirect("/teacher/dashboard")
}

View File

@@ -1,23 +1,10 @@
"use client"
import { useEffect } from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
export default function Error({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<EmptyState

View File

@@ -0,0 +1,151 @@
import Link from "next/link"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentRankingCard } from "@/modules/dashboard/components/student-dashboard/student-ranking-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
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"
export const dynamic = "force-dynamic"
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 default async function ProfilePage() {
const session = await auth()
if (!session?.user) redirect("/login")
const name = session.user.name ?? "User"
const email = session.user.email ?? "-"
const role = String(session.user.role ?? "teacher")
const userId = String(session.user.id ?? "").trim()
const studentData =
role === "student" && userId
? await (async () => {
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignmentsAll.filter((a) => a.progressStatus === "graded").length
const upcomingAssignments = [...assignmentsAll]
.sort((a, b) => {
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
.slice(0, 8)
const todayWeekday = toWeekday(now)
const todayScheduleItems = schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
return {
enrolledClassCount: classes.length,
dueSoonCount,
overdueCount,
gradedCount,
todayScheduleItems,
upcomingAssignments,
grades,
}
})()
: null
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
<div className="text-sm text-muted-foreground">Your account information.</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/settings">Open settings</Link>
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Signed-in user details from session.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{name}</div>
<Badge variant="secondary" className="capitalize">
{role}
</Badge>
</div>
<div className="text-sm text-muted-foreground">{email}</div>
</CardContent>
</Card>
{studentData ? (
<div className="space-y-6">
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Student</h2>
<div className="text-sm text-muted-foreground">Your learning overview.</div>
</div>
<StudentStatsGrid
enrolledClassCount={studentData.enrolledClassCount}
dueSoonCount={studentData.dueSoonCount}
overdueCount={studentData.overdueCount}
gradedCount={studentData.gradedCount}
/>
<div className="grid gap-4 md:grid-cols-2">
<StudentGradesCard grades={studentData.grades} />
<StudentRankingCard ranking={studentData.grades.ranking} />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
</div>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
export const dynamic = "force-dynamic"
export default async function SettingsPage() {
const session = await auth()
if (!session?.user) redirect("/login")
const role = String(session.user.role ?? "teacher")
if (role === "admin") return <AdminSettingsView />
if (role === "student") return <StudentSettingsView user={session.user} />
if (role === "teacher") return <TeacherSettingsView user={session.user} />
redirect("/dashboard")
}

View File

@@ -0,0 +1,61 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-4 w-56" />
</div>
<Skeleton className="h-10 w-40" />
</div>
<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-24" />
<Skeleton className="h-4 w-4 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="text-sm">
<Skeleton className="h-4 w-40" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card className="lg:col-span-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-sm">
<Skeleton className="h-4 w-44" />
</CardTitle>
<Skeleton className="h-9 w-24" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,5 +1,88 @@
import { StudentDashboard } from "@/modules/dashboard/components/student-view"
import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getDemoStudentUser, getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Inbox } from "lucide-react"
export default function StudentDashboardPage() {
return <StudentDashboard />
export const dynamic = "force-dynamic"
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 default async function StudentDashboardPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col items-center justify-center">
<EmptyState
title="No user found"
description="Create a student user to see dashboard."
icon={Inbox}
className="border-none shadow-none h-auto"
/>
</div>
)
}
const [classes, schedule, assignments, grades] = await Promise.all([
getStudentClasses(student.id),
getStudentSchedule(student.id),
getStudentHomeworkAssignments(student.id),
getStudentDashboardGrades(student.id),
])
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignments.filter((a) => a.progressStatus === "graded").length
const todayWeekday = toWeekday(now)
const todayScheduleItems = schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
.sort((a, b) => a.startTime.localeCompare(b.startTime))
const upcomingAssignments = [...assignments]
.sort((a, b) => {
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
return aDue - bDue
})
.slice(0, 6)
return (
<StudentDashboard
studentName={student.name}
enrolledClassCount={classes.length}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
todayScheduleItems={todayScheduleItems}
upcomingAssignments={upcomingAssignments}
grades={grades}
/>
)
}

View File

@@ -0,0 +1,29 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-52" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-44" />
<Skeleton className="mt-2 h-4 w-56" />
</CardHeader>
<CardContent className="pt-2 flex items-center justify-between">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-9 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { Inbox } from "lucide-react"
import { getStudentClasses } from "@/modules/classes/data-access"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
export default async function StudentCoursesPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
</div>
<EmptyState
title="No user found"
description="Create a student user to see courses."
icon={Inbox}
/>
</div>
)
}
const classes = await getStudentClasses(student.id)
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
</div>
<StudentCoursesView classes={classes} />
</div>
)
}

View File

@@ -0,0 +1,78 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { ArrowLeft, BookOpen, Inbox } from "lucide-react"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getDemoStudentUser } from "@/modules/homework/data-access"
export const dynamic = "force-dynamic"
export default async function StudentTextbookDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbook</h2>
<p className="text-muted-foreground">Read chapters and review content.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to read textbooks." icon={Inbox} />
</div>
)
}
const { id } = await params
const [textbook, chapters] = await Promise.all([getTextbookById(id), getChaptersByTextbookId(id)])
if (!textbook) notFound()
return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
<div className="flex items-center gap-4 border-b bg-background py-4 shrink-0 z-10">
<Button variant="ghost" size="icon" asChild>
<Link href="/student/learning/textbooks">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{textbook.subject}</Badge>
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
{textbook.grade ?? "-"}
</span>
</div>
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
</div>
</div>
<div className="flex-1 overflow-hidden pt-6">
{chapters.length === 0 ? (
<div className="px-8">
<EmptyState
icon={BookOpen}
title="No chapters"
description="This textbook has no chapters yet."
className="bg-card"
/>
</div>
) : (
<div className="h-[calc(100vh-140px)] px-8 min-h-0">
<TextbookReader chapters={chapters} />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { BookOpen, Inbox } from "lucide-react"
import { getTextbooks } from "@/modules/textbooks/data-access"
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
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
}
export default async function StudentTextbooksPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const [student, sp] = await Promise.all([getDemoStudentUser(), searchParams])
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
</div>
)
}
const q = getParam(sp, "q") || undefined
const subject = getParam(sp, "subject") || undefined
const grade = getParam(sp, "grade") || undefined
const textbooks = await getTextbooks(q, subject, grade)
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
<TextbookFilters />
{textbooks.length === 0 ? (
<EmptyState
icon={BookOpen}
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "No textbooks are available right now."}
action={hasFilters ? { label: "Clear filters", href: "/student/learning/textbooks" } : undefined}
className="bg-card"
/>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-52" />
</div>
<Skeleton className="h-10 w-60" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<Skeleton className="h-4 w-16" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 3 }).map((__, j) => (
<Skeleton key={j} className="h-16 w-full" />
))}
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { Inbox } from "lucide-react"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function StudentSchedulePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">Your weekly timetable.</p>
</div>
<EmptyState title="No user found" description="Create a student user to see schedule." icon={Inbox} />
</div>
)
}
const [sp, classes, schedule] = await Promise.all([
searchParams,
getStudentClasses(student.id),
getStudentSchedule(student.id),
])
const classIdParam = sp.classId
const classId = typeof classIdParam === "string" ? classIdParam : Array.isArray(classIdParam) ? classIdParam[0] : "all"
const filteredItems =
classId && classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">Your weekly timetable.</p>
</div>
<StudentScheduleFilters classes={classes} />
</div>
<StudentScheduleView items={filteredItems} />
</div>
)
}

View File

@@ -0,0 +1,259 @@
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

@@ -0,0 +1,315 @@
import Link from "next/link"
import { notFound } from "next/navigation"
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, CardHeader, CardTitle } from "@/shared/components/ui/card"
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)
}
export default async function ClassDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<SearchParams>
}) {
const { id } = await params
const sp = await searchParams
const hw = getParam(sp, "hw")
const hwFilter = hw === "active" || hw === "overdue" ? hw : "all"
const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 50 }),
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,
},
]
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/teacher/classes/my">Back</Link>
</Button>
<Badge variant="secondary">{insights.class.grade}</Badge>
<Badge variant="outline">{insights.studentCounts.total} students</Badge>
</div>
<h2 className="text-2xl font-bold tracking-tight">{insights.class.name}</h2>
<div className="text-sm text-muted-foreground">
{insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"}
{insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null}
</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)}`}>Students</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>Schedule</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(insights.class.id)}`}>Insights</Link>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<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">Schedule items</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{schedule.length}</div>
<div className="text-xs text-muted-foreground">Weekly timetable entries</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)}` : "No homework yet"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall avg</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 homework</CardTitle>
<div className="mt-1 flex flex-wrap 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>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Students (preview)</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-sm text-muted-foreground">No students enrolled.</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.slice(0, 8).map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Schedule</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<CardTitle className="text-base">Homework history</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button asChild size="sm" variant={hwFilter === "all" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "active" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "overdue" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>Open list</Link>
</Button>
<Button asChild size="sm">
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>New homework</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{!hasAssignments ? (
<div className="text-sm text-muted-foreground">No homework assignments yet.</div>
) : (
<div className="rounded-md border">
<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>
</TableRow>
</TableHeader>
<TableBody>
{filteredAssignments.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>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-start justify-between gap-3">
<Skeleton className="h-5 w-[60%]" />
<Skeleton className="h-5 w-20" />
</div>
<Skeleton className="mt-3 h-4 w-32" />
<div className="mt-6 flex items-center justify-between">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-16" />
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,10 +1,18 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Users } from "lucide-react"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
export const dynamic = "force-dynamic"
export default function MyClassesPage() {
return <MyClassesPageImpl />
}
async function MyClassesPageImpl() {
const classes = await getTeacherClasses()
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<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">
@@ -12,11 +20,8 @@ export default function MyClassesPage() {
</p>
</div>
</div>
<EmptyState
title="No classes found"
description="You are not assigned to any classes yet."
icon={Users}
/>
<MyClassesGrid classes={classes} />
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-10 w-full" />
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,10 +1,73 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Suspense } from "react"
import { Calendar } from "lucide-react"
export default function SchedulePage() {
import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access"
import { ScheduleFilters } from "@/modules/classes/components/schedule-filters"
import { ScheduleView } from "@/modules/classes/components/schedule-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
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
}
async function ScheduleResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const classId = getParam(params, "classId")
const classes = await getTeacherClasses()
const schedule = await getClassSchedule({
classId: classId && classId !== "all" ? classId : undefined,
})
const hasFilters = Boolean(classId && classId !== "all")
if (schedule.length === 0) {
return (
<EmptyState
icon={Calendar}
title={hasFilters ? "No schedule for this class" : "No schedule available"}
description={hasFilters ? "Try selecting another class." : "Your class schedule has not been set up yet."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/schedule" } : undefined}
className="h-[360px] bg-card"
/>
)
}
return <ScheduleView schedule={schedule} classes={classes} />
}
function ScheduleResultsFallback() {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
)
}
export default async function SchedulePage({ 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">Schedule</h2>
<p className="text-muted-foreground">
@@ -12,11 +75,16 @@ export default function SchedulePage() {
</p>
</div>
</div>
<EmptyState
title="No schedule available"
description="Your class schedule has not been set up yet."
icon={Calendar}
/>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ScheduleFilters classes={classes} />
</Suspense>
<Suspense fallback={<ScheduleResultsFallback />}>
<ScheduleResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-10 w-full" />
<div className="rounded-md border bg-card">
<div className="space-y-2 p-4">
{Array.from({ length: 10 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,74 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Suspense } from "react"
import { User } from "lucide-react"
export default function StudentsPage() {
import { getClassStudents, getTeacherClasses } 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"
import { Skeleton } from "@/shared/components/ui/skeleton"
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
}
async function StudentsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const q = getParam(params, "q") || undefined
const classId = getParam(params, "classId")
const filteredStudents = await getClassStudents({
q,
classId: classId && classId !== "all" ? classId : undefined,
})
const hasFilters = Boolean(q || (classId && classId !== "all"))
if (filteredStudents.length === 0) {
return (
<EmptyState
icon={User}
title={hasFilters ? "No students match your filters" : "No students found"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "There are no students in your classes yet."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/students" } : undefined}
className="h-[360px] bg-card"
/>
)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div className="rounded-md border bg-card">
<StudentsTable students={filteredStudents} />
</div>
)
}
function StudentsResultsFallback() {
return (
<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>
)
}
export default async function StudentsPage({ 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">Students</h2>
<p className="text-muted-foreground">
@@ -12,11 +76,16 @@ export default function StudentsPage() {
</p>
</div>
</div>
<EmptyState
title="No students found"
description="There are no students in your classes yet."
icon={User}
/>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<StudentsFilters classes={classes} />
</Suspense>
<Suspense fallback={<StudentsResultsFallback />}>
<StudentsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -1,28 +1,27 @@
import { TeacherStats } from "@/modules/dashboard/components/teacher-stats";
import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule";
import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions";
import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions";
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access";
import { getHomeworkAssignments, getHomeworkSubmissions } from "@/modules/homework/data-access";
export const dynamic = "force-dynamic";
export default async function TeacherDashboardPage() {
const teacherId = await getTeacherIdForMutations();
const [classes, schedule, assignments, submissions] = await Promise.all([
getTeacherClasses({ teacherId }),
getClassSchedule({ teacherId }),
getHomeworkAssignments({ creatorId: teacherId }),
getHomeworkSubmissions({ creatorId: teacherId }),
]);
export default function TeacherDashboardPage() {
return (
<div className="flex-1 space-y-4">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Teacher Dashboard</h2>
<div className="flex items-center space-x-2">
<TeacherQuickActions />
</div>
</div>
{/* Overview Stats */}
<TeacherStats />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Left Column: Schedule (3/7 width) */}
<TeacherSchedule />
{/* Right Column: Recent Activity (4/7 width) */}
<RecentSubmissions />
</div>
</div>
);
<TeacherDashboardView
data={{
classes,
schedule,
assignments,
submissions,
}}
/>
)
}

View File

@@ -0,0 +1,244 @@
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getGradesForStaff } from "@/modules/school/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { BarChart3 } from "lucide-react"
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]
if (typeof v === "string") return v
if (Array.isArray(v)) return v[0]
return undefined
}
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const teacherId = await getTeacherIdForMutations()
const grades = await getGradesForStaff(teacherId)
const allowedIds = new Set(grades.map((g) => g.id))
const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : ""
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
if (grades.length === 0) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">View grade-level homework statistics for grades you lead.</p>
</div>
<EmptyState
icon={BarChart3}
title="No grades assigned"
description="You are not assigned as a grade head or teaching head for any grade."
className="h-[360px] bg-card"
/>
</div>
)
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Filters</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<form action="/teacher/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label className="text-sm font-medium">Grade</label>
<select
name="gradeId"
defaultValue={selected || "all"}
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
>
<option value="all">Select a grade</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.school.name} / {g.name}
</option>
))}
</select>
<Button type="submit" className="md:ml-2">
Apply
</Button>
</form>
</CardContent>
</Card>
{!selected ? (
<EmptyState
icon={BarChart3}
title="Select a grade to view insights"
description="Pick a grade to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title="Grade not found"
description="This grade may not exist or has no accessible data."
className="h-[360px] bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No homework data for this grade"
description="No homework assignments were targeted to students in this grade yet."
className="h-[360px] bg-card"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Classes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
<div className="text-xs text-muted-foreground">
{insights.grade.school.name} / {insights.grade.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{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">Overall Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
<div className="text-xs text-muted-foreground">Across graded homework</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Homework timeline</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.assignments.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</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>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Class ranking</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.classes.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Class</TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead className="text-right">Latest Avg</TableHead>
<TableHead className="text-right">Prev Avg</TableHead>
<TableHead className="text-right">Δ</TableHead>
<TableHead className="text-right">Overall Avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">
{c.class.name}
{c.class.homeroom ? <span className="text-muted-foreground"> {c.class.homeroom}</span> : null}
</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,9 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { getHomeworkAssignmentById } from "@/modules/homework/data-access"
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
import { HomeworkAssignmentQuestionErrorDetailsCard } from "@/modules/homework/components/homework-assignment-question-error-details-card"
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -10,9 +13,11 @@ export const dynamic = "force-dynamic"
export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const assignment = await getHomeworkAssignmentById(id)
const analytics = await getHomeworkAssignmentAnalytics(id)
if (!assignment) return notFound()
if (!analytics) return notFound()
const { assignment, questions, gradedSampleCount } = analytics
return (
<div className="flex h-full flex-col space-y-8 p-8">
@@ -69,12 +74,28 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
<div className="text-sm">
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
<div className="text-muted-foreground">
Late: {assignment.allowLate ? (assignment.lateDueAt ? formatDate(assignment.lateDueAt) : "Allowed") : "Not allowed"}
Late:{" "}
{assignment.allowLate
? assignment.lateDueAt
? formatDate(assignment.lateDueAt)
: "Allowed"
: "Not allowed"}
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2">
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
<HomeworkAssignmentQuestionErrorDetailsCard questions={questions} gradedSampleCount={gradedSampleCount} />
</div>
<HomeworkAssignmentExamContentCard
structure={assignment.structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
</div>
)
}

View File

@@ -28,10 +28,22 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
<div>
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
<p className="text-muted-foreground">{assignment.title}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>Exam: {assignment.sourceExamTitle}</span>
<span></span>
<span>Targets: {assignment.targetCount}</span>
<span></span>
<span>Submitted: {assignment.submittedCount}</span>
<span></span>
<span>Graded: {assignment.gradedCount}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={`/teacher/homework/assignments/${id}`}>Back</Link>
<Link href="/teacher/homework/submissions">Back</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/homework/assignments/${id}`}>Open Assignment</Link>
</Button>
</div>
</div>

View File

@@ -1,12 +1,13 @@
import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form"
import { getExams } from "@/modules/exams/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { FileQuestion } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function CreateHomeworkAssignmentPage() {
const exams = await getExams({})
const [exams, classes] = await Promise.all([getExams({}), getTeacherClasses()])
const options = exams.map((e) => ({ id: e.id, title: e.title }))
return (
@@ -25,8 +26,15 @@ export default async function CreateHomeworkAssignmentPage() {
icon={FileQuestion}
action={{ label: "Create Exam", href: "/teacher/exams/create" }}
/>
) : classes.length === 0 ? (
<EmptyState
title="No classes available"
description="Create a class first, then publish homework to that class."
icon={FileQuestion}
action={{ label: "Go to Classes", href: "/teacher/classes/my" }}
/>
) : (
<HomeworkAssignmentForm exams={options} />
<HomeworkAssignmentForm exams={options} classes={classes} />
)}
</div>
)

View File

@@ -12,13 +12,28 @@ import {
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function AssignmentsPage() {
const assignments = await getHomeworkAssignments()
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const classId = getParam(sp, "classId") || undefined
const [assignments, classes] = await Promise.all([
getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
])
const hasAssignments = assignments.length > 0
const className = classId && classId !== "all" ? classes.find((c) => c.id === classId)?.name : undefined
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
@@ -26,25 +41,41 @@ export default async function AssignmentsPage() {
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">
Manage homework assignments.
{classId && classId !== "all" ? `Filtered by class: ${className ?? classId}` : "Manage homework assignments."}
</p>
</div>
<Button asChild>
<Link href="/teacher/homework/assignments/create">
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
<div className="flex items-center gap-2">
{classId && classId !== "all" ? (
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Clear filter</Link>
</Button>
) : null}
<Button asChild>
<Link
href={
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
: "/teacher/homework/assignments/create"
}
>
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
</div>
</div>
{!hasAssignments ? (
<EmptyState
title="No assignments"
description="You haven't created any assignments yet."
description={classId && classId !== "all" ? "No assignments for this class yet." : "You haven't created any assignments yet."}
icon={PenTool}
action={{
label: "Create Assignment",
href: "/teacher/homework/assignments/create",
href:
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
: "/teacher/homework/assignments/create",
}}
/>
) : (

View File

@@ -10,14 +10,16 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkSubmissions } from "@/modules/homework/data-access"
import { getHomeworkAssignmentReviewList } from "@/modules/homework/data-access"
import { Inbox } from "lucide-react"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
export default async function SubmissionsPage() {
const submissions = await getHomeworkSubmissions()
const hasSubmissions = submissions.length > 0
const creatorId = await getTeacherIdForMutations()
const assignments = await getHomeworkAssignmentReviewList({ creatorId })
const hasAssignments = assignments.length > 0
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
@@ -25,15 +27,15 @@ export default async function SubmissionsPage() {
<div>
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
<p className="text-muted-foreground">
Review student homework submissions.
Review homework by assignment.
</p>
</div>
</div>
{!hasSubmissions ? (
{!hasAssignments ? (
<EmptyState
title="No submissions"
description="There are no homework submissions to review."
title="No assignments"
description="There are no homework assignments to review yet."
icon={Inbox}
/>
) : (
@@ -42,29 +44,31 @@ export default async function SubmissionsPage() {
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Student</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Score</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targets</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.map((s) => (
<TableRow key={s.id}>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/submissions/${s.id}`} className="hover:underline">
{s.assignmentTitle}
<Link href={`/teacher/homework/assignments/${a.id}/submissions`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">{a.sourceExamTitle}</div>
</TableCell>
<TableCell>{s.studentName}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
{a.status}
</Badge>
{s.isLate ? <span className="ml-2 text-xs text-destructive">Late</span> : null}
</TableCell>
<TableCell className="text-muted-foreground">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell>{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell className="text-muted-foreground">{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>
</TableRow>
))}
</TableBody>

View File

@@ -1,12 +1,14 @@
import { notFound } from "next/navigation";
import { ArrowLeft, Edit } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByChapterId } from "@/modules/textbooks/data-access";
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout";
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
export const dynamic = "force-dynamic"
export default async function TextbookDetailPage({
params,
}: {
@@ -14,36 +16,16 @@ export default async function TextbookDetailPage({
}) {
const { id } = await params;
const [textbook, chapters] = await Promise.all([
const [textbook, chapters, knowledgePoints] = await Promise.all([
getTextbookById(id),
getChaptersByTextbookId(id),
getKnowledgePointsByTextbookId(id),
]);
if (!textbook) {
notFound();
}
// Fetch all KPs for these chapters. In a real app, this might be optimized to fetch only needed or use a different query strategy.
// For now, we simulate fetching KPs for all chapters to pass down, or we could fetch on demand.
// Given the layout loads everything client-side for interactivity, let's fetch all KPs associated with any chapter in this textbook.
// We'll need to extend the data access for this specific query pattern or loop.
// For simplicity in this mock, let's assume getKnowledgePointsByChapterId can handle fetching all KPs for a textbook if we had such a function,
// or we iterate. Let's create a helper to get all KPs for the textbook's chapters.
// Actually, let's update data-access to support getting KPs by Textbook ID directly or just fetch all for mock.
// Since we don't have getKnowledgePointsByTextbookId, we will map over chapters.
const allKnowledgePoints = (await Promise.all(
chapters.map(c => getKnowledgePointsByChapterId(c.id))
)).flat();
// Also need to get KPs for children chapters if any
const childrenKPs = (await Promise.all(
chapters.flatMap(c => c.children || []).map(child => getKnowledgePointsByChapterId(child.id))
)).flat();
const knowledgePoints = [...allKnowledgePoints, ...childrenKPs];
return (
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
{/* Header / Nav (Fixed height) */}

View File

@@ -1,20 +1,53 @@
import { Search, Filter } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Suspense } from "react"
import { BookOpen } from "lucide-react"
import { TextbookCard } from "@/modules/textbooks/components/textbook-card";
import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog";
import { getTextbooks } from "@/modules/textbooks/data-access";
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default async function TextbooksPage() {
// In a real app, we would parse searchParams here
const textbooks = await getTextbooks();
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
}
async function TextbooksResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const q = getParam(params, "q") || undefined
const subject = getParam(params, "subject")
const grade = getParam(params, "grade")
const textbooks = await getTextbooks(q, subject || undefined, grade || undefined)
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
if (textbooks.length === 0) {
return (
<EmptyState
icon={BookOpen}
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
className="bg-card"
/>
)
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} />
))}
</div>
)
}
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
return (
<div className="space-y-6">
@@ -29,50 +62,13 @@ export default async function TextbooksPage() {
<TextbookFormDialog />
</div>
{/* Toolbar */}
<div className="flex flex-col gap-4 md:flex-row md:items-center justify-between bg-card p-4 rounded-lg border shadow-sm">
<div className="relative w-full md:w-96">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search textbooks..."
className="pl-9 bg-background"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Select>
<SelectTrigger className="w-[140px] bg-background">
<Filter className="mr-2 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="Subject" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
<SelectItem value="math">Mathematics</SelectItem>
<SelectItem value="physics">Physics</SelectItem>
<SelectItem value="history">History</SelectItem>
<SelectItem value="english">English</SelectItem>
</SelectContent>
</Select>
<Select>
<SelectTrigger className="w-[140px] bg-background">
<SelectValue placeholder="Grade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Grades</SelectItem>
<SelectItem value="10">Grade 10</SelectItem>
<SelectItem value="11">Grade 11</SelectItem>
<SelectItem value="12">Grade 12</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Suspense fallback={<div className="h-14 w-full animate-pulse rounded-lg bg-muted" />}>
<TextbookFilters />
</Suspense>
{/* Grid Content */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} />
))}
</div>
<Suspense fallback={<div className="h-[360px] w-full animate-pulse rounded-md bg-muted" />}>
<TextbooksResults searchParams={searchParams} />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,4 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@@ -1,6 +1,7 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { ThemeProvider } from "@/shared/components/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { AuthSessionProvider } from "@/shared/components/auth-session-provider"
import "./globals.css";
export const metadata: Metadata = {
@@ -25,9 +26,11 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<NuqsAdapter>
{children}
</NuqsAdapter>
<AuthSessionProvider>
<NuqsAdapter>
{children}
</NuqsAdapter>
</AuthSessionProvider>
<Toaster />
</ThemeProvider>
</body>