feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

主要变更:

- 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布

- 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item)

- 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验

- 新增 teacher/lesson-plans 页面 (列表/新建/编辑)

- 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot

- 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts

- 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false)

- 重构多模块 data-access/actions/组件, 修复权限校验与类型规范

- 同步架构文档 004/005 反映新增模块、导出、依赖关系

- 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
SpecialX
2026-06-22 01:06:16 +08:00
parent d8962aba96
commit 978d9a8309
327 changed files with 34070 additions and 5642 deletions

View File

@@ -1,27 +1,35 @@
import type { JSX } from "react"
import Link from "next/link"
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getAttendanceRecords } from "@/modules/attendance/data-access"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import type { AttendanceStatus } from "@/modules/attendance/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const VALID_STATUSES: ReadonlySet<string> = new Set([
"present",
"absent",
"late",
"early_leave",
"excused",
])
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
function parseAttendanceStatus(v?: string): AttendanceStatus | undefined {
return v && VALID_STATUSES.has(v) ? (v as AttendanceStatus) : undefined
}
export default async function TeacherAttendancePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await getAuthContext()
@@ -29,34 +37,35 @@ export default async function TeacherAttendancePage({
const status = getParam(sp, "status")
const date = getParam(sp, "date")
const classes = await getTeacherClasses()
const [classes, result] = await Promise.all([
getTeacherClasses(),
getAttendanceRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
status: status && status !== "all" ? parseAttendanceStatus(status) : undefined,
date: date && date.length > 0 ? date : undefined,
}),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const result = await getAttendanceRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
status: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined,
date: date && date.length > 0 ? date : undefined,
})
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">Attendance</h2>
<h1 className="text-2xl font-bold tracking-tight">Attendance</h1>
<p className="text-muted-foreground">Manage student attendance records.</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/attendance/stats">
<BarChart3 className="mr-2 h-4 w-4" />
<BarChart3 className="mr-2 h-4 w-4" aria-hidden="true" />
Statistics
</Link>
</Button>
<Button asChild>
<Link href="/teacher/attendance/sheet">
<PlusCircle className="mr-2 h-4 w-4" />
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
Take Attendance
</Link>
</Button>

View File

