feat(app): add lesson-plans, practice, and grade dashboard routes

- Add admin/lesson-plans, parent/lesson-plans, student/lesson-plans routes

- Add student/practice and teacher/practice routes for adaptive practice

- Add management/grade/dashboard and management/grade/practice routes

- Add teacher/lesson-plans error and loading boundaries

- Update existing admin, parent, student, teacher pages with new features

- Update globals.css and proxy middleware
This commit is contained in:
SpecialX
2026-06-24 12:03:47 +08:00
parent 8c2fe14c20
commit 37d2688a28
84 changed files with 2665 additions and 661 deletions

View File

@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { getStudentHomeworkTakeData } from "@/modules/homework/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
@@ -17,6 +18,8 @@ export default async function StudentAssignmentTakePage({
const student = await getCurrentStudentUser()
if (!student) return notFound()
const t = await getTranslations("student")
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
if (!data) return notFound()
@@ -28,7 +31,7 @@ export default async function StudentAssignmentTakePage({
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
<div className="text-sm text-muted-foreground">
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
<span>{t("assignment.due", { date: data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-" })}</span>
</div>
</div>
@@ -42,9 +45,9 @@ export default async function StudentAssignmentTakePage({
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
<div className="text-sm text-muted-foreground">
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
<span>{t("assignment.due", { date: data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-" })}</span>
<span className="mx-2" aria-hidden="true"></span>
<span>Max Attempts: {data.assignment.maxAttempts}</span>
<span>{t("assignment.maxAttempts", { count: data.assignment.maxAttempts })}</span>
</div>
</div>

View File

@@ -10,6 +10,7 @@ import {
School,
User,
} from "lucide-react"
import { getTranslations } from "next-intl/server"
import { getStudentClassById, getStudentSchedule } from "@/modules/classes/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
@@ -20,14 +21,14 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
const WEEKDAYS: Record<number, string> = {
1: "Mon",
2: "Tue",
3: "Wed",
4: "Thu",
5: "Fri",
6: "Sat",
7: "Sun",
const WEEKDAY_KEYS: Record<number, string> = {
1: "mon",
2: "tue",
3: "wed",
4: "thu",
5: "fri",
6: "sat",
7: "sun",
}
export default async function StudentClassDetailPage({
@@ -39,6 +40,8 @@ export default async function StudentClassDetailPage({
const student = await getCurrentStudentUser()
if (!student) return notFound()
const t = await getTranslations("student")
const [classInfo, schedule] = await Promise.all([
getStudentClassById(student.id, classId),
getStudentSchedule(student.id),
@@ -58,14 +61,14 @@ export default async function StudentClassDetailPage({
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-1">
<Link href="/student/learning/courses">
<ChevronLeft className="mr-1 h-4 w-4" />
Back to Courses
{t("classDetail.backToCourses")}
</Link>
</Button>
<h2 className="text-2xl font-bold tracking-tight">{classInfo.name}</h2>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="h-4 w-4" />
Grade {classInfo.grade}
{t("classDetail.grade", { grade: classInfo.grade })}
</span>
{classInfo.homeroom && (
<>
@@ -78,24 +81,24 @@ export default async function StudentClassDetailPage({
<span aria-hidden="true"></span>
<span className="flex items-center gap-1">
<Building2 className="h-4 w-4" />
Room {classInfo.room}
{t("classDetail.room", { room: classInfo.room })}
</span>
</>
)}
<Badge variant="secondary">Active</Badge>
<Badge variant="secondary">{t("classDetail.active")}</Badge>
</div>
</div>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/student/schedule?classId=${encodeURIComponent(classInfo.id)}`}>
<CalendarDays className="mr-2 h-4 w-4" />
Full Schedule
{t("classDetail.fullSchedule")}
</Link>
</Button>
<Button asChild size="sm">
<Link href="/student/learning/assignments">
<PenTool className="mr-2 h-4 w-4" />
Assignments
{t("classDetail.assignments")}
</Link>
</Button>
</div>
@@ -107,7 +110,7 @@ export default async function StudentClassDetailPage({
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<User className="h-4 w-4" />
Teacher
{t("classDetail.teacher")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
@@ -117,7 +120,7 @@ export default async function StudentClassDetailPage({
<span className="font-medium">{classInfo.teacherName}</span>
</div>
) : (
<p className="text-muted-foreground">No teacher assigned.</p>
<p className="text-muted-foreground">{t("classDetail.noTeacher")}</p>
)}
{classInfo.teacherEmail && (
<div className="flex items-center gap-2">
@@ -138,7 +141,7 @@ export default async function StudentClassDetailPage({
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<School className="h-4 w-4" />
School
{t("classDetail.school")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
@@ -148,12 +151,12 @@ export default async function StudentClassDetailPage({
<span className="font-medium">{classInfo.schoolName}</span>
</div>
) : (
<p className="text-muted-foreground">School info not available.</p>
<p className="text-muted-foreground">{t("classDetail.schoolNotAvailable")}</p>
)}
{classInfo.grade && (
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span>Grade {classInfo.grade}</span>
<span>{t("classDetail.grade", { grade: classInfo.grade })}</span>
</div>
)}
</CardContent>
@@ -164,22 +167,22 @@ export default async function StudentClassDetailPage({
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Building2 className="h-4 w-4" />
Classroom
{t("classDetail.classroom")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{classInfo.room ? (
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Room {classInfo.room}</span>
<span className="font-medium">{t("classDetail.room", { room: classInfo.room })}</span>
</div>
) : (
<p className="text-muted-foreground">Room not assigned.</p>
<p className="text-muted-foreground">{t("classDetail.roomNotAssigned")}</p>
)}
{classInfo.homeroom && (
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>Homeroom: {classInfo.homeroom}</span>
<span>{t("classDetail.homeroom", { homeroom: classInfo.homeroom })}</span>
</div>
)}
</CardContent>
@@ -191,15 +194,15 @@ export default async function StudentClassDetailPage({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-5 w-5" />
Class Schedule
{t("classDetail.classSchedule")}
</CardTitle>
</CardHeader>
<CardContent>
{classSchedule.length === 0 ? (
<EmptyState
icon={CalendarDays}
title="No schedule"
description="No timetable entries found for this class."
title={t("classDetail.noSchedule")}
description={t("classDetail.noScheduleDesc")}
className="border-none shadow-none"
/>
) : (
@@ -211,7 +214,7 @@ export default async function StudentClassDetailPage({
>
<div className="flex items-center gap-3">
<Badge variant="outline" className="w-12 justify-center">
{WEEKDAYS[s.weekday]}
{t(`weekdays.${WEEKDAY_KEYS[s.weekday]}`)}
</Badge>
<div>
<p className="font-medium">{s.course}</p>

View File

@@ -1,4 +1,5 @@
import { UserX } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { getStudentClasses } from "@/modules/classes/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
@@ -14,17 +15,18 @@ export default async function StudentCoursesPage({
}: {
searchParams: Promise<SearchParams>
}) {
const t = await getTranslations("student")
const student = await getCurrentStudentUser()
if (!student) {
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
<h2 className="text-2xl font-bold tracking-tight">{t("courses.title")}</h2>
<p className="text-muted-foreground">{t("courses.description")}</p>
</div>
<EmptyState
title="No user found"
description="Create a student user to see courses."
title={t("courses.noUser")}
description={t("courses.noUserDesc")}
icon={UserX}
/>
</div>
@@ -51,8 +53,8 @@ export default async function StudentCoursesPage({
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
<h2 className="text-2xl font-bold tracking-tight">{t("courses.title")}</h2>
<p className="text-muted-foreground">{t("courses.description")}</p>
</div>
{classes.length > 0 && <CourseFilters />}
<StudentCoursesView classes={filteredClasses} />

View File

@@ -1,5 +1,6 @@
import Link from "next/link"
import { BookOpen, PenTool, Library, ArrowRight } from "lucide-react"
import { BookOpen, PenTool, Library, ArrowRight, UserX } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { getStudentClasses } from "@/modules/classes/data-access"
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
@@ -7,16 +8,16 @@ import { getCurrentStudentUser } from "@/modules/users/data-access"
import { getTextbooks } from "@/modules/textbooks/data-access"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { UserX } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function StudentLearningPage() {
const t = await getTranslations("student")
const student = await getCurrentStudentUser()
if (!student) {
return (
<div className="space-y-8">
<EmptyState title="No user found" description="Create a student user to see learning." icon={UserX} />
<EmptyState title={t("learning.noUser")} description={t("learning.noUserDesc")} icon={UserX} />
</div>
)
}
@@ -40,33 +41,33 @@ export default async function StudentLearningPage() {
const cards = [
{
title: "Courses",
description: "Your enrolled classes.",
title: t("learning.courses"),
description: t("learning.coursesDesc"),
icon: BookOpen,
href: "/student/learning/courses",
stat: `${classes.length} enrolled`,
stat: t("learning.enrolled", { count: classes.length }),
},
{
title: "Assignments",
description: "Homework and practice.",
title: t("learning.assignments"),
description: t("learning.assignmentsDesc"),
icon: PenTool,
href: "/student/learning/assignments",
stat: `${pendingCount} pending${dueSoonCount > 0 ? ` · ${dueSoonCount} due soon` : ""}`,
stat: t("learning.pending", { count: pendingCount }) + (dueSoonCount > 0 ? t("learning.dueSoon", { count: dueSoonCount }) : ""),
},
{
title: "Textbooks",
description: "Browse course materials.",
title: t("learning.textbooks"),
description: t("learning.textbooksDesc"),
icon: Library,
href: "/student/learning/textbooks",
stat: `${textbooks.length} available`,
stat: t("learning.available", { count: textbooks.length }),
},
]
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Learning</h2>
<p className="text-muted-foreground">Your learning hub: courses, assignments, and textbooks.</p>
<h2 className="text-2xl font-bold tracking-tight">{t("learning.title")}</h2>
<p className="text-muted-foreground">{t("learning.description")}</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">