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
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:
@@ -1,16 +1,23 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getAnnouncementById } from "@/modules/announcements/data-access"
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑公告 - Next_Edu",
|
||||
description: "更新公告详情",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditAnnouncementPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
|
||||
const [announcement, grades] = await Promise.all([
|
||||
@@ -23,8 +30,8 @@ export default async function EditAnnouncementPage({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Edit Announcement</h2>
|
||||
<p className="text-muted-foreground">Update the announcement details below.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">编辑公告</h2>
|
||||
<p className="text-muted-foreground">更新公告详情。</p>
|
||||
</div>
|
||||
<AnnouncementForm
|
||||
mode="edit"
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getAnnouncements } from "@/modules/announcements/data-access"
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import type { AnnouncementStatus } from "@/modules/announcements/types"
|
||||
|
||||
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 const metadata: Metadata = {
|
||||
title: "公告管理 - Next_Edu",
|
||||
description: "管理系统公告,支持草稿、发布与归档",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const isValidStatus = (v?: string): v is AnnouncementStatus =>
|
||||
v === "draft" || v === "published" || v === "archived"
|
||||
|
||||
@@ -19,9 +21,9 @@ export default async function AdminAnnouncementsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const [announcements, grades] = await Promise.all([
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { 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 { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import { getAdminClasses } 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 metadata: Metadata = {
|
||||
title: "考勤总览 - Next_Edu",
|
||||
description: "查看全校所有班级的考勤记录",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
const isValidAttendanceStatus = (v?: string): v is AttendanceStatus =>
|
||||
v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused"
|
||||
|
||||
export default async function AdminAttendancePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||
const sp = await searchParams
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const status = getParam(sp, "status")
|
||||
const date = getParam(sp, "date")
|
||||
const classId = getSearchParam(sp, "classId")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status =
|
||||
statusParam && statusParam !== "all" && isValidAttendanceStatus(statusParam) ? statusParam : undefined
|
||||
const date = getSearchParam(sp, "date")
|
||||
|
||||
const classes = await getAdminClasses()
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
@@ -36,7 +46,7 @@ export default async function AdminAttendancePage({
|
||||
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,
|
||||
status,
|
||||
date: date && date.length > 0 ? date : undefined,
|
||||
})
|
||||
|
||||
@@ -44,13 +54,13 @@ export default async function AdminAttendancePage({
|
||||
<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 Overview</h2>
|
||||
<p className="text-muted-foreground">View all attendance records across the school.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">考勤总览</h2>
|
||||
<p className="text-muted-foreground">查看全校所有班级的考勤记录。</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/attendance/stats">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Statistics
|
||||
统计分析
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -59,8 +69,8 @@ export default async function AdminAttendancePage({
|
||||
|
||||
{result.items.length === 0 && !classId && !status && !date ? (
|
||||
<EmptyState
|
||||
title="No attendance records"
|
||||
description="There are no attendance records yet."
|
||||
title="暂无考勤记录"
|
||||
description="系统中尚未产生任何考勤记录。"
|
||||
icon={ClipboardList}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import {
|
||||
getDataChangeLogs,
|
||||
getDataChangeStats,
|
||||
@@ -9,28 +13,30 @@ import { DataChangeLogTable } from "@/modules/audit/components/data-change-log-t
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { DataChangeAction } from "@/modules/audit/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "数据变更日志 - Next_Edu",
|
||||
description: "追踪系统所有数据变更(增删改),保障合规",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
const isValidDataChangeAction = (v?: string): v is DataChangeAction =>
|
||||
v === "create" || v === "update" || v === "delete"
|
||||
|
||||
export default async function DataChangeLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
|
||||
const params = await searchParams
|
||||
const page = Number(getParam(params, "page") ?? "1") || 1
|
||||
const tableName = getParam(params, "tableName") ?? undefined
|
||||
const action = (getParam(params, "action") as DataChangeAction | undefined) ?? undefined
|
||||
const startDate = getParam(params, "startDate") ?? undefined
|
||||
const endDate = getParam(params, "endDate") ?? undefined
|
||||
const page = Number(getSearchParam(params, "page") ?? "1") || 1
|
||||
const tableName = getSearchParam(params, "tableName") ?? undefined
|
||||
const actionParam = getSearchParam(params, "action")
|
||||
const action = isValidDataChangeAction(actionParam) ? actionParam : undefined
|
||||
const startDate = getSearchParam(params, "startDate") ?? undefined
|
||||
const endDate = getSearchParam(params, "endDate") ?? undefined
|
||||
|
||||
const [result, tableOptions, stats] = await Promise.all([
|
||||
getDataChangeLogs({ page, tableName, action, startDate, endDate }),
|
||||
@@ -48,9 +54,9 @@ export default async function DataChangeLogsPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Change Logs</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">数据变更日志</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Track all data mutations (create/update/delete) across system tables for compliance.
|
||||
追踪系统所有数据变更(增删改),保障合规。
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="dataChange" params={exportParams} />
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import { getLoginLogs } from "@/modules/audit/data-access"
|
||||
import { LoginLogView } from "@/modules/audit/components/login-log-view"
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { LoginLogAction, LoginLogStatus } from "@/modules/audit/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "登录日志 - Next_Edu",
|
||||
description: "监控所有认证事件,包括登录、登出与注册",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
const isValidLoginLogAction = (v?: string): v is LoginLogAction =>
|
||||
v === "signin" || v === "signout" || v === "signup"
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
const isValidLoginLogStatus = (v?: string): v is LoginLogStatus =>
|
||||
v === "success" || v === "failure"
|
||||
|
||||
export default async function LoginLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
|
||||
const params = await searchParams
|
||||
const page = Number(getParam(params, "page") ?? "1") || 1
|
||||
const action = (getParam(params, "action") as LoginLogAction | undefined) ?? undefined
|
||||
const status = (getParam(params, "status") as LoginLogStatus | undefined) ?? undefined
|
||||
const startDate = getParam(params, "startDate") ?? undefined
|
||||
const endDate = getParam(params, "endDate") ?? undefined
|
||||
const page = Number(getSearchParam(params, "page") ?? "1") || 1
|
||||
const actionParam = getSearchParam(params, "action")
|
||||
const action = isValidLoginLogAction(actionParam) ? actionParam : undefined
|
||||
const statusParam = getSearchParam(params, "status")
|
||||
const status = isValidLoginLogStatus(statusParam) ? statusParam : undefined
|
||||
const startDate = getSearchParam(params, "startDate") ?? undefined
|
||||
const endDate = getSearchParam(params, "endDate") ?? undefined
|
||||
|
||||
const result = await getLoginLogs({ page, action, status, startDate, endDate })
|
||||
|
||||
@@ -40,9 +50,9 @@ export default async function LoginLogsPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Login Logs</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">登录日志</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor all authentication events including sign in, sign out, and sign up.
|
||||
监控所有认证事件,包括登录、登出与注册。
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="login" params={exportParams} />
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import { getAuditLogs, getAuditModuleOptions } from "@/modules/audit/data-access"
|
||||
import { AuditLogView } from "@/modules/audit/components/audit-log-view"
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { AuditLogStatus } from "@/modules/audit/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "审计日志 - Next_Edu",
|
||||
description: "追踪系统内所有用户操作,保障安全与合规",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
const isValidAuditLogStatus = (v?: string): v is AuditLogStatus =>
|
||||
v === "success" || v === "failure"
|
||||
|
||||
export default async function AuditLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
|
||||
const params = await searchParams
|
||||
const page = Number(getParam(params, "page") ?? "1") || 1
|
||||
const moduleFilter = getParam(params, "module") ?? undefined
|
||||
const action = getParam(params, "action") ?? undefined
|
||||
const status = (getParam(params, "status") as AuditLogStatus | undefined) ?? undefined
|
||||
const startDate = getParam(params, "startDate") ?? undefined
|
||||
const endDate = getParam(params, "endDate") ?? undefined
|
||||
const page = Number(getSearchParam(params, "page") ?? "1") || 1
|
||||
const moduleFilter = getSearchParam(params, "module") ?? undefined
|
||||
const action = getSearchParam(params, "action") ?? undefined
|
||||
const statusParam = getSearchParam(params, "status")
|
||||
const status = isValidAuditLogStatus(statusParam) ? statusParam : undefined
|
||||
const startDate = getSearchParam(params, "startDate") ?? undefined
|
||||
const endDate = getSearchParam(params, "endDate") ?? undefined
|
||||
|
||||
const [result, moduleOptions] = await Promise.all([
|
||||
getAuditLogs({ page, module: moduleFilter, action, status, startDate, endDate }),
|
||||
@@ -45,9 +51,9 @@ export default async function AuditLogsPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Audit Logs</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">审计日志</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Track all user operations across the system for security and compliance.
|
||||
追踪系统内所有用户操作,保障安全与合规。
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="audit" params={exportParams} />
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
import { getCoursePlanById, getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑课程计划 - Next_Edu",
|
||||
description: "更新课程计划详情",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditCoursePlanPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
|
||||
const [plan, classes, subjects, teachers, academicYears] = await Promise.all([
|
||||
@@ -28,8 +34,8 @@ export default async function EditCoursePlanPage({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Edit Course Plan</h2>
|
||||
<p className="text-muted-foreground">Update the course plan details below.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">编辑课程计划</h2>
|
||||
<p className="text-muted-foreground">更新课程计划详情。</p>
|
||||
</div>
|
||||
<CoursePlanForm
|
||||
mode="edit"
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "课程计划详情 - Next_Edu",
|
||||
description: "查看课程计划详情",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CoursePlanDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
const plan = await getCoursePlanById(id)
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "新建课程计划 - Next_Edu",
|
||||
description: "创建新的课程教学计划",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateCoursePlanPage() {
|
||||
export default async function CreateCoursePlanPage(): Promise<JSX.Element> {
|
||||
const [classes, subjects, teachers, academicYears] = await Promise.all([
|
||||
getAdminClasses(),
|
||||
getSubjectOptions(),
|
||||
@@ -16,8 +24,8 @@ export default async function CreateCoursePlanPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">New Course Plan</h2>
|
||||
<p className="text-muted-foreground">Create a new course teaching plan.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">新建课程计划</h2>
|
||||
<p className="text-muted-foreground">创建新的课程教学计划。</p>
|
||||
</div>
|
||||
<CoursePlanForm
|
||||
mode="create"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import type { CoursePlanStatus } from "@/modules/course-plans/types"
|
||||
|
||||
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 const metadata: Metadata = {
|
||||
title: "课程计划 - Next_Edu",
|
||||
description: "管理课程教学计划与周课时安排",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const isValidStatus = (v?: string): v is CoursePlanStatus =>
|
||||
v === "planning" || v === "active" || v === "completed" || v === "paused"
|
||||
|
||||
@@ -18,9 +20,9 @@ export default async function AdminCoursePlansPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const plans = await getCoursePlans({ status })
|
||||
@@ -28,16 +30,16 @@ export default async function AdminCoursePlansPage({
|
||||
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">Course Plans</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">课程计划</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage course teaching plans and weekly schedules.
|
||||
管理课程教学计划与周课时安排。
|
||||
</p>
|
||||
</div>
|
||||
<CoursePlanList
|
||||
plans={plans}
|
||||
canManage
|
||||
createHref="/admin/course-plans/create"
|
||||
detailHrefBuilder={(id) => `/admin/course-plans/${id}`}
|
||||
detailBaseHref="/admin/course-plans"
|
||||
initialStatus={status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard"
|
||||
import { getAdminDashboardData } from "@/modules/dashboard/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "管理控制台 - Next_Edu",
|
||||
description: "系统管理总览",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminDashboardPage() {
|
||||
export default async function AdminDashboardPage(): Promise<JSX.Element> {
|
||||
const data = await getAdminDashboardData()
|
||||
return <AdminDashboardView data={data} />
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getElectiveCourseById, getSubjectOptions } from "@/modules/elective/data-access"
|
||||
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { getElectiveCourseById } from "@/modules/elective/data-access"
|
||||
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑选修课程 - Next_Edu",
|
||||
description: "更新选修课程详情",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditElectiveCoursePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
|
||||
const [course, subjects, grades, teachers] = await Promise.all([
|
||||
@@ -25,8 +32,8 @@ export default async function EditElectiveCoursePage({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Edit Elective Course</h2>
|
||||
<p className="text-muted-foreground">Update the elective course details below.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">编辑选修课程</h2>
|
||||
<p className="text-muted-foreground">更新选修课程详情。</p>
|
||||
</div>
|
||||
<ElectiveCourseForm
|
||||
mode="edit"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { getSubjectOptions } from "@/modules/elective/data-access"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "新建选修课程 - Next_Edu",
|
||||
description: "创建新的选修课程",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateElectiveCoursePage() {
|
||||
export default async function CreateElectiveCoursePage(): Promise<JSX.Element> {
|
||||
const [subjects, grades, teachers] = await Promise.all([
|
||||
getSubjectOptions(),
|
||||
getGrades(),
|
||||
@@ -14,8 +21,8 @@ export default async function CreateElectiveCoursePage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">New Elective Course</h2>
|
||||
<p className="text-muted-foreground">Create a new elective course.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">新建选修课程</h2>
|
||||
<p className="text-muted-foreground">创建新的选修课程。</p>
|
||||
</div>
|
||||
<ElectiveCourseForm
|
||||
mode="create"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getElectiveCourses } from "@/modules/elective/data-access"
|
||||
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import type { ElectiveCourseStatus } from "@/modules/elective/types"
|
||||
|
||||
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 const metadata: Metadata = {
|
||||
title: "选修课程 - Next_Edu",
|
||||
description: "管理选修课程、开放/关闭选课与抽签",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
|
||||
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
|
||||
|
||||
@@ -18,9 +20,9 @@ export default async function AdminElectivePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const courses = await getElectiveCourses({ status })
|
||||
@@ -28,16 +30,16 @@ export default async function AdminElectivePage({
|
||||
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">Elective Courses</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">选修课程</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage elective courses, open/close selection, and run lottery.
|
||||
管理选修课程、开放/关闭选课与抽签。
|
||||
</p>
|
||||
</div>
|
||||
<ElectiveCourseList
|
||||
courses={courses}
|
||||
canManage
|
||||
createHref="/admin/elective/create"
|
||||
editHrefBuilder={(id) => `/admin/elective/${id}/edit`}
|
||||
editBaseHref="/admin/elective"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
22
src/app/(dashboard)/admin/error.tsx
Normal file
22
src/app/(dashboard)/admin/error.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function AdminError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
@@ -6,9 +9,14 @@ import {
|
||||
} from "@/modules/files/data-access"
|
||||
import { AdminFilesView } from "@/modules/files/components/admin-files-view"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "文件管理 - Next_Edu",
|
||||
description: "查看与管理系统中所有上传文件",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminFilesPage() {
|
||||
export default async function AdminFilesPage(): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.FILE_READ)
|
||||
const [files, stats] = await Promise.all([
|
||||
getFileAttachmentsWithFilters({ limit: 200 }),
|
||||
|
||||
38
src/app/(dashboard)/admin/loading.tsx
Normal file
38
src/app/(dashboard)/admin/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import { CalendarClock, ClipboardList, Settings2 } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAdminClassesForScheduling } from "@/modules/scheduling/data-access"
|
||||
import { AutoSchedulePanel } from "@/modules/scheduling/components/auto-schedule-panel"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "自动排课 - Next_Edu",
|
||||
description: "基于规则与学科分配自动生成周课表",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchedulingAutoPage() {
|
||||
export default async function AdminSchedulingAutoPage(): Promise<JSX.Element> {
|
||||
const classes = await getAdminClassesForScheduling()
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||
|
||||
@@ -16,16 +23,15 @@ export default async function AdminSchedulingAutoPage() {
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Auto Schedule</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">自动排课</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Generate a weekly schedule automatically based on configured rules and subject
|
||||
assignments.
|
||||
基于规则与学科分配自动生成周课表。
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/scheduling/rules">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
Configure Rules
|
||||
配置规则
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -33,8 +39,8 @@ export default async function AdminSchedulingAutoPage() {
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No classes available"
|
||||
description="Please create classes before running auto scheduling."
|
||||
title="暂无可用班级"
|
||||
description="请先创建班级,再进行自动排课。"
|
||||
/>
|
||||
) : (
|
||||
<AutoSchedulePanel classes={classOptions} />
|
||||
@@ -42,9 +48,7 @@ export default async function AdminSchedulingAutoPage() {
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
<span>
|
||||
Applying a new schedule will replace the existing schedule for the selected class.
|
||||
</span>
|
||||
<span>应用新课表将替换所选班级的现有课表。</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, ClipboardList } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import {
|
||||
getAdminClassesForScheduling,
|
||||
getScheduleChanges,
|
||||
@@ -11,15 +14,13 @@ import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-cha
|
||||
import { ScheduleConflictsView } from "@/modules/scheduling/components/schedule-conflicts-view"
|
||||
import type { ScheduleChangeStatus } from "@/modules/scheduling/types"
|
||||
|
||||
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 const metadata: Metadata = {
|
||||
title: "课表变更申请 - Next_Edu",
|
||||
description: "审核、批准或拒绝课表变更与代课申请",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const isValidStatus = (v?: string): v is ScheduleChangeStatus =>
|
||||
v === "pending" || v === "approved" || v === "rejected" || v === "completed"
|
||||
|
||||
@@ -27,11 +28,11 @@ export default async function AdminSchedulingChangesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
const classIdParam = getParam(sp, "classId")
|
||||
const classIdParam = getSearchParam(sp, "classId")
|
||||
const classId = classIdParam && classIdParam !== "all" ? classIdParam : undefined
|
||||
|
||||
const [classes, items] = await Promise.all([
|
||||
@@ -44,15 +45,15 @@ export default async function AdminSchedulingChangesPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schedule Change Requests</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">课表变更申请</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Review, approve, or reject schedule change and substitute teacher requests.
|
||||
审核、批准或拒绝课表变更与代课申请。
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/teacher/schedule-changes">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
New Request
|
||||
新建申请
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -60,10 +61,10 @@ export default async function AdminSchedulingChangesPage({
|
||||
{items.length === 0 && !status && !classId ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No schedule change requests"
|
||||
description="There are no schedule change requests yet."
|
||||
title="暂无课表变更申请"
|
||||
description="系统中尚未产生任何课表变更申请。"
|
||||
action={{
|
||||
label: "New Request",
|
||||
label: "新建申请",
|
||||
href: "/teacher/schedule-changes",
|
||||
}}
|
||||
/>
|
||||
@@ -72,15 +73,15 @@ export default async function AdminSchedulingChangesPage({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Conflict Detection</h3>
|
||||
<h3 className="text-lg font-semibold">冲突检测</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Detect time overlaps in an existing class schedule.
|
||||
检测现有班级课表中的时间重叠。
|
||||
</p>
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No classes available"
|
||||
description="Please create classes before checking conflicts."
|
||||
title="暂无可用班级"
|
||||
description="请先创建班级,再进行冲突检测。"
|
||||
/>
|
||||
) : (
|
||||
<ScheduleConflictsView classes={classOptions} />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CalendarCog, ClipboardList } from "lucide-react"
|
||||
import { CalendarCog, ClipboardList } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
@@ -7,9 +9,14 @@ import {
|
||||
} from "@/modules/scheduling/data-access"
|
||||
import { SchedulingRulesForm } from "@/modules/scheduling/components/scheduling-rules-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "排课规则 - Next_Edu",
|
||||
description: "配置每日课时上限、课间窗口与均衡偏好",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchedulingRulesPage() {
|
||||
export default async function AdminSchedulingRulesPage(): Promise<JSX.Element> {
|
||||
const [classes, existingRules] = await Promise.all([
|
||||
getAdminClassesForScheduling(),
|
||||
getSchedulingRules(),
|
||||
@@ -20,17 +27,17 @@ export default async function AdminSchedulingRulesPage() {
|
||||
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">Scheduling Rules</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">排课规则</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Configure daily hour limits, break windows, and balancing preferences for each class.
|
||||
配置每日课时上限、课间窗口与均衡偏好。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No classes available"
|
||||
description="Please create classes before configuring scheduling rules."
|
||||
title="暂无可用班级"
|
||||
description="请先创建班级,再配置排课规则。"
|
||||
/>
|
||||
) : (
|
||||
<SchedulingRulesForm classes={classOptions} existingRules={existingRules} />
|
||||
@@ -38,9 +45,7 @@ export default async function AdminSchedulingRulesPage() {
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CalendarCog className="h-4 w-4" />
|
||||
<span>
|
||||
Tip: rules saved without selecting a specific class become the global default.
|
||||
</span>
|
||||
<span>提示:未选择具体班级时保存的规则将作为全局默认。</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { AcademicYearClient } from "@/modules/school/components/academic-year-view"
|
||||
import { getAcademicYears } from "@/modules/school/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "学年管理 - Next_Edu",
|
||||
description: "管理学年区间与当前激活学年",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminAcademicYearPage() {
|
||||
export default async function AdminAcademicYearPage(): Promise<JSX.Element> {
|
||||
const years = await getAcademicYears()
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Academic Year</h2>
|
||||
<p className="text-muted-foreground">Manage academic year ranges and the active year.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">学年管理</h2>
|
||||
<p className="text-muted-foreground">管理学年区间与当前激活学年。</p>
|
||||
</div>
|
||||
<AcademicYearClient years={years} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access"
|
||||
import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "班级管理 - Next_Edu",
|
||||
description: "管理班级并分配教师",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchoolClassesPage() {
|
||||
export default async function AdminSchoolClassesPage(): Promise<JSX.Element> {
|
||||
const [classes, teachers] = await Promise.all([getAdminClasses(), getTeacherOptions()])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Classes</h2>
|
||||
<p className="text-muted-foreground">Manage classes and assign teachers.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">班级管理</h2>
|
||||
<p className="text-muted-foreground">管理班级并分配教师。</p>
|
||||
</div>
|
||||
<AdminClassesClient classes={classes} teachers={teachers} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { DepartmentsClient } from "@/modules/school/components/departments-view"
|
||||
import { getDepartments } from "@/modules/school/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "部门管理 - Next_Edu",
|
||||
description: "管理学校部门",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminDepartmentsPage() {
|
||||
export default async function AdminDepartmentsPage(): Promise<JSX.Element> {
|
||||
const departments = await getDepartments()
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Departments</h2>
|
||||
<p className="text-muted-foreground">Manage school departments.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">部门管理</h2>
|
||||
<p className="text-muted-foreground">管理学校部门。</p>
|
||||
</div>
|
||||
<DepartmentsClient departments={departments} />
|
||||
</div>
|
||||
|
||||
@@ -1,65 +1,75 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { formatDate, formatNumber, getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "年级作业洞察 - Next_Edu",
|
||||
description: "按年级聚合的作业统计与班级排名",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
if (typeof v === "string") return v
|
||||
if (Array.isArray(v)) return v[0]
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
|
||||
|
||||
export default async function AdminGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
export default async function AdminGradeInsightsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const params = await searchParams
|
||||
const gradeId = getParam(params, "gradeId")
|
||||
|
||||
const grades = await getGrades()
|
||||
const gradeId = getSearchParam(params, "gradeId")
|
||||
const selected = gradeId && gradeId !== "all" ? gradeId : ""
|
||||
|
||||
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
|
||||
// grades 与 insights 无数据依赖,并行查询
|
||||
const [grades, insights] = await Promise.all([
|
||||
getGrades(),
|
||||
selected ? getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : Promise.resolve(null),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
|
||||
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">年级作业洞察</h2>
|
||||
<p className="text-muted-foreground">按年级聚合的作业统计与班级排名。</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/school/grades">Manage grades</Link>
|
||||
<Link href="/admin/school/grades">管理年级</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Filters</CardTitle>
|
||||
<CardTitle className="text-base">筛选</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{grades.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action="/admin/school/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<label className="text-sm font-medium">Grade</label>
|
||||
<form
|
||||
action="/admin/school/grades/insights"
|
||||
method="get"
|
||||
className="flex flex-col gap-3 md:flex-row md:items-center"
|
||||
>
|
||||
<label htmlFor="grade-filter" className="text-sm font-medium">
|
||||
年级
|
||||
</label>
|
||||
<select
|
||||
id="grade-filter"
|
||||
name="gradeId"
|
||||
defaultValue={selected || "all"}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-80"
|
||||
>
|
||||
<option value="all">Select a grade</option>
|
||||
<option value="all">请选择年级</option>
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.school.name} / {g.name}
|
||||
@@ -67,7 +77,7 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" className="md:ml-2">
|
||||
Apply
|
||||
应用
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -76,72 +86,56 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="Select a grade to view insights"
|
||||
description="Pick a grade to see latest homework and historical score statistics."
|
||||
className="h-[360px] bg-card"
|
||||
title="请选择年级以查看洞察"
|
||||
description="选择一个年级,查看最新作业与历史成绩统计。"
|
||||
className="h-80 bg-card"
|
||||
/>
|
||||
) : !insights ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="Grade not found"
|
||||
description="This grade may not exist or has no accessible data."
|
||||
className="h-[360px] bg-card"
|
||||
title="年级未找到"
|
||||
description="该年级可能不存在或无可访问数据。"
|
||||
className="h-80 bg-card"
|
||||
/>
|
||||
) : insights.assignments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="No homework data for this grade"
|
||||
description="No homework assignments were targeted to students in this grade yet."
|
||||
className="h-[360px] bg-card"
|
||||
title="该年级暂无作业数据"
|
||||
description="尚未向该年级学生布置任何作业。"
|
||||
className="h-80 bg-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Classes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{insights.grade.school.name} / {insights.grade.name}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Students</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{insights.studentCounts.total}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active {insights.studentCounts.active} • Inactive {insights.studentCounts.inactive}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Overall Avg</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
|
||||
<div className="text-xs text-muted-foreground">Across graded homework</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
|
||||
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="班级数"
|
||||
value={insights.classCount}
|
||||
description={`${insights.grade.school.name} / ${insights.grade.name}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="学生数"
|
||||
value={insights.studentCounts.total}
|
||||
description={`在读 ${insights.studentCounts.active} • 停用 ${insights.studentCounts.inactive}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="总体均分"
|
||||
value={formatNumber(insights.overallScores.avg)}
|
||||
description="基于已批改作业"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="最新均分"
|
||||
value={formatNumber(insights.latest?.scoreStats.avg ?? null)}
|
||||
description={insights.latest?.title ?? "-"}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Latest homework</CardTitle>
|
||||
<CardTitle className="text-base">最新作业</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.assignments.length}
|
||||
</Badge>
|
||||
@@ -151,14 +145,14 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Targeted</TableHead>
|
||||
<TableHead className="text-right">Submitted</TableHead>
|
||||
<TableHead className="text-right">Graded</TableHead>
|
||||
<TableHead className="text-right">Avg</TableHead>
|
||||
<TableHead className="text-right">Median</TableHead>
|
||||
<TableHead>作业</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">目标数</TableHead>
|
||||
<TableHead className="text-right">提交数</TableHead>
|
||||
<TableHead className="text-right">已批改</TableHead>
|
||||
<TableHead className="text-right">均分</TableHead>
|
||||
<TableHead className="text-right">中位数</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -174,8 +168,8 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.median)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -186,7 +180,7 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Class ranking</CardTitle>
|
||||
<CardTitle className="text-base">班级排名</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.classes.length}
|
||||
</Badge>
|
||||
@@ -196,12 +190,12 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>Class</TableHead>
|
||||
<TableHead className="text-right">Students</TableHead>
|
||||
<TableHead className="text-right">Latest Avg</TableHead>
|
||||
<TableHead className="text-right">Prev Avg</TableHead>
|
||||
<TableHead>班级</TableHead>
|
||||
<TableHead className="text-right">学生数</TableHead>
|
||||
<TableHead className="text-right">最新均分</TableHead>
|
||||
<TableHead className="text-right">上次均分</TableHead>
|
||||
<TableHead className="text-right">Δ</TableHead>
|
||||
<TableHead className="text-right">Overall Avg</TableHead>
|
||||
<TableHead className="text-right">总体均分</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -209,13 +203,15 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
<TableRow key={c.class.id}>
|
||||
<TableCell className="font-medium">
|
||||
{c.class.name}
|
||||
{c.class.homeroom ? <span className="text-muted-foreground"> • {c.class.homeroom}</span> : null}
|
||||
{c.class.homeroom ? (
|
||||
<span className="text-muted-foreground"> • {c.class.homeroom}</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.overallScores.avg)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -228,4 +224,3 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { GradesClient } from "@/modules/school/components/grades-view"
|
||||
import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "年级管理 - Next_Edu",
|
||||
description: "管理年级并分配年级组长",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminGradesPage() {
|
||||
export default async function AdminGradesPage(): Promise<JSX.Element> {
|
||||
const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grades</h2>
|
||||
<p className="text-muted-foreground">Manage grades and assign grade heads.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">年级管理</h2>
|
||||
<p className="text-muted-foreground">管理年级并分配年级组长。</p>
|
||||
</div>
|
||||
<GradesClient grades={grades} schools={schools} staff={staff} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function AdminSchoolPage() {
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default function AdminSchoolPage(): never {
|
||||
redirect("/admin/school/classes")
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { SchoolsClient } from "@/modules/school/components/schools-view"
|
||||
import { getSchools } from "@/modules/school/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "学校管理 - Next_Edu",
|
||||
description: "多校区场景下的学校管理",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchoolsPage() {
|
||||
export default async function AdminSchoolsPage(): Promise<JSX.Element> {
|
||||
const schools = await getSchools()
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schools</h2>
|
||||
<p className="text-muted-foreground">Manage schools for multi-school setups.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">学校管理</h2>
|
||||
<p className="text-muted-foreground">多校区场景下的学校管理。</p>
|
||||
</div>
|
||||
<SchoolsClient schools={schools} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Metadata } from "next"
|
||||
import { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Users, FileSpreadsheet, Info } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { UserImportDialog } from "@/modules/users/components/user-import-dialog"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -11,7 +20,9 @@ export const metadata: Metadata = {
|
||||
description: "通过 Excel 批量导入用户",
|
||||
}
|
||||
|
||||
export default function UserImportPage() {
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default function UserImportPage(): JSX.Element {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
@@ -64,7 +75,7 @@ export default function UserImportPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-5 w-5 text-amber-500" />
|
||||
<Info className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">注意事项</CardTitle>
|
||||
</div>
|
||||
<CardDescription>导入前请仔细阅读</CardDescription>
|
||||
@@ -90,42 +101,42 @@ export default function UserImportPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-2 pr-4 text-left font-medium">列名</th>
|
||||
<th className="py-2 pr-4 text-left font-medium">是否必填</th>
|
||||
<th className="py-2 pr-4 text-left font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">姓名</td>
|
||||
<td className="py-2 pr-4">必填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">用户姓名</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">邮箱</td>
|
||||
<td className="py-2 pr-4">必填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">登录账号,需符合邮箱格式且唯一</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">角色</td>
|
||||
<td className="py-2 pr-4">必填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">admin / teacher / student / parent / grade_head / teaching_head</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">手机</td>
|
||||
<td className="py-2 pr-4">选填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">联系电话</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">班级邀请码</td>
|
||||
<td className="py-2 pr-4">选填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">仅 student 角色有效,6 位邀请码</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>列名</TableHead>
|
||||
<TableHead>是否必填</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">姓名</TableCell>
|
||||
<TableCell>必填</TableCell>
|
||||
<TableCell className="text-muted-foreground">用户姓名</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">邮箱</TableCell>
|
||||
<TableCell>必填</TableCell>
|
||||
<TableCell className="text-muted-foreground">登录账号,需符合邮箱格式且唯一</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">角色</TableCell>
|
||||
<TableCell>必填</TableCell>
|
||||
<TableCell className="text-muted-foreground">admin / teacher / student / parent / grade_head / teaching_head</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">手机</TableCell>
|
||||
<TableCell>选填</TableCell>
|
||||
<TableCell className="text-muted-foreground">联系电话</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">班级邀请码</TableCell>
|
||||
<TableCell>选填</TableCell>
|
||||
<TableCell className="text-muted-foreground">仅 student 角色有效,6 位邀请码</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -5,6 +5,10 @@ import { AnnouncementList } from "@/modules/announcements/components/announcemen
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata = {
|
||||
title: "Announcements",
|
||||
}
|
||||
|
||||
export default async function AnnouncementsPage() {
|
||||
await requirePermission(Permissions.ANNOUNCEMENT_READ)
|
||||
const announcements = await getAnnouncements({ status: "published" })
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
||||
import { getGradesForStaff } from "@/modules/school/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -21,10 +23,10 @@ const getParam = (params: SearchParams, key: string) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
|
||||
const formatScore = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
|
||||
|
||||
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
await requireAuth()
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const params = await searchParams
|
||||
const gradeId = getParam(params, "gradeId")
|
||||
|
||||
@@ -68,8 +70,9 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<label className="text-sm font-medium">Grade</label>
|
||||
<label htmlFor="gradeId" className="text-sm font-medium">Grade</label>
|
||||
<select
|
||||
id="gradeId"
|
||||
name="gradeId"
|
||||
defaultValue={selected || "all"}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
|
||||
@@ -112,46 +115,30 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Classes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{insights.grade.school.name} / {insights.grade.name}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Students</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{insights.studentCounts.total}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active {insights.studentCounts.active} • Inactive {insights.studentCounts.inactive}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Overall Avg</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
|
||||
<div className="text-xs text-muted-foreground">Across graded homework</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
|
||||
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="Classes"
|
||||
value={insights.classCount}
|
||||
description={`${insights.grade.school.name} / ${insights.grade.name}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="Students"
|
||||
value={insights.studentCounts.total}
|
||||
description={`Active ${insights.studentCounts.active} • Inactive ${insights.studentCounts.inactive}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="Overall Avg"
|
||||
value={formatScore(insights.overallScores.avg)}
|
||||
description="Across graded homework"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="Latest Avg"
|
||||
value={formatScore(insights.latest?.scoreStats.avg ?? null)}
|
||||
description={insights.latest ? insights.latest.title : "-"}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
@@ -189,8 +176,8 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(a.scoreStats.median)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -227,10 +214,10 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
{c.class.homeroom ? <span className="text-muted-foreground"> • {c.class.homeroom}</span> : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(c.overallScores.avg)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { after } from "next/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getMessageById, markMessageAsRead } from "@/modules/messaging/data-access"
|
||||
@@ -6,6 +7,10 @@ import { MessageDetail } from "@/modules/messaging/components/message-detail"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata = {
|
||||
title: "Message Detail",
|
||||
}
|
||||
|
||||
export default async function MessageDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -17,9 +22,9 @@ export default async function MessageDetailPage({
|
||||
const message = await getMessageById(id, ctx.userId)
|
||||
if (!message) notFound()
|
||||
|
||||
// Auto-mark as read when viewed by the receiver
|
||||
// Auto-mark as read when viewed by the receiver (non-blocking)
|
||||
if (!message.isRead && message.receiverId === ctx.userId) {
|
||||
await markMessageAsRead(id, ctx.userId)
|
||||
after(() => markMessageAsRead(id, ctx.userId))
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,10 @@ import { MessageCompose } from "@/modules/messaging/components/message-compose"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata = {
|
||||
title: "Compose Message",
|
||||
}
|
||||
|
||||
export default async function ComposeMessagePage({
|
||||
searchParams,
|
||||
}: {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getMessages, getNotifications } from "@/modules/messaging/data-access"
|
||||
import { getMessages } from "@/modules/messaging/data-access"
|
||||
import { getNotifications } from "@/modules/notifications/data-access"
|
||||
import { MessageList } from "@/modules/messaging/components/message-list"
|
||||
import { NotificationList } from "@/modules/messaging/components/notification-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata = {
|
||||
title: "Messages",
|
||||
}
|
||||
|
||||
export default async function MessagesPage() {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link"
|
||||
import { FileQuestion } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function NotFound() {
|
||||
@@ -12,12 +13,11 @@ export default function NotFound() {
|
||||
description="The page you are looking for does not exist or has been moved."
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium transition-colors"
|
||||
>
|
||||
Return to Dashboard
|
||||
</Link>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard">
|
||||
Return to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
ParentChildrenDataPage,
|
||||
ParentNoChildrenPage,
|
||||
} from "@/modules/parent/components/parent-children-data-page"
|
||||
import { CalendarCheck } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -11,51 +14,41 @@ export default async function ParentAttendancePage() {
|
||||
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.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">Children Attendance</h2>
|
||||
<p className="text-muted-foreground">View your children's attendance records.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No children linked"
|
||||
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
icon={CalendarCheck}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
<ParentNoChildrenPage
|
||||
title="Children Attendance"
|
||||
description="View your children's attendance records."
|
||||
icon={CalendarCheck}
|
||||
emptyTitle="No children linked"
|
||||
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const summaries = await Promise.all(
|
||||
ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id))
|
||||
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
|
||||
const results = await Promise.allSettled(
|
||||
ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id)),
|
||||
)
|
||||
|
||||
const validSummaries = summaries.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||
const validSummaries = results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<NonNullable<Awaited<ReturnType<typeof getStudentAttendanceSummary>>>> =>
|
||||
r.status === "fulfilled" && r.value !== null,
|
||||
)
|
||||
.map((r) => r.value)
|
||||
|
||||
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">Children Attendance</h2>
|
||||
<p className="text-muted-foreground">View your children's attendance records.</p>
|
||||
</div>
|
||||
|
||||
{validSummaries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No attendance records"
|
||||
description="Your children don't have any attendance records yet."
|
||||
icon={CalendarCheck}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{validSummaries.map((summary) => (
|
||||
<div key={summary.studentId} className="space-y-4">
|
||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||
<StudentAttendanceView summary={summary} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ParentChildrenDataPage
|
||||
title="Children Attendance"
|
||||
description="View your children's attendance records."
|
||||
icon={CalendarCheck}
|
||||
noRecordsTitle="No attendance records"
|
||||
noRecordsDescription="Your children don't have any attendance records yet."
|
||||
items={validSummaries}
|
||||
renderItem={(summary) => (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||
<StudentAttendanceView summary={summary} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { db } from "@/shared/db"
|
||||
import { parentStudentRelations } from "@/shared/db/schema"
|
||||
import { getChildDashboardData } from "@/modules/parent/data-access"
|
||||
import { verifyParentChildRelation, getChildDashboardData } from "@/modules/parent/data-access"
|
||||
import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header"
|
||||
import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
@@ -20,17 +17,15 @@ export default async function ChildDetailPage({
|
||||
const { studentId } = await params
|
||||
const ctx = await requireAuth()
|
||||
|
||||
// Verify the student is linked to the current parent
|
||||
const [relation] = await db
|
||||
.select({
|
||||
id: parentStudentRelations.id,
|
||||
relation: parentStudentRelations.relation,
|
||||
})
|
||||
.from(parentStudentRelations)
|
||||
.where(eq(parentStudentRelations.studentId, studentId))
|
||||
.limit(1)
|
||||
// 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露)
|
||||
const relation = await verifyParentChildRelation(studentId, ctx.userId)
|
||||
|
||||
if (!relation) {
|
||||
// dataScope 二次校验:admin/其他角色可能通过 requireAuth,但需确认 dataScope 包含该子女
|
||||
const isInScope =
|
||||
ctx.dataScope.type === "all" ||
|
||||
(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.includes(studentId))
|
||||
|
||||
if (!relation || !isInScope) {
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<EmptyState
|
||||
@@ -43,21 +38,7 @@ export default async function ChildDetailPage({
|
||||
)
|
||||
}
|
||||
|
||||
// Double-check the parent owns this relation
|
||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<EmptyState
|
||||
icon={ShieldAlert}
|
||||
title="Access denied"
|
||||
description="You do not have permission to view this student's data."
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const child = await getChildDashboardData(studentId, relation.relation)
|
||||
const child = await getChildDashboardData(studentId, relation)
|
||||
if (!child) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { getParentDashboardData } from "@/modules/parent/data-access"
|
||||
import { ParentDashboard } from "@/modules/parent/components/parent-dashboard"
|
||||
import { ParentNoChildrenPage } from "@/modules/parent/components/parent-children-data-page"
|
||||
import { Users } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentDashboardPage() {
|
||||
const ctx = await requireAuth()
|
||||
|
||||
// 非 admin 且 dataScope 非 children 类型时,显示空状态
|
||||
if (
|
||||
ctx.dataScope.type !== "all" &&
|
||||
!(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0)
|
||||
) {
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<ParentNoChildrenPage
|
||||
title="Parent Dashboard"
|
||||
description="Here's an overview of your children."
|
||||
icon={Users}
|
||||
emptyTitle="No children linked"
|
||||
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = await getParentDashboardData(ctx.userId)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
ParentChildrenDataPage,
|
||||
ParentNoChildrenPage,
|
||||
} from "@/modules/parent/components/parent-children-data-page"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -11,51 +14,41 @@ export default async function ParentGradesPage() {
|
||||
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.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">Children Grades</h2>
|
||||
<p className="text-muted-foreground">View your children's grade records.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No children linked"
|
||||
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
icon={GraduationCap}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
<ParentNoChildrenPage
|
||||
title="Children Grades"
|
||||
description="View your children's grade records."
|
||||
icon={GraduationCap}
|
||||
emptyTitle="No children linked"
|
||||
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const summaries = await Promise.all(
|
||||
ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id))
|
||||
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
|
||||
const results = await Promise.allSettled(
|
||||
ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id)),
|
||||
)
|
||||
|
||||
const validSummaries = summaries.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||
const validSummaries = results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>> =>
|
||||
r.status === "fulfilled" && r.value !== null,
|
||||
)
|
||||
.map((r) => r.value)
|
||||
|
||||
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">Children Grades</h2>
|
||||
<p className="text-muted-foreground">View your children's grade records.</p>
|
||||
</div>
|
||||
|
||||
{validSummaries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No grade records"
|
||||
description="Your children don't have any grade records yet."
|
||||
icon={GraduationCap}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{validSummaries.map((summary) => (
|
||||
<div key={summary.studentId} className="space-y-4">
|
||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||
<StudentGradeSummary summary={summary} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ParentChildrenDataPage
|
||||
title="Children Grades"
|
||||
description="View your children's grade records."
|
||||
icon={GraduationCap}
|
||||
noRecordsTitle="No grade records"
|
||||
noRecordsDescription="Your children don't have any grade records yet."
|
||||
items={validSummaries}
|
||||
renderItem={(summary) => (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||
<StudentGradeSummary summary={summary} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,27 +9,28 @@ import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student
|
||||
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { PageHeader } from "@/shared/components/ui/page-header"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
const day = d.getDay()
|
||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
export const metadata = {
|
||||
title: "Profile",
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | null) => {
|
||||
if (!date) return "-"
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
|
||||
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
|
||||
const toWeekday = (d: Date): Weekday => {
|
||||
const day = d.getDay()
|
||||
const result = WEEKDAY_MAP[day]
|
||||
if (result < 1 || result > 7) throw new Error("Invalid weekday")
|
||||
return result
|
||||
}
|
||||
|
||||
export default async function ProfilePage() {
|
||||
@@ -42,9 +43,9 @@ export default async function ProfilePage() {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const permissions = ctx.permissions
|
||||
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
|
||||
const roles = ctx.roles
|
||||
const isStudent = roles.includes("student")
|
||||
const isTeacher = roles.includes("teacher")
|
||||
|
||||
const studentData =
|
||||
isStudent
|
||||
@@ -118,17 +119,15 @@ export default async function ProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-8 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
|
||||
<div className="text-sm text-muted-foreground">Manage your personal and account information.</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeader
|
||||
title="Profile"
|
||||
description="Manage your personal and account information."
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings">Edit Profile</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
@@ -205,7 +204,7 @@ export default async function ProfilePage() {
|
||||
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
{formatDate(userProfile.onboardedAt)}
|
||||
{userProfile.onboardedAt ? formatDate(userProfile.onboardedAt) : "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,14 @@ import { AdminSettingsView } from "@/modules/settings/components/admin-settings-
|
||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getNotificationPreferences } from "@/modules/notifications/preferences"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata = {
|
||||
title: "Settings",
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const ctx = await requireAuth()
|
||||
|
||||
@@ -18,13 +21,13 @@ export default async function SettingsPage() {
|
||||
|
||||
if (!userProfile) redirect("/login")
|
||||
|
||||
const permissions = ctx.permissions
|
||||
const roles = ctx.roles
|
||||
const notificationPrefs = await getNotificationPreferences(userId)
|
||||
|
||||
if (permissions.includes(Permissions.SETTINGS_ADMIN)) {
|
||||
if (roles.includes("admin")) {
|
||||
return <AdminSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
||||
}
|
||||
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) {
|
||||
if (roles.includes("student")) {
|
||||
return <StudentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
||||
}
|
||||
return <TeacherSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Lock } from "lucide-react"
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { PageHeader } from "@/shared/components/ui/page-header"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -15,15 +16,11 @@ export default async function SecuritySettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-7 w-7 text-muted-foreground" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">Security</h1>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Manage your password and account security settings.
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Security"
|
||||
description="Manage your password and account security settings."
|
||||
icon={Lock}
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<PasswordChangeForm />
|
||||
|
||||
25
src/app/(dashboard)/student/attendance/loading.tsx
Normal file
25
src/app/(dashboard)/student/attendance/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { CalendarCheck } from "lucide-react"
|
||||
import { UserX } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -21,7 +21,7 @@ export default async function StudentAttendancePage() {
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Unable to load your student profile."
|
||||
icon={CalendarCheck}
|
||||
icon={UserX}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,27 +3,31 @@ import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-ac
|
||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Inbox } from "lucide-react"
|
||||
import { UserX } from "lucide-react"
|
||||
import type { StudentHomeworkProgressStatus } from "@/modules/homework/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
// getDay() 返回 0(周日)-6(周六),转换为 1-7(周一为 1)
|
||||
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
|
||||
const day = d.getDay()
|
||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
if (day < 0 || day > 6) {
|
||||
throw new Error(`Invalid day from getDay(): ${day}`)
|
||||
}
|
||||
return WEEKDAY_MAP[day]
|
||||
}
|
||||
|
||||
export default async function StudentDashboardPage() {
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Create a student user to see dashboard."
|
||||
icon={Inbox}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Create a student user to see dashboard."
|
||||
icon={UserX}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,19 +42,24 @@ export default async function StudentDashboardPage() {
|
||||
const in7Days = new Date(now)
|
||||
in7Days.setDate(in7Days.getDate() + 7)
|
||||
|
||||
const dueSoonCount = assignments.filter((a) => {
|
||||
if (!a.dueAt) return false
|
||||
// 单次遍历统计,避免重复 filter(PERF-04)
|
||||
let dueSoonCount = 0
|
||||
let overdueCount = 0
|
||||
let gradedCount = 0
|
||||
for (const a of assignments) {
|
||||
const status: StudentHomeworkProgressStatus = a.progressStatus
|
||||
if (status === "graded") {
|
||||
gradedCount++
|
||||
continue
|
||||
}
|
||||
if (!a.dueAt) continue
|
||||
const due = new Date(a.dueAt)
|
||||
return due >= now && due <= in7Days && a.progressStatus !== "graded"
|
||||
}).length
|
||||
|
||||
const overdueCount = assignments.filter((a) => {
|
||||
if (!a.dueAt) return false
|
||||
const due = new Date(a.dueAt)
|
||||
return due < now && a.progressStatus !== "graded"
|
||||
}).length
|
||||
|
||||
const gradedCount = assignments.filter((a) => a.progressStatus === "graded").length
|
||||
if (due >= now && due <= in7Days) {
|
||||
dueSoonCount++
|
||||
} else if (due < now) {
|
||||
overdueCount++
|
||||
}
|
||||
}
|
||||
|
||||
const todayWeekday = toWeekday(now)
|
||||
const todayScheduleItems = schedule
|
||||
@@ -75,15 +84,21 @@ export default async function StudentDashboardPage() {
|
||||
.slice(0, 6)
|
||||
|
||||
return (
|
||||
<StudentDashboard
|
||||
studentName={student.name}
|
||||
enrolledClassCount={classes.length}
|
||||
dueSoonCount={dueSoonCount}
|
||||
overdueCount={overdueCount}
|
||||
gradedCount={gradedCount}
|
||||
todayScheduleItems={todayScheduleItems}
|
||||
upcomingAssignments={upcomingAssignments}
|
||||
grades={grades}
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
|
||||
<p className="text-muted-foreground">Welcome back, {student.name}.</p>
|
||||
</div>
|
||||
<StudentDashboard
|
||||
studentName={student.name}
|
||||
enrolledClassCount={classes.length}
|
||||
dueSoonCount={dueSoonCount}
|
||||
overdueCount={overdueCount}
|
||||
gradedCount={gradedCount}
|
||||
todayScheduleItems={todayScheduleItems}
|
||||
upcomingAssignments={upcomingAssignments}
|
||||
grades={grades}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
35
src/app/(dashboard)/student/diagnostic/loading.tsx
Normal file
35
src/app/(dashboard)/student/diagnostic/loading.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-44" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
src/app/(dashboard)/student/elective/loading.tsx
Normal file
26
src/app/(dashboard)/student/elective/loading.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="mt-2 h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default async function StudentElectivePage() {
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
21
src/app/(dashboard)/student/error.tsx
Normal file
21
src/app/(dashboard)/student/error.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
|
||||
export default function StudentError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="Something went wrong"
|
||||
description={error.message || "An unexpected error occurred. Please try again."}
|
||||
action={{ label: "Try again", onClick: reset }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
23
src/app/(dashboard)/student/grades/loading.tsx
Normal file
23
src/app/(dashboard)/student/grades/loading.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
import { UserX } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -21,7 +21,7 @@ export default async function StudentGradesPage() {
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Unable to load your student profile."
|
||||
icon={GraduationCap}
|
||||
icon={UserX}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export default async function StudentAssignmentTakePage({
|
||||
const status = data.submission?.status
|
||||
if (status === "graded" || status === "submitted") {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<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">
|
||||
@@ -43,7 +43,7 @@ export default async function StudentAssignmentTakePage({
|
||||
<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 className="mx-2">•</span>
|
||||
<span className="mx-2" aria-hidden="true">•</span>
|
||||
<span>Max Attempts: {data.assignment.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
33
src/app/(dashboard)/student/learning/assignments/loading.tsx
Normal file
33
src/app/(dashboard)/student/learning/assignments/loading.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="gap-2 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-9 w-20" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,46 +7,109 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { Inbox } from "lucide-react"
|
||||
import { Inbox, UserX } from "lucide-react"
|
||||
import type {
|
||||
StudentHomeworkAssignmentListItem,
|
||||
StudentHomeworkProgressStatus,
|
||||
} from "@/modules/homework/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
if (status === "graded") return "default"
|
||||
if (status === "submitted") return "secondary"
|
||||
if (status === "in_progress") return "secondary"
|
||||
return "outline"
|
||||
const getStatusVariant = (
|
||||
status: StudentHomeworkProgressStatus
|
||||
): "default" | "secondary" | "outline" => {
|
||||
switch (status) {
|
||||
case "graded":
|
||||
return "default"
|
||||
case "submitted":
|
||||
return "secondary"
|
||||
case "in_progress":
|
||||
return "outline"
|
||||
default:
|
||||
return "outline"
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
if (status === "graded") return "Graded"
|
||||
if (status === "submitted") return "Submitted"
|
||||
if (status === "in_progress") return "In progress"
|
||||
return "Not started"
|
||||
const getStatusLabel = (status: StudentHomeworkProgressStatus): string => {
|
||||
switch (status) {
|
||||
case "graded":
|
||||
return "Graded"
|
||||
case "submitted":
|
||||
return "Submitted"
|
||||
case "in_progress":
|
||||
return "In progress"
|
||||
default:
|
||||
return "Not started"
|
||||
}
|
||||
}
|
||||
|
||||
const getActionLabel = (status: string) => {
|
||||
if (status === "graded") return "Review"
|
||||
if (status === "submitted") return "View"
|
||||
if (status === "in_progress") return "Continue"
|
||||
return "Start"
|
||||
const getActionLabel = (status: StudentHomeworkProgressStatus): string => {
|
||||
switch (status) {
|
||||
case "graded":
|
||||
return "Review"
|
||||
case "submitted":
|
||||
return "View"
|
||||
case "in_progress":
|
||||
return "Continue"
|
||||
default:
|
||||
return "Start"
|
||||
}
|
||||
}
|
||||
|
||||
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
if (status === "graded" || status === "submitted") return "outline"
|
||||
return "default"
|
||||
const getActionVariant = (
|
||||
status: StudentHomeworkProgressStatus
|
||||
): "default" | "secondary" | "outline" => {
|
||||
return status === "graded" || status === "submitted" ? "outline" : "default"
|
||||
}
|
||||
|
||||
const isAnswered = (status: string) => status === "submitted" || status === "graded"
|
||||
const isAnswered = (status: StudentHomeworkProgressStatus): boolean =>
|
||||
status === "submitted" || status === "graded"
|
||||
|
||||
function AssignmentCard({ assignment: a }: { assignment: StudentHomeworkAssignmentListItem }) {
|
||||
return (
|
||||
<Card className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
|
||||
<CardHeader className="gap-2 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="text-base">
|
||||
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
|
||||
{a.title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||
{getStatusLabel(a.progressStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
|
||||
<span className="px-2" aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
<span>
|
||||
Attempts {a.attemptsUsed}/{a.maxAttempts}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<div className="text-muted-foreground">Score</div>
|
||||
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
|
||||
</div>
|
||||
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
||||
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||
{getActionLabel(a.progressStatus)}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function StudentAssignmentsPage() {
|
||||
const student = await getCurrentStudentUser()
|
||||
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<EmptyState title="No user found" description="Create a student user to see assignments." icon={Inbox} />
|
||||
</div>
|
||||
<EmptyState title="No user found" description="Create a student user to see assignments." icon={UserX} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,108 +124,61 @@ export default async function StudentAssignmentsPage() {
|
||||
acc.set(subject, [assignment])
|
||||
}
|
||||
return acc
|
||||
}, new Map<string, typeof assignments>())
|
||||
const subjectEntries = Array.from(assignmentsBySubject.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
}, new Map<string, StudentHomeworkAssignmentListItem[]>())
|
||||
const subjectEntries = Array.from(assignmentsBySubject.entries()).sort((a, b) =>
|
||||
a[0].localeCompare(b[0])
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<>
|
||||
{!hasAssignments ? (
|
||||
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{subjectEntries.map(([subject, items]) => {
|
||||
const answeredItems = items.filter((a) => isAnswered(a.progressStatus))
|
||||
const unansweredItems = items.filter((a) => !isAnswered(a.progressStatus))
|
||||
// 单次遍历分桶,避免重复 filter(PERF-05)
|
||||
const answered: StudentHomeworkAssignmentListItem[] = []
|
||||
const unanswered: StudentHomeworkAssignmentListItem[] = []
|
||||
for (const a of items) {
|
||||
if (isAnswered(a.progressStatus)) {
|
||||
answered.push(a)
|
||||
} else {
|
||||
unanswered.push(a)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={subject} className="space-y-3">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{subject}</div>
|
||||
{unansweredItems.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">未答题</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{unansweredItems.map((a) => (
|
||||
<Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
|
||||
<CardHeader className="gap-2 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="text-base">
|
||||
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
|
||||
{a.title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||
{getStatusLabel(a.progressStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
|
||||
<span className="px-2">•</span>
|
||||
<span>
|
||||
Attempts {a.attemptsUsed}/{a.maxAttempts}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<div className="text-muted-foreground">Score</div>
|
||||
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
|
||||
</div>
|
||||
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
||||
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||
{getActionLabel(a.progressStatus)}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="text-sm font-semibold text-muted-foreground">{subject}</div>
|
||||
{unanswered.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Pending
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{unanswered.map((a) => (
|
||||
<AssignmentCard key={a.id} assignment={a} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{answeredItems.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">已答题</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{answeredItems.map((a) => (
|
||||
<Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
|
||||
<CardHeader className="gap-2 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="text-base">
|
||||
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
|
||||
{a.title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||
{getStatusLabel(a.progressStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
|
||||
<span className="px-2">•</span>
|
||||
<span>
|
||||
Attempts {a.attemptsUsed}/{a.maxAttempts}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<div className="text-muted-foreground">Score</div>
|
||||
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
|
||||
</div>
|
||||
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
||||
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||
{getActionLabel(a.progressStatus)}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
{answered.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Completed
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{answered.map((a) => (
|
||||
<AssignmentCard key={a.id} assignment={a} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)})}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inbox } from "lucide-react"
|
||||
import { UserX } from "lucide-react"
|
||||
|
||||
import { getStudentClasses } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -11,7 +11,7 @@ export default async function StudentCoursesPage() {
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
|
||||
<p className="text-muted-foreground">Your enrolled classes.</p>
|
||||
@@ -19,7 +19,7 @@ export default async function StudentCoursesPage() {
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Create a student user to see courses."
|
||||
icon={Inbox}
|
||||
icon={UserX}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b py-3 px-6 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-6">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { BookOpen, Inbox } from "lucide-react"
|
||||
import { BookOpen } from "lucide-react"
|
||||
|
||||
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
||||
@@ -16,19 +16,7 @@ export default async function StudentTextbookDetailPage({
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Textbook</h2>
|
||||
<p className="text-muted-foreground">Read chapters and review content.</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState title="No user found" description="Create a student user to read textbooks." icon={Inbox} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!student) return notFound()
|
||||
|
||||
const { id } = await params
|
||||
|
||||
@@ -46,7 +34,7 @@ export default async function StudentTextbookDetailPage({
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="hidden sm:inline-block w-px h-4 bg-border" />
|
||||
<span className="hidden sm:inline-block w-px h-4 bg-border" aria-hidden="true" />
|
||||
<Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
|
||||
{textbook.grade && (
|
||||
<Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
|
||||
|
||||
18
src/app/(dashboard)/student/learning/textbooks/loading.tsx
Normal file
18
src/app/(dashboard)/student/learning/textbooks/loading.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full max-w-md" />
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookOpen, Inbox } from "lucide-react"
|
||||
import { BookOpen, UserX } from "lucide-react"
|
||||
|
||||
import { getTextbooks } from "@/modules/textbooks/data-access"
|
||||
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
||||
@@ -25,7 +25,7 @@ export default async function StudentTextbooksPage({
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
|
||||
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={UserX} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,15 +39,10 @@ export default async function StudentTextbooksPage({
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
{/* <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/student/dashboard">Back</Link>
|
||||
</Button>
|
||||
</div> */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
||||
</div>
|
||||
|
||||
<TextbookFilters />
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inbox } from "lucide-react"
|
||||
import { UserX } from "lucide-react"
|
||||
|
||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -18,12 +18,12 @@ export default async function StudentSchedulePage({
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
||||
<p className="text-muted-foreground">Your weekly timetable.</p>
|
||||
</div>
|
||||
<EmptyState title="No user found" description="Create a student user to see schedule." icon={Inbox} />
|
||||
<EmptyState title="No user found" description="Create a student user to see schedule." icon={UserX} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,9 +35,14 @@ export default async function StudentSchedulePage({
|
||||
])
|
||||
|
||||
const classIdParam = sp.classId
|
||||
const classId = typeof classIdParam === "string" ? classIdParam : Array.isArray(classIdParam) ? classIdParam[0] : "all"
|
||||
const resolveClassId = (param: string | string[] | undefined): string => {
|
||||
if (typeof param === "string") return param
|
||||
if (Array.isArray(param)) return param[0] ?? "all"
|
||||
return "all"
|
||||
}
|
||||
const classId = resolveClassId(classIdParam)
|
||||
const filteredItems =
|
||||
classId && classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule
|
||||
classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
23
src/app/(dashboard)/teacher/lesson-plans/new/page.tsx
Normal file
23
src/app/(dashboard)/teacher/lesson-plans/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
src/app/(dashboard)/teacher/lesson-plans/page.tsx
Normal file
39
src/app/(dashboard)/teacher/lesson-plans/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
|
||||
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { formatDateForFile } from "@/shared/lib/utils"
|
||||
import { exportUsersToExcel } from "@/modules/users/import-export"
|
||||
import { exportGradeRecordsToExcel } from "@/modules/grades/export"
|
||||
import {
|
||||
@@ -125,11 +126,3 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ success: false, message }, { status })
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateForFile(): string {
|
||||
const now = new Date()
|
||||
const y = now.getFullYear()
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0")
|
||||
const d = String(now.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { resolvePermissions } from "@/shared/lib/permissions"
|
||||
import { isRole } from "@/shared/types/permissions"
|
||||
import { logLoginEvent } from "@/shared/lib/login-logger"
|
||||
import {
|
||||
PASSWORD_RULES,
|
||||
@@ -126,7 +127,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
token.role = normalizeRole(u.role)
|
||||
token.name = u.name ?? undefined
|
||||
// Store all roles (not just primary) and resolved permissions
|
||||
const allRoles = u.roles ?? [u.role ?? "student"]
|
||||
const allRoles = (u.roles ?? [u.role ?? "student"]).filter(isRole)
|
||||
token.roles = allRoles
|
||||
token.permissions = resolvePermissions(allRoles)
|
||||
}
|
||||
@@ -153,7 +154,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
])
|
||||
|
||||
if (fresh) {
|
||||
const allRoles = roleRows.map((r) => r.name)
|
||||
const allRoles = roleRows.map((r) => r.name).filter(isRole)
|
||||
token.role = resolvePrimaryRole(allRoles)
|
||||
token.name = fresh.name ?? token.name
|
||||
token.roles = allRoles
|
||||
@@ -167,7 +168,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
if (session.user) {
|
||||
session.user.id = String(token.id ?? "")
|
||||
session.user.role = normalizeRole(token.role)
|
||||
session.user.roles = (token.roles ?? []) as string[]
|
||||
session.user.roles = (token.roles ?? []).filter(isRole)
|
||||
session.user.permissions = (token.permissions ?? []) as typeof token.permissions
|
||||
if (typeof token.name === "string") {
|
||||
session.user.name = token.name
|
||||
|
||||
@@ -19,6 +19,14 @@ import {
|
||||
} from "./data-access"
|
||||
import type { GetAnnouncementsParams, Announcement } from "./types"
|
||||
|
||||
function handleActionError(e: unknown): ActionState<never> {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
|
||||
export async function createAnnouncementAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -71,11 +79,7 @@ export async function createAnnouncementAction(
|
||||
|
||||
return { success: true, message: "Announcement created", data: id }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +139,7 @@ export async function updateAnnouncementAction(
|
||||
|
||||
return { success: true, message: "Announcement updated", data: id }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,11 +157,7 @@ export async function deleteAnnouncementAction(id: string): Promise<ActionState<
|
||||
|
||||
return { success: true, message: "Announcement deleted" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +179,7 @@ export async function publishAnnouncementAction(id: string): Promise<ActionState
|
||||
|
||||
return { success: true, message: "Announcement published" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,11 +198,7 @@ export async function archiveAnnouncementAction(id: string): Promise<ActionState
|
||||
|
||||
return { success: true, message: "Announcement archived" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,10 +210,6 @@ export async function getAnnouncementsAction(
|
||||
const data = await getAnnouncements(params)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user