@@ -1,38 +1,33 @@
import type { JSX } from "react"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassStudentsForAttendance } from "@/modules/attendance/data-access"
import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
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 AttendanceSheetPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
}): Promise<JSX.Element> {
const sp = await searchParams
const defaultClassId = getParam(sp, "classId")
const defaultDate = getParam(sp, "date")
const classes = await getTeacherClasses()
const [classes, students] = await Promise.all([
getTeacherClasses(),
defaultClassId
? getClassStudentsForAttendance(defaultClassId)
: Promise.resolve([] as Awaited<ReturnType<typeof getClassStudentsForAttendance>>),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
let students: Array<{ id: string; name: string; email: string }> = []
if (defaultClassId) {
students = await getClassStudentsForAttendance(defaultClassId)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Take Attendance</h2>
<h1 className="text-2xl font-bold tracking-tight">Take Attendance</h1>
<p className="text-muted-foreground">
Select a class and date, then mark attendance for each student.
</p>

View File

@@ -1,24 +1,20 @@
import type { JSX } from "react"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { AttendanceStatsClassSelector } from "@/modules/attendance/components/attendance-stats-class-selector"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
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]
return Array.isArray(v) ? v[0] : v
}
export default async function AttendanceStatsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
}): Promise<JSX.Element> {
const sp = await searchParams
const classId = getParam(sp, "classId")
@@ -31,7 +27,7 @@ export default async function AttendanceStatsPage({
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
<h1 className="text-2xl font-bold tracking-tight">Attendance Statistics</h1>
<p className="text-muted-foreground">View class attendance statistics.</p>
</div>
<EmptyState
@@ -57,11 +53,11 @@ export default async function AttendanceStatsPage({
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
<h1 className="text-2xl font-bold tracking-tight">Attendance Statistics</h1>
<p className="text-muted-foreground">View class attendance statistics and trends.</p>
</div>
<StatsClassSelector
<AttendanceStatsClassSelector
classes={classOptions}
currentClassId={targetClassId}
startDate={startDate ?? ""}
@@ -72,7 +68,7 @@ export default async function AttendanceStatsPage({
<>
<AttendanceStatsCard stats={summary.stats} />
<div>
<h3 className="mb-4 text-lg font-semibold">Student Records</h3>
<h2 className="mb-4 text-lg font-semibold">Student Records</h2>
<AttendanceRecordList records={summary.studentRecords} />
</div>
</>
@@ -87,34 +83,3 @@ export default async function AttendanceStatsPage({
</div>
)
}
function StatsClassSelector({
classes,
currentClassId,
startDate,
endDate,
}: {
classes: Array<{ id: string; name: string }>
currentClassId: string
startDate: string
endDate: string
}) {
const dateParams = `${startDate ? `&startDate=${startDate}` : ""}${endDate ? `&endDate=${endDate}` : ""}`
return (
<div className="flex flex-wrap gap-2">
{classes.map((c) => (
<a
key={c.id}
href={`/teacher/attendance/stats?classId=${c.id}${dateParams}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
c.id === currentClassId
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
{c.name}
</a>
))}
</div>
)
}

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
@@ -14,21 +15,19 @@ export default async function ClassDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
}): Promise<JSX.Element> {
const { id } = await params
// Parallel data fetching
const [insights, students, schedule] = await Promise.all([
const [insights, students, schedule, studentScores] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 20 }),
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
getClassStudentSubjectScoresV2({ classId: id }),
])
if (!insights) return notFound()
// Fetch subject scores
const studentScores = await getClassStudentSubjectScoresV2({ classId: id })
// Data mapping for widgets
const assignmentSummaries = insights.assignments.map(a => ({
id: a.assignmentId,
@@ -85,17 +84,16 @@ export default async function ClassDetailPage({
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content Area (Left 2/3) */}
<div className="space-y-6 lg:col-span-2">
<ClassTrendsWidget assignments={assignmentSummaries} />
<ClassStudentsWidget
classId={insights.class.id}
students={studentSummaries}
/>
<div className="min-w-0 space-y-6 lg:col-span-2">
<ClassTrendsWidget assignments={assignmentSummaries} />
<ClassStudentsWidget
classId={insights.class.id}
students={studentSummaries}
/>
</div>
{/* Sidebar Area (Right 1/3) */}
<div className="space-y-6">
{/* <ClassQuickActions classId={insights.class.id} /> */}
<div className="min-w-0 space-y-6">
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
<ClassAssignmentsWidget
classId={insights.class.id}

View File

@@ -1,13 +1,10 @@
import type { JSX } from "react"
import { getClassSubjects, 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() {
export default async function MyClassesPage(): Promise<JSX.Element> {
const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()])
return (

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { Calendar } from "lucide-react"
@@ -6,17 +7,11 @@ 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"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
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> }) {
async function ScheduleResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const params = await searchParams
const classId = getParam(params, "classId")
@@ -62,7 +57,7 @@ function ScheduleResultsFallback() {
)
}
export default async function SchedulePage({ searchParams }: { searchParams: Promise<SearchParams> }) {
export default async function SchedulePage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const classes = await getTeacherClasses()
return (

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { User } from "lucide-react"
@@ -6,17 +7,11 @@ 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"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
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, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }) {
async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }): Promise<JSX.Element> {
const params = await searchParams
const q = getParam(params, "q") || undefined
@@ -80,7 +75,7 @@ function StudentsResultsFallback() {
)
}
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const classes = await getTeacherClasses()
// Logic to determine default class (first one available)

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { getCoursePlanById } from "@/modules/course-plans/data-access"
@@ -9,7 +10,7 @@ export default async function TeacherCoursePlanDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
}): Promise<JSX.Element> {
const { id } = await params
const plan = await getCoursePlanById(id)

View File

@@ -1,31 +1,34 @@
import { auth } from "@/auth"
import type { JSX } from "react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getCoursePlans } from "@/modules/course-plans/data-access"
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
import type { CoursePlanStatus } from "@/modules/course-plans/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const VALID_STATUSES: ReadonlySet<string> = new Set([
"planning",
"active",
"completed",
"paused",
])
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
function parseStatus(v?: string): CoursePlanStatus | undefined {
return v && VALID_STATUSES.has(v) ? (v as CoursePlanStatus) : undefined
}
const isValidStatus = (v?: string): v is CoursePlanStatus =>
v === "planning" || v === "active" || v === "completed" || v === "paused"
export default async function TeacherCoursePlansPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const session = await auth()
const teacherId = String(session?.user?.id ?? "")
}): Promise<JSX.Element> {
const ctx = await getAuthContext()
const teacherId = ctx.userId
const sp = await searchParams
const statusParam = getParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const status = parseStatus(statusParam)
const plans = teacherId
? await getCoursePlans({ teacherId, status })
@@ -34,14 +37,14 @@ export default async function TeacherCoursePlansPage({
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">My Course Plans</h2>
<h1 className="text-2xl font-bold tracking-tight">My Course Plans</h1>
<p className="text-muted-foreground">
View your course teaching plans and weekly schedules.
</p>
</div>
<CoursePlanList
plans={plans}
detailHrefBuilder={(id) => `/teacher/course-plans/${id}`}
detailBaseHref="/teacher/course-plans"
initialStatus={status}
/>
</div>

View File

@@ -1,26 +1,24 @@
import type { JSX } from "react"
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access";
import { getHomeworkAssignments, getHomeworkSubmissions, getTeacherGradeTrends } from "@/modules/homework/data-access";
import { db } from "@/shared/db";
import { users } from "@/shared/db/schema";
import { eq } from "drizzle-orm";
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access"
import { getHomeworkAssignments, getHomeworkSubmissions, getTeacherGradeTrends } from "@/modules/homework/data-access"
import { getUserBasicInfo } from "@/modules/users/data-access"
import { getAuthContext } from "@/shared/lib/auth-guard"
export const dynamic = "force-dynamic";
export const dynamic = "force-dynamic"
export default async function TeacherDashboardPage() {
const teacherId = await getTeacherIdForMutations();
export default async function TeacherDashboardPage(): Promise<JSX.Element> {
await getAuthContext()
const teacherId = await getTeacherIdForMutations()
const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
getTeacherClasses({ teacherId }),
getClassSchedule({ teacherId }),
getHomeworkAssignments({ creatorId: teacherId }),
getHomeworkSubmissions({ creatorId: teacherId }),
db.query.users.findFirst({
where: eq(users.id, teacherId),
columns: { name: true },
}),
getUserBasicInfo(teacherId),
getTeacherGradeTrends(teacherId),
]);
])
return (
<TeacherDashboardView

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { Stethoscope } from "lucide-react"
import { getAuthContext } from "@/shared/lib/auth-guard"
@@ -10,7 +11,7 @@ export default async function ClassDiagnosticPage({
params,
}: {
params: Promise<{ classId: string }>
}) {
}): Promise<JSX.Element> {
const { classId } = await params
const ctx = await getAuthContext()
@@ -31,10 +32,10 @@ export default async function ClassDiagnosticPage({
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Stethoscope className="h-6 w-6" />
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Stethoscope className="h-6 w-6" aria-hidden="true" />
Class Diagnostic
</h2>
</h1>
<p className="text-muted-foreground">
Class-level knowledge point mastery overview and student attention list.
</p>

View File

@@ -1,22 +1,37 @@
import type { JSX } from "react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
import { ReportList } from "@/modules/diagnostic/components/report-list"
import type { DiagnosticReportType, DiagnosticReportStatus } from "@/modules/diagnostic/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const VALID_REPORT_TYPES: ReadonlySet<string> = new Set([
"individual",
"class",
"grade",
])
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
const VALID_REPORT_STATUSES: ReadonlySet<string> = new Set([
"draft",
"published",
"archived",
])
function parseReportType(v?: string): DiagnosticReportType | undefined {
return v && VALID_REPORT_TYPES.has(v) ? (v as DiagnosticReportType) : undefined
}
function parseReportStatus(v?: string): DiagnosticReportStatus | undefined {
return v && VALID_REPORT_STATUSES.has(v) ? (v as DiagnosticReportStatus) : undefined
}
export default async function TeacherDiagnosticPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await getAuthContext()
@@ -24,8 +39,8 @@ export default async function TeacherDiagnosticPage({
const status = getParam(sp, "status")
const reports = await getDiagnosticReports({
reportType: reportType && reportType !== "all" ? (reportType as DiagnosticReportType) : undefined,
status: status && status !== "all" ? (status as DiagnosticReportStatus) : undefined,
reportType: reportType && reportType !== "all" ? parseReportType(reportType) : undefined,
status: status && status !== "all" ? parseReportStatus(status) : undefined,
})
// 学生角色仅查看自己的报告;其他角色查看全部
@@ -37,7 +52,7 @@ export default async function TeacherDiagnosticPage({
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Learning Diagnostic</h2>
<h1 className="text-2xl font-bold tracking-tight">Learning Diagnostic</h1>
<p className="text-muted-foreground">
View and manage diagnostic reports based on knowledge point mastery.
</p>

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { Stethoscope } from "lucide-react"
import { getAuthContext } from "@/shared/lib/auth-guard"
@@ -15,7 +16,7 @@ export default async function StudentDiagnosticPage({
params,
}: {
params: Promise<{ studentId: string }>
}) {
}): Promise<JSX.Element> {
const { studentId } = await params
const ctx = await getAuthContext()
@@ -27,16 +28,15 @@ export default async function StudentDiagnosticPage({
notFound()
}
const [summary, reports] = await Promise.all([
const [summary, reports, classStats] = await Promise.all([
getStudentMasterySummary(studentId),
getDiagnosticReports({ studentId }),
getKnowledgePointStats(),
])
// 班级平均掌握度(用于雷达图对比)
let classAverageMastery: MasteryRadarPoint[] | undefined
if (summary) {
// 通过学生所在班级获取班级平均
const classStats = await getKnowledgePointStats()
classAverageMastery = classStats.map((k) => ({
knowledgePoint: k.knowledgePointName,
student: 0,
@@ -47,10 +47,10 @@ export default async function StudentDiagnosticPage({
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Stethoscope className="h-6 w-6" />
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Stethoscope className="h-6 w-6" aria-hidden="true" />
Student Diagnostic
</h2>
</h1>
<p className="text-muted-foreground">
Knowledge point mastery analysis and diagnostic reports.
</p>

View File

@@ -1,31 +1,34 @@
import { auth } from "@/auth"
import type { JSX } from "react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getElectiveCourses } from "@/modules/elective/data-access"
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
import type { ElectiveCourseStatus } from "@/modules/elective/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const VALID_STATUSES: ReadonlySet<string> = new Set([
"draft",
"open",
"closed",
"cancelled",
])
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
function parseStatus(v?: string): ElectiveCourseStatus | undefined {
return v && VALID_STATUSES.has(v) ? (v as ElectiveCourseStatus) : undefined
}
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
export default async function TeacherElectivePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const session = await auth()
const teacherId = String(session?.user?.id ?? "")
}): Promise<JSX.Element> {
const ctx = await getAuthContext()
const teacherId = ctx.userId
const sp = await searchParams
const statusParam = getParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const status = parseStatus(statusParam)
const courses = teacherId
? await getElectiveCourses({ teacherId, status })
@@ -34,7 +37,7 @@ export default async function TeacherElectivePage({
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">My Elective Courses</h2>
<h1 className="text-2xl font-bold tracking-tight">My Elective Courses</h1>
<p className="text-muted-foreground">
View and manage the elective courses you teach.
</p>
@@ -43,7 +46,7 @@ export default async function TeacherElectivePage({
courses={courses}
canManage
createHref="/admin/elective/create"
editHrefBuilder={(id) => `/admin/elective/${id}/edit`}
editBaseHref="/admin/elective"
/>
</div>
)

View File

@@ -1,36 +1,45 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { ExamAssembly } from "@/modules/exams/components/exam-assembly"
import { getExamById } from "@/modules/exams/data-access"
import { getQuestions } from "@/modules/questions/data-access"
import { normalizeStructure } from "@/modules/exams/utils/normalize-structure"
import type { Question } from "@/modules/questions/types"
import type { ExamNode } from "@/modules/exams/components/assembly/selected-question-list"
import { createId } from "@paralleldrive/cuid2"
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }) {
export const dynamic = "force-dynamic"
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
const { id } = await params
const exam = await getExamById(id)
if (!exam) return notFound()
// Fetch initial questions for the bank (pagination handled by client)
const { data: questionsData } = await getQuestions({ pageSize: 20 })
// Run both queries in parallel since the second depends on exam.questions IDs
const initialSelected = (exam.questions || []).map(q => ({
id: q.id,
score: q.score || 0
}))
const selectedQuestionIds = initialSelected.map((s) => s.id)
const { data: selectedQuestionsData } = selectedQuestionIds.length
? await getQuestions({ ids: selectedQuestionIds, pageSize: Math.max(10, selectedQuestionIds.length) })
: { data: [] as typeof questionsData }
const [bankResult, selectedResult] = await Promise.all([
getQuestions({ pageSize: 20 }),
selectedQuestionIds.length
? getQuestions({ ids: selectedQuestionIds, pageSize: Math.max(10, selectedQuestionIds.length) })
: Promise.resolve({ data: [] as Awaited<ReturnType<typeof getQuestions>>["data"] }),
])
const questionsData = bankResult.data
const selectedQuestionsData = selectedResult.data
type RawQuestion = (typeof questionsData)[number]
const toQuestionOption = (q: RawQuestion): Question => ({
id: q.id,
content: q.content as Question["content"],
type: q.type as Question["type"],
content: q.content,
type: q.type,
difficulty: q.difficulty ?? 1,
createdAt: new Date(q.createdAt),
updatedAt: new Date(q.updatedAt),
@@ -49,49 +58,8 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
for (const q of selectedQuestionsData) questionOptionsById.set(q.id, toQuestionOption(q))
const questionOptions = Array.from(questionOptionsById.values())
const normalizeStructure = (nodes: unknown): ExamNode[] => {
const seen = new Set<string>()
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null
const normalize = (raw: unknown[]): ExamNode[] => {
return raw
.map((n) => {
if (!isRecord(n)) return null
const type = n.type
if (type !== "group" && type !== "question") return null
let id = typeof n.id === "string" && n.id.length > 0 ? n.id : createId()
while (seen.has(id)) id = createId()
seen.add(id)
if (type === "group") {
return {
id,
type: "group",
title: typeof n.title === "string" ? n.title : undefined,
children: normalize(Array.isArray(n.children) ? n.children : []),
} satisfies ExamNode
}
if (typeof n.questionId !== "string" || n.questionId.length === 0) return null
return {
id,
type: "question",
questionId: n.questionId,
score: typeof n.score === "number" ? n.score : undefined,
} satisfies ExamNode
})
.filter(Boolean) as ExamNode[]
}
if (!Array.isArray(nodes)) return []
return normalize(nodes)
}
let initialStructure: ExamNode[] = normalizeStructure(exam.structure)
if (initialStructure.length === 0 && initialSelected.length > 0) {
initialStructure = initialSelected.map((s) => ({
id: createId(),
@@ -103,6 +71,10 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
return (
<div className="flex h-full flex-col space-y-4 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
<p className="text-muted-foreground">Assemble questions for your exam.</p>
</div>
<ExamAssembly
examId={exam.id}
title={exam.title}

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
@@ -16,7 +17,7 @@ export default async function ExamProctoringPage({
params,
}: {
params: Promise<{ id: string }>
}) {
}): Promise<JSX.Element> {
try {
await requirePermission(Permissions.EXAM_PROCTOR)
} catch (error) {
@@ -49,6 +50,10 @@ export default async function ExamProctoringPage({
return (
<div className="flex h-full flex-col space-y-4 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Exam Proctoring</h1>
<p className="text-muted-foreground">Monitor student activity during the exam.</p>
</div>
<ProctoringDashboard examId={id} initialData={initialData} />
</div>
)

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { Suspense } from "react"
import Link from "next/link"
import { Button } from "@/shared/components/ui/button"
@@ -9,16 +10,10 @@ import { examColumns } from "@/modules/exams/components/exam-columns"
import { ExamFilters } from "@/modules/exams/components/exam-filters"
import { getExams } from "@/modules/exams/data-access"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { FileText, PlusCircle } from "lucide-react"
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 ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
async function ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const params = await searchParams
const { dataScope } = await getAuthContext()
@@ -62,7 +57,7 @@ async function ExamsResults({ searchParams }: { searchParams: Promise<SearchPara
<div className="flex items-center gap-2">
<Button asChild size="sm">
<Link href="/teacher/exams/create" className="inline-flex items-center gap-2">
<PlusCircle className="h-4 w-4" />
<PlusCircle className="h-4 w-4" aria-hidden="true" />
Create Exam
</Link>
</Button>
@@ -131,7 +126,7 @@ export default async function AllExamsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
}): Promise<JSX.Element> {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-4">

View File

@@ -1,10 +1,18 @@
import type { JSX } from "react"
import { ExamForm } from "@/modules/exams/components/exam-form"
export default function CreateExamPage() {
export const dynamic = "force-dynamic"
export default function CreateExamPage(): JSX.Element {
return (
<div className="flex w-full justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<ExamForm />
<div className="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Create Exam</h1>
<p className="text-muted-foreground">Configure a new exam for your classes.</p>
</div>
<ExamForm />
</div>
</div>
)
}

View File

@@ -1,21 +0,0 @@
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="rounded-md border p-4">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[95%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-4 w-[85%]" />
</div>
</div>
</div>
)
}

View File

@@ -1,14 +1,14 @@
import type { JSX } from "react"
import Link from "next/link"
import { BarChart3, ArrowLeft } from "lucide-react"
import { asc } from "drizzle-orm"
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGrades } from "@/modules/school/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import {
getClassComparison,
@@ -20,21 +20,15 @@ import { GradeTrendChart } from "@/modules/grades/components/grade-trend-chart"
import { ClassComparisonChart } from "@/modules/grades/components/class-comparison-chart"
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
import { AnalyticsFilters } from "@/modules/grades/components/analytics-filters"
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 GradeAnalyticsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await getAuthContext()
@@ -45,16 +39,14 @@ export default async function GradeAnalyticsPage({
const [classes, allGrades, allSubjects] = await Promise.all([
getTeacherClasses(),
getGrades(),
db.query.subjects.findMany({
orderBy: [asc(subjects.order), asc(subjects.name)],
}),
getSubjectOptions(),
])
if (classes.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
<h1 className="text-2xl font-bold tracking-tight">Grade Analytics</h1>
<p className="text-muted-foreground">
Trend analysis, class comparisons, and score distributions.
</p>
@@ -106,14 +98,14 @@ export default async function GradeAnalyticsPage({
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
<h1 className="text-2xl font-bold tracking-tight">Grade Analytics</h1>
<p className="text-muted-foreground">
Trend analysis, class comparisons, and score distributions.
</p>
</div>
<Button asChild variant="outline">
<Link href="/teacher/grades">
<ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
Back to Grades
</Link>
</Button>
@@ -137,123 +129,3 @@ export default async function GradeAnalyticsPage({
</div>
)
}
interface AnalyticsFiltersProps {
classes: Array<{ id: string; name: string }>
grades: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
currentClassId: string
currentSubjectId: string
currentGradeId: string
}
function AnalyticsFilters({
classes,
grades,
subjects,
currentClassId,
currentSubjectId,
currentGradeId,
}: AnalyticsFiltersProps) {
const buildHref = (overrides: {
classId?: string
subjectId?: string
gradeId?: string
}) => {
const params = new URLSearchParams()
params.set(
"classId",
overrides.classId !== undefined ? overrides.classId : currentClassId
)
params.set(
"subjectId",
overrides.subjectId !== undefined ? overrides.subjectId : currentSubjectId
)
if (
overrides.gradeId !== undefined
? overrides.gradeId
: currentGradeId
) {
params.set(
"gradeId",
overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId
)
}
return `/teacher/grades/analytics?${params.toString()}`
}
return (
<div className="rounded-lg border bg-card p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Class</div>
<div className="flex flex-wrap gap-1.5">
{classes.map((c) => (
<a
key={c.id}
href={buildHref({ classId: c.id })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
c.id === currentClassId
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
{c.name}
</a>
))}
</div>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Subject</div>
<div className="flex flex-wrap gap-1.5">
<a
href={buildHref({ subjectId: "all" })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
currentSubjectId === "all"
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
All
</a>
{subjects.map((s) => (
<a
key={s.id}
href={buildHref({ subjectId: s.id })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
s.id === currentSubjectId
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
{s.name}
</a>
))}
</div>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Grade (for class comparison)
</div>
<div className="flex flex-wrap gap-1.5">
{grades.map((g) => (
<a
key={g.id}
href={buildHref({ gradeId: g.id })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
g.id === currentGradeId
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
{g.name}
</a>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,42 +1,37 @@
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { asc } from "drizzle-orm"
import type { JSX } from "react"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassStudentsForEntry } from "@/modules/grades/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
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 BatchEntryPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
export default async function BatchEntryPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const defaultClassId = getParam(sp, "classId")
const defaultSubjectId = getParam(sp, "subjectId")
const [classes, allSubjects] = await Promise.all([
const [classes, allSubjects, students] = await Promise.all([
getTeacherClasses(),
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
getSubjectOptions(),
defaultClassId
? getClassStudentsForEntry(defaultClassId)
: Promise.resolve([] as Awaited<ReturnType<typeof getClassStudentsForEntry>>),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
let students: Array<{ id: string; name: string; email: string }> = []
if (defaultClassId) {
students = await getClassStudentsForEntry(defaultClassId)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h2>
<h1 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h1>
<p className="text-muted-foreground">Enter grades for all students in a class at once.</p>
</div>

View File

@@ -1,27 +1,36 @@
import type { JSX } from "react"
import Link from "next/link"
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { asc } from "drizzle-orm"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGradeRecords } from "@/modules/grades/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters"
import { GradeRecordList } from "@/modules/grades/components/grade-record-list"
import { ExportButton } from "@/modules/grades/components/export-button"
import type { GradeRecordType, GradeRecordSemester } from "@/modules/grades/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const VALID_GRADE_TYPES: ReadonlySet<string> = new Set(["exam", "quiz", "homework", "other"])
const VALID_SEMESTERS: ReadonlySet<string> = new Set(["1", "2"])
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
function parseGradeType(v?: string): GradeRecordType | undefined {
return v && VALID_GRADE_TYPES.has(v) ? (v as GradeRecordType) : undefined
}
export default async function TeacherGradesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
function parseSemester(v?: string): GradeRecordSemester | undefined {
return v && VALID_SEMESTERS.has(v) ? (v as GradeRecordSemester) : undefined
}
export default async function TeacherGradesPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await getAuthContext()
@@ -30,20 +39,19 @@ export default async function TeacherGradesPage({ searchParams }: { searchParams
const type = getParam(sp, "type")
const semester = getParam(sp, "semester")
const [classes, allSubjects] = await Promise.all([
const [classes, allSubjects, records] = await Promise.all([
getTeacherClasses(),
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
getSubjectOptions(),
getGradeRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
type: type && type !== "all" ? parseGradeType(type) : undefined,
semester: semester && semester !== "all" ? parseSemester(semester) : undefined,
}),
])
const records = await getGradeRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
type: type && type !== "all" ? (type as "exam" | "quiz" | "homework" | "other") : undefined,
semester: semester && semester !== "all" ? (semester as "1" | "2") : undefined,
})
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
@@ -51,19 +59,19 @@ export default async function TeacherGradesPage({ searchParams }: { searchParams
<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">Grades</h2>
<h1 className="text-2xl font-bold tracking-tight">Grades</h1>
<p className="text-muted-foreground">Manage student grade records.</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/grades/stats">
<BarChart3 className="mr-2 h-4 w-4" />
<BarChart3 className="mr-2 h-4 w-4" aria-hidden="true" />
Statistics
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/teacher/grades/entry">
<ClipboardList className="mr-2 h-4 w-4" />
<ClipboardList className="mr-2 h-4 w-4" aria-hidden="true" />
Batch Entry
</Link>
</Button>
@@ -74,7 +82,7 @@ export default async function TeacherGradesPage({ searchParams }: { searchParams
/>
<Button asChild>
<Link href="/teacher/grades/entry">
<PlusCircle className="mr-2 h-4 w-4" />
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
Record Grades
</Link>
</Button>

View File

@@ -1,23 +1,21 @@
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { asc } from "drizzle-orm"
import type { JSX } from "react"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassGradeStatsWithMeta, getClassRanking } from "@/modules/grades/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { ClassGradeReport } from "@/modules/grades/components/class-grade-report"
import { ExportButton } from "@/modules/grades/components/export-button"
import { StatsClassSelector } from "@/modules/grades/components/stats-class-selector"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
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]
return Array.isArray(v) ? v[0] : v
}
export default async function StatsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
export default async function StatsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const classId = getParam(sp, "classId")
@@ -25,14 +23,14 @@ export default async function StatsPage({ searchParams }: { searchParams: Promis
const [classes, allSubjects] = await Promise.all([
getTeacherClasses(),
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
getSubjectOptions(),
])
if (classes.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
<h1 className="text-2xl font-bold tracking-tight">Grade Statistics</h1>
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
</div>
<EmptyState
@@ -60,7 +58,7 @@ export default async function StatsPage({ searchParams }: { searchParams: Promis
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
<h1 className="text-2xl font-bold tracking-tight">Grade Statistics</h1>
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
</div>
<ExportButton
@@ -82,58 +80,3 @@ export default async function StatsPage({ searchParams }: { searchParams: Promis
</div>
)
}
function StatsClassSelector({
classes,
subjects,
currentClassId,
currentSubjectId,
}: {
classes: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
currentClassId: string
currentSubjectId: string
}) {
return (
<div className="flex flex-wrap gap-2">
{classes.map((c) => (
<a
key={c.id}
href={`/teacher/grades/stats?classId=${c.id}${currentSubjectId !== "all" ? `&subjectId=${currentSubjectId}` : ""}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
c.id === currentClassId
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
{c.name}
</a>
))}
<div className="ml-auto flex flex-wrap gap-2">
<a
href={`/teacher/grades/stats?classId=${currentClassId}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
currentSubjectId === "all"
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
All Subjects
</a>
{subjects.map((s) => (
<a
key={s.id}
href={`/teacher/grades/stats?classId=${currentClassId}&subjectId=${s.id}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
s.id === currentSubjectId
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
{s.name}
</a>
))}
</div>
</div>
)
}

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
@@ -10,7 +11,7 @@ import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-re
export const dynamic = "force-dynamic"
export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
const { id } = await params
const analytics = await getHomeworkAssignmentAnalytics(id)
@@ -23,17 +24,17 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
{/* Header */}
<div className="border-b bg-background px-8 py-5">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex flex-col gap-2">
<div className="min-w-0 flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Link href="/teacher/homework/assignments" className="flex items-center hover:text-foreground transition-colors">
<ChevronLeft className="h-4 w-4 mr-1" />
<ChevronLeft className="h-4 w-4 mr-1" aria-hidden="true" />
Assignments
</Link>
<span>/</span>
<span aria-hidden="true">/</span>
<span>Details</span>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-foreground">{assignment.title}</h1>
<h1 className="text-2xl font-bold tracking-tight text-foreground line-clamp-2">{assignment.title}</h1>
<Badge variant={assignment.status === "published" ? "default" : "secondary"} className="capitalize">
{assignment.status}
</Badge>
@@ -44,7 +45,7 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
<div className="flex items-center gap-3 mt-2 md:mt-0">
<Button asChild variant="outline" className="shadow-sm">
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>
<Users className="h-4 w-4 mr-2" />
<Users className="h-4 w-4 mr-2" aria-hidden="true" />
View Submissions
</Link>
</Button>
@@ -54,20 +55,20 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
{/* Quick Stats Row */}
<div className="flex flex-wrap gap-x-8 gap-y-2 mt-6 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>Due: <span className="font-medium text-foreground">{assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}</span></span>
<Calendar className="h-4 w-4" aria-hidden="true" />
<span>Due: <span className="font-medium text-foreground tabular-nums">{assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}</span></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>Targets: <span className="font-medium text-foreground">{assignment.targetCount}</span></span>
<Users className="h-4 w-4" aria-hidden="true" />
<span>Targets: <span className="font-medium text-foreground tabular-nums">{assignment.targetCount}</span></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4" />
<span>Submissions: <span className="font-medium text-foreground">{assignment.submissionCount}</span></span>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
<span>Submissions: <span className="font-medium text-foreground tabular-nums">{assignment.submissionCount}</span></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<BarChart3 className="h-4 w-4" />
<span>Graded: <span className="font-medium text-foreground">{gradedSampleCount}</span></span>
<BarChart3 className="h-4 w-4" aria-hidden="true" />
<span>Graded: <span className="font-medium text-foreground tabular-nums">{gradedSampleCount}</span></span>
</div>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { Badge } from "@/shared/components/ui/badge"
@@ -15,27 +16,28 @@ import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/hom
export const dynamic = "force-dynamic"
export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }) {
export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
const { id } = await params
const assignment = await getHomeworkAssignmentById(id)
const [assignment, submissions] = await Promise.all([
getHomeworkAssignmentById(id),
getHomeworkSubmissions({ assignmentId: id }),
])
if (!assignment) return notFound()
const submissions = await getHomeworkSubmissions({ assignmentId: id })
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">Submissions</h2>
<p className="text-muted-foreground">{assignment.title}</p>
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Submissions</h1>
<p className="text-muted-foreground truncate">{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>
<span aria-hidden="true"></span>
<span className="tabular-nums">Targets: {assignment.targetCount}</span>
<span aria-hidden="true"></span>
<span className="tabular-nums">Submitted: {assignment.submittedCount}</span>
<span aria-hidden="true"></span>
<span className="tabular-nums">Graded: {assignment.gradedCount}</span>
</div>
</div>
<div className="flex items-center gap-2">
@@ -62,15 +64,15 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
<TableBody>
{submissions.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.studentName}</TableCell>
<TableCell className="font-medium truncate max-w-[160px]">{s.studentName}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.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 tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell>
<Link href={`/teacher/homework/submissions/${s.id}`} className="text-sm underline-offset-4 hover:underline">
Grade

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import Link from "next/link"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
@@ -11,6 +12,7 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react"
@@ -18,36 +20,33 @@ import { getTeacherIdForMutations } from "@/modules/classes/data-access"
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 AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const sp = await searchParams
const classId = getParam(sp, "classId") || undefined
const rawClassId = getParam(sp, "classId")
const creatorId = await getTeacherIdForMutations()
// Only fetch classes list when a class filter is active — needed to resolve
// the class name for display. When no filter is applied, skip the query to
// avoid an unnecessary DB round-trip.
const filteredClassId = rawClassId && rawClassId !== "all" ? rawClassId : null
const [assignments, classes] = await Promise.all([
getHomeworkAssignments({ creatorId, classId: classId && classId !== "all" ? classId : undefined }),
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
getHomeworkAssignments({ creatorId, classId: filteredClassId ?? undefined }),
filteredClassId ? getTeacherClasses() : Promise.resolve([]),
])
const hasAssignments = assignments.length > 0
const className = classId && classId !== "all" ? classes.find((c) => c.id === classId)?.name : undefined
const className = filteredClassId ? classes.find((c) => c.id === filteredClassId)?.name : undefined
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">Assignments</h2>
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
<p className="text-muted-foreground">
{classId && classId !== "all" ? `Filtered by class: ${className ?? classId}` : "Manage homework assignments."}
{filteredClassId ? `Filtered by class: ${className ?? filteredClassId}` : "Manage homework assignments."}
</p>
</div>
<div className="flex items-center gap-2">
{classId && classId !== "all" ? (
{filteredClassId ? (
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Clear filter</Link>
</Button>
@@ -55,12 +54,12 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
<Button asChild>
<Link
href={
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
filteredClassId
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(filteredClassId)}`
: "/teacher/homework/assignments/create"
}
>
<PlusCircle className="mr-2 h-4 w-4" />
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
Create Assignment
</Link>
</Button>
@@ -70,13 +69,13 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
{!hasAssignments ? (
<EmptyState
title="No assignments"
description={classId && classId !== "all" ? "No assignments for this class yet." : "You haven't created any assignments yet."}
description={filteredClassId ? "No assignments for this class yet." : "You haven't created any assignments yet."}
icon={PenTool}
action={{
label: "Create Assignment",
href:
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
filteredClassId
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(filteredClassId)}`
: "/teacher/homework/assignments/create",
}}
/>
@@ -96,7 +95,10 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.id}`} className="hover:underline">
<Link
href={`/teacher/homework/assignments/${a.id}`}
className="hover:underline line-clamp-2 max-w-[240px]"
>
{a.title}
</Link>
</TableCell>
@@ -105,9 +107,9 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-muted-foreground">{a.sourceExamTitle}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-muted-foreground truncate max-w-[200px]">{a.sourceExamTitle}</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{formatDate(a.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access"
import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view"
@@ -5,7 +6,7 @@ import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) {
export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }): Promise<JSX.Element> {
const { submissionId } = await params
const submission = await getHomeworkSubmissionDetails(submissionId)
@@ -14,15 +15,15 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">{submission.assignmentTitle}</h2>
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight line-clamp-2">{submission.assignmentTitle}</h1>
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
<span>
Student: <span className="font-medium text-foreground">{submission.studentName}</span>
</span>
<span></span>
<span>Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
<span></span>
<span aria-hidden="true"></span>
<span className="tabular-nums">Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
<span aria-hidden="true"></span>
<span className="capitalize">Status: {submission.status}</span>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import Link from "next/link"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
@@ -16,7 +17,7 @@ import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
export default async function SubmissionsPage() {
export default async function SubmissionsPage(): Promise<JSX.Element> {
const creatorId = await getTeacherIdForMutations()
const assignments = await getHomeworkAssignmentReviewList({ creatorId })
const hasAssignments = assignments.length > 0
@@ -25,7 +26,7 @@ export default async function SubmissionsPage() {
<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">Submissions</h2>
<h1 className="text-2xl font-bold tracking-tight">Submissions</h1>
<p className="text-muted-foreground">
Review homework by assignment.
</p>
@@ -55,20 +56,23 @@ export default async function SubmissionsPage() {
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.id}/submissions`} className="hover:underline">
<Link
href={`/teacher/homework/assignments/${a.id}/submissions`}
className="hover:underline line-clamp-2 max-w-[240px]"
>
{a.title}
</Link>
<div className="text-xs text-muted-foreground">{a.sourceExamTitle}</div>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">{a.sourceExamTitle}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</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>
<TableCell className="text-muted-foreground tabular-nums">{a.dueAt ? formatDate(a.dueAt) : "-"}</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>
</TableRow>
))}
</TableBody>

View File

@@ -0,0 +1,38 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getAuthContext } from "@/shared/lib/auth-guard"
export const dynamic = "force-dynamic"
export default async function EditLessonPlanPage({
params,
}: {
params: Promise<{ planId: string }>
}): Promise<JSX.Element> {
const { planId } = await params
const ctx = await getAuthContext()
const [plan, teacherClasses] = await Promise.all([
getLessonPlanById(planId, ctx.userId),
getTeacherClasses({ teacherId: ctx.userId }),
])
if (!plan) notFound()
const classes = teacherClasses.map((c) => ({ id: c.id, name: c.name }))
return (
<div className="h-[calc(100vh-4rem)]">
<LessonPlanEditor
planId={plan.id}
initialTitle={plan.title}
initialDoc={plan.content}
textbookId={plan.textbookId ?? undefined}
chapterId={plan.chapterId ?? undefined}
classes={classes}
/>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type { JSX } from "react"
import Link from "next/link"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker"
export const dynamic = "force-dynamic"
export default function NewLessonPlanPage(): JSX.Element {
return (
<div className="p-6">
<div className="mb-6 flex items-center gap-4">
<Button asChild variant="ghost" size="icon">
<Link href="/teacher/lesson-plans" aria-label="Back to lesson plans">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<h1 className="text-2xl font-bold tracking-tight">New Lesson Plan</h1>
</div>
<TemplatePicker />
</div>
)
}

View File

@@ -0,0 +1,39 @@
import type { JSX } from "react"
import Link from "next/link"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
export const dynamic = "force-dynamic"
export default async function LessonPlansPage(): Promise<JSX.Element> {
const ctx = await getAuthContext()
const [items, subjects] = await Promise.all([
getLessonPlans({}, ctx.dataScope, ctx.userId),
getSubjectOptions(),
])
return (
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight">My Lesson Plans</h1>
<p className="text-muted-foreground">
Manage your lesson preparation and teaching plans.
</p>
</div>
<Button asChild>
<Link href="/teacher/lesson-plans/new">
<Plus className="h-4 w-4 mr-2" aria-hidden="true" />
New Lesson Plan
</Link>
</Button>
</div>
<LessonPlanList initialItems={items} subjects={subjects} />
</div>
)
}

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { ClipboardList } from "lucide-react"
@@ -8,16 +9,24 @@ import { CreateQuestionButton } from "@/modules/questions/components/create-ques
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { getQuestions } from "@/modules/questions/data-access"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import type { QuestionType } from "@/modules/questions/types"
type SearchParams = { [key: string]: string | string[] | undefined }
export const dynamic = "force-dynamic"
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
const VALID_QUESTION_TYPES: ReadonlySet<string> = new Set([
"single_choice",
"multiple_choice",
"text",
"judgment",
"composite",
])
function parseQuestionType(v?: string): QuestionType | undefined {
return v && VALID_QUESTION_TYPES.has(v) ? (v as QuestionType) : undefined
}
async function QuestionBankResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
async function QuestionBankResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const params = await searchParams
const q = getParam(params, "q")
@@ -25,14 +34,7 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
const difficulty = getParam(params, "difficulty")
const knowledgePointId = getParam(params, "kp")
const questionType: QuestionType | undefined =
type === "single_choice" ||
type === "multiple_choice" ||
type === "text" ||
type === "judgment" ||
type === "composite"
? type
: undefined
const questionType = parseQuestionType(type)
const { data: questions } = await getQuestions({
q: q || undefined,
@@ -91,12 +93,12 @@ export default async function QuestionBankPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
}): Promise<JSX.Element> {
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">Question Bank</h2>
<h1 className="text-2xl font-bold tracking-tight">Question Bank</h1>
<p className="text-muted-foreground">
Manage your question repository for exams and assignments.
</p>

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { ClipboardList } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -12,7 +13,7 @@ import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-cha
export const dynamic = "force-dynamic"
export default async function TeacherScheduleChangesPage() {
export default async function TeacherScheduleChangesPage(): Promise<JSX.Element> {
const ctx = await getAuthContext()
// Teachers see only their own requests; admins landing here see all.
@@ -34,7 +35,7 @@ export default async function TeacherScheduleChangesPage() {
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">Schedule Change Requests</h2>
<h1 className="text-2xl font-bold tracking-tight">Schedule Change Requests</h1>
<p className="text-muted-foreground">
Submit a schedule change or substitute teacher request, and track its status.
</p>
@@ -51,7 +52,7 @@ export default async function TeacherScheduleChangesPage() {
<ScheduleChangeForm classes={classOptions} teachers={teacherOptions} />
<div className="space-y-2">
<h3 className="text-lg font-semibold">My Requests</h3>
<h2 className="text-lg font-semibold">My Requests</h2>
{items.length === 0 ? (
<EmptyState
icon={ClipboardList}

View File

@@ -1,5 +1,5 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
import { Separator } from "@/shared/components/ui/separator";
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Separator } from "@/shared/components/ui/separator"
export default function Loading() {
return (
@@ -24,43 +24,43 @@ export default function Loading() {
<Skeleton className="h-6 w-40" />
<Skeleton className="h-8 w-24" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-6 w-6 rounded-md" />
<Skeleton className="h-6 w-full rounded-md" />
</div>
))}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-6 w-6 rounded-md" />
<Skeleton className="h-6 w-full rounded-md" />
</div>
))}
</div>
</div>
{/* Sidebar Skeleton */}
<div className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-4">
<Skeleton className="h-6 w-32 mb-4" />
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-5 w-32" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-20 w-full" />
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-4 w-24" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-4 w-24" />
</div>
</div>
<Skeleton className="h-6 w-32 mb-4" />
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-5 w-32" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-20 w-full" />
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-4 w-24" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</div>
</div>
</div>
</div>
);
)
}

View File

@@ -1,29 +1,30 @@
import { notFound } from "next/navigation";
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, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader";
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
import type { JSX } from "react"
import { notFound } from "next/navigation"
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, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"
export const dynamic = "force-dynamic"
export default async function TextbookDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
const { id } = await params
const [textbook, chapters, knowledgePoints] = await Promise.all([
getTextbookById(id),
getChaptersByTextbookId(id),
getKnowledgePointsByTextbookId(id),
]);
])
if (!textbook) {
notFound();
notFound()
}
return (
@@ -31,33 +32,33 @@ export default async function TextbookDetailPage({
{/* Header / Nav (Fixed height) */}
<div className="flex items-center gap-4 py-4 border-b shrink-0 bg-background z-10">
<Button variant="ghost" size="icon" asChild>
<Link href="/teacher/textbooks">
<ArrowLeft className="h-4 w-4" />
<Link href="/teacher/textbooks" aria-label="Back to textbooks">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<div className="flex-1">
<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>
<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 className="flex gap-2">
<TextbookSettingsDialog textbook={textbook} />
<TextbookSettingsDialog textbook={textbook} />
</div>
</div>
{/* Main Content Layout (Flex grow) */}
<div className="flex-1 overflow-hidden pt-6">
<TextbookReader
chapters={chapters}
knowledgePoints={knowledgePoints}
textbookId={id}
canEdit={true}
<TextbookReader
chapters={chapters}
knowledgePoints={knowledgePoints}
textbookId={id}
canEdit={true}
/>
</div>
</div>
);
)
}

View File

@@ -1,5 +1,5 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
export default function Loading() {
return (
@@ -34,15 +34,15 @@ export default function Loading() {
<Skeleton className="h-6 w-full" />
</CardHeader>
<CardContent className="p-4 pt-0 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-32" />
</CardContent>
<CardFooter className="p-4 pt-0 mt-auto">
<Skeleton className="h-6 w-full rounded-md" />
<Skeleton className="h-6 w-full rounded-md" />
</CardFooter>
</Card>
))}
</div>
</div>
);
)
}

View File

@@ -1,21 +1,16 @@
import type { JSX } from "react"
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 { 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"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
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> }) {
async function TextbooksResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const params = await searchParams
const q = getParam(params, "q") || undefined
@@ -47,8 +42,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
)
}
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
return (
<div className="space-y-6 p-8">
{/* Page Header */}
@@ -70,5 +64,5 @@ export default async function TextbooksPage({ searchParams }: { searchParams: Pr
<TextbooksResults searchParams={searchParams} />
</Suspense>
</div>
);
)
}