sync-docs-and-fixes

This commit is contained in:
SpecialX
2026-03-03 17:32:26 +08:00
parent 538805bad0
commit eb08c0ab68
73 changed files with 2218 additions and 422 deletions

View File

@@ -25,7 +25,7 @@ export default function RegisterPage() {
if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" }
try {
const [{ db }, { users }] = await Promise.all([
const [{ db }, { roles, users, usersToRoles }] = await Promise.all([
import("@/shared/db"),
import("@/shared/db/schema"),
])
@@ -45,13 +45,25 @@ export default function RegisterPage() {
if (existing) return { success: false, message: "该邮箱已注册" }
const hashedPassword = normalizeBcryptHash(await hash(password, 10))
const userId = createId()
await db.insert(users).values({
id: createId(),
id: userId,
name: name.length ? name : null,
email,
password: hashedPassword,
role: "student",
})
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, "student"),
columns: { id: true },
})
if (!roleRow) {
await db.insert(roles).values({ name: "student" })
}
const resolvedRole = roleRow
?? (await db.query.roles.findFirst({ where: eq(roles.name, "student"), columns: { id: true } }))
if (resolvedRole?.id) {
await db.insert(usersToRoles).values({ userId, roleId: resolvedRole.id })
}
return { success: true, message: "账户创建成功" }
} catch (error) {

View File

@@ -1,19 +1,18 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getUserProfile } from "@/modules/users/data-access"
export const dynamic = "force-dynamic"
const normalizeRole = (value: unknown) => {
const role = String(value ?? "").trim().toLowerCase()
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return "student"
}
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/login")
const role = normalizeRole(session.user.role)
const userId = String(session.user.id ?? "").trim()
if (!userId) redirect("/login")
const profile = await getUserProfile(userId)
if (!profile) redirect("/login")
const role = profile.role || "student"
if (role === "admin") redirect("/admin/dashboard")
if (role === "student") redirect("/student/dashboard")

View File

@@ -2,7 +2,7 @@ import Link from "next/link"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
@@ -44,6 +44,7 @@ export default async function ProfilePage() {
const role = userProfile.role || "student"
const isStudent = role === "student"
const isTeacher = role === "teacher"
const studentData =
isStudent
@@ -107,6 +108,14 @@ export default async function ProfilePage() {
})()
: null
const teacherData =
isTeacher
? await (async () => {
const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
return { subjects, classes }
})()
: null
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
@@ -231,6 +240,65 @@ export default async function ProfilePage() {
</div>
</div>
) : null}
{teacherData ? (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Teacher Overview</h2>
<div className="text-sm text-muted-foreground">Your teaching subjects and classes.</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Teaching Subjects</CardTitle>
<CardDescription>Subjects you are currently assigned to teach.</CardDescription>
</CardHeader>
<CardContent>
{teacherData.subjects.length === 0 ? (
<div className="text-sm text-muted-foreground">No subjects assigned yet.</div>
) : (
<div className="flex flex-wrap gap-2">
{teacherData.subjects.map((subject) => (
<Badge key={subject} variant="secondary">
{subject}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Teaching Classes</CardTitle>
<CardDescription>Classes you are currently managing.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{teacherData.classes.length === 0 ? (
<div className="text-sm text-muted-foreground">No classes assigned yet.</div>
) : (
teacherData.classes.map((cls) => (
<div key={cls.id} className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{cls.name}</div>
<div className="text-xs text-muted-foreground">
{cls.grade}
{cls.homeroom ? `${cls.homeroom}` : ""}
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/my/${encodeURIComponent(cls.id)}`}>View</Link>
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
) : null}
</div>
)
}

View File

@@ -1,4 +1,3 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { BookOpen, Inbox } from "lucide-react"

View File

@@ -5,8 +5,6 @@ import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
export const dynamic = "force-dynamic"
@@ -27,12 +25,6 @@ export default async function StudentTextbooksPage({
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
</div>
)
@@ -47,7 +39,7 @@ 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 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>
@@ -55,7 +47,7 @@ export default async function StudentTextbooksPage({
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
</div> */}
<TextbookFilters />

View File

@@ -5,26 +5,21 @@ import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detai
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function ClassDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<SearchParams>
}) {
const { id } = await params
// Parallel data fetching
const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
getClassHomeworkInsights({ classId: id, limit: 20 }),
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
])
@@ -32,7 +27,7 @@ export default async function ClassDetailPage({
if (!insights) return notFound()
// Fetch subject scores
const studentScores = await getClassStudentSubjectScoresV2(id)
const studentScores = await getClassStudentSubjectScoresV2({ classId: id })
// Data mapping for widgets
const assignmentSummaries = insights.assignments.map(a => ({
@@ -91,10 +86,7 @@ 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
classId={insights.class.id}
assignments={assignmentSummaries}
/>
<ClassTrendsWidget assignments={assignmentSummaries} />
<ClassStudentsWidget
classId={insights.class.id}
students={studentSummaries}

View File

@@ -1,9 +1,5 @@
import { eq } from "drizzle-orm"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access"
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { grades } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
@@ -12,11 +8,11 @@ export default function MyClassesPage() {
}
async function MyClassesPageImpl() {
const classes = await getTeacherClasses()
const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()])
return (
<div className="flex h-full flex-col space-y-4 p-8">
<MyClassesGrid classes={classes} canCreateClass={false} />
<MyClassesGrid classes={classes} subjectOptions={subjectOptions} />
</div>
)
}

View File

@@ -82,7 +82,6 @@ function StudentsResultsFallback() {
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
const params = await searchParams
// Logic to determine default class (first one available)
const defaultClassId = classes.length > 0 ? classes[0].id : undefined

View File

@@ -1,37 +1,9 @@
import { ExamForm } from "@/modules/exams/components/exam-form"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
export default function CreateExamPage() {
return (
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
<div className="space-y-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Create</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div>
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
<p className="text-muted-foreground mt-2">
Set up a new exam draft and choose your assembly method.
</p>
</div>
</div>
<div className="flex justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<ExamForm />
</div>
)

View File

@@ -5,7 +5,6 @@ import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"

View File

@@ -14,6 +14,7 @@ import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
@@ -27,9 +28,10 @@ const getParam = (params: SearchParams, key: string) => {
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const classId = getParam(sp, "classId") || undefined
const creatorId = await getTeacherIdForMutations()
const [assignments, classes] = await Promise.all([
getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
getHomeworkAssignments({ creatorId, classId: classId && classId !== "all" ? classId : undefined }),
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
])
const hasAssignments = assignments.length > 0

View File

@@ -23,6 +23,7 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
const q = getParam(params, "q")
const type = getParam(params, "type")
const difficulty = getParam(params, "difficulty")
const knowledgePointId = getParam(params, "kp")
const questionType: QuestionType | undefined =
type === "single_choice" ||
@@ -37,10 +38,16 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
q: q || undefined,
type: questionType,
difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined,
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
pageSize: 200,
})
const hasFilters = Boolean(q || (type && type !== "all") || (difficulty && difficulty !== "all"))
const hasFilters = Boolean(
q ||
(type && type !== "all") ||
(difficulty && difficulty !== "all") ||
(knowledgePointId && knowledgePointId !== "all")
)
if (questions.length === 0) {
return (

View File

@@ -3,7 +3,7 @@ import { eq, inArray } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { classes, classSubjectTeachers, users } from "@/shared/db/schema"
import { classes, classSubjectTeachers, roles, users, usersToRoles, subjects } from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
import { enrollStudentByInvitationCode } from "@/modules/classes/data-access"
@@ -34,13 +34,14 @@ export async function POST(req: Request) {
const role = (allowedRoles as readonly string[]).includes(roleRaw) ? roleRaw : null
if (!role) return NextResponse.json({ success: false, message: "Invalid role" }, { status: 400 })
const current = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { role: true },
})
const currentRole = String(current?.role ?? "student")
const currentRoleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId))
const currentMapped = currentRoleRows.map((r) => String(r.name ?? "").trim().toLowerCase())
if (role === "admin" && currentRole !== "admin") {
if (role === "admin" && !currentMapped.includes("admin")) {
return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 })
}
@@ -58,16 +59,33 @@ export async function POST(req: Request) {
.map((s) => String(s).trim())
.filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject))
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, role),
columns: { id: true },
})
if (!roleRow) {
await db.insert(roles).values({ name: role })
}
const resolvedRole = roleRow
?? (await db.query.roles.findFirst({ where: eq(roles.name, role), columns: { id: true } }))
const roleId = resolvedRole?.id
await db
.update(users)
.set({
role,
name,
phone: phone.length ? phone : null,
address: address.length ? address : null,
})
.where(eq(users.id, userId))
if (roleId) {
await db
.insert(usersToRoles)
.values({ userId, roleId })
.onDuplicateKeyUpdate({ set: { roleId } })
}
if (role === "student" && codes.length) {
for (const code of codes) {
await enrollStudentByInvitationCode(userId, code)
@@ -87,14 +105,26 @@ export async function POST(req: Request) {
}
}
// Resolve subject ids when possible (by name exact match)
const subjectsFound = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, teacherSubjects))
const subjectIdByName = new Map<string, string>()
for (const s of subjectsFound) {
if (s.name && s.id) subjectIdByName.set(String(s.name), String(s.id))
}
for (const code of codes) {
const classId = byCode.get(code)
if (!classId) continue
for (const subject of teacherSubjects) {
const subjectId = subjectIdByName.get(subject)
if (!subjectId) continue
await db
.insert(classSubjectTeachers)
.values({ classId, subject, teacherId: userId })
.onDuplicateKeyUpdate({ set: { teacherId: userId, updatedAt: new Date() } })
.values({ classId, subjectId, teacherId: userId })
.onDuplicateKeyUpdate({ set: { teacherId: userId, subjectId, updatedAt: new Date() } })
}
}
}
@@ -106,4 +136,3 @@ export async function POST(req: Request) {
return NextResponse.json({ success: true })
}

View File

@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import { roles, users, usersToRoles } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
@@ -14,12 +14,32 @@ export async function GET() {
return NextResponse.json({ required: false })
}
const row = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { onboardedAt: true, role: true },
})
const [row, roleRows] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, userId),
columns: { onboardedAt: true },
}),
db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId)),
])
const normalizeRole = (value: string) => {
const role = value.trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return ""
}
const mappedRoles = roleRows.map((r) => normalizeRole(r.name)).filter(Boolean)
const resolvedRole = mappedRoles.find((r) => r === "admin")
?? mappedRoles.find((r) => r === "teacher")
?? mappedRoles.find((r) => r === "parent")
?? mappedRoles.find((r) => r === "student")
?? "student"
const required = !row?.onboardedAt
return NextResponse.json({ required, role: row?.role ?? "student" })
return NextResponse.json({ required, role: resolvedRole })
}

View File

@@ -1,13 +1,23 @@
import { compare, hash } from "bcryptjs"
import { compare } from "bcryptjs"
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
const normalizeRole = (value: unknown) => {
const role = String(value ?? "").trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return "student"
}
const resolvePrimaryRole = (roleNames: string[]) => {
const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
if (mapped.includes("admin")) return "admin"
if (mapped.includes("teacher")) return "teacher"
if (mapped.includes("parent")) return "parent"
if (mapped.includes("student")) return "student"
return "student"
}
const normalizeBcryptHash = (value: string) => {
if (value.startsWith("$2")) return value
if (value.startsWith("$")) return `$2b${value}`
@@ -30,7 +40,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const password = String(credentials?.password ?? "")
if (!email || !password) return null
const [{ eq }, { db }, { users }] = await Promise.all([
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
import("drizzle-orm"),
import("@/shared/db"),
import("@/shared/db/schema"),
@@ -48,11 +58,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const ok = await compare(password, normalizedPassword)
if (!ok) return null
const roleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, user.id))
const resolvedRole = resolvePrimaryRole(roleRows.map((r) => r.name))
return {
id: user.id,
name: user.name ?? undefined,
email: user.email,
role: normalizeRole(user.role),
role: resolvedRole,
}
},
}),
@@ -67,19 +85,26 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const userId = String(token.id ?? "").trim()
if (userId) {
const [{ eq }, { db }, { users }] = await Promise.all([
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
import("drizzle-orm"),
import("@/shared/db"),
import("@/shared/db/schema"),
])
const fresh = await db.query.users.findFirst({
const [fresh, roleRows] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, userId),
columns: { role: true, name: true },
})
columns: { name: true },
}),
db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId)),
])
if (fresh) {
token.role = normalizeRole(fresh.role ?? token.role)
token.role = resolvePrimaryRole(roleRows.map((r) => r.name))
token.name = fresh.name ?? token.name
}
}

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache"
import { and, eq, sql, or, inArray } from "drizzle-orm"
import { and, eq, sql, or } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
@@ -16,6 +16,7 @@ import {
deleteTeacherClass,
enrollStudentByEmail,
enrollStudentByInvitationCode,
enrollTeacherByInvitationCode,
ensureClassInvitationCode,
regenerateClassInvitationCode,
setClassSubjectTeachers,
@@ -371,8 +372,18 @@ export async function joinClassByInvitationCodeAction(
return { success: false, message: "Unauthorized" }
}
const subjectValue = formData.get("subject")
const subject = role === "teacher" && typeof subjectValue === "string" ? subjectValue.trim() : null
if (role === "teacher" && (!subject || subject.length === 0)) {
return { success: false, message: "Subject is required" }
}
try {
const classId = await enrollStudentByInvitationCode(session.user.id, code)
const classId =
role === "teacher"
? await enrollTeacherByInvitationCode(session.user.id, code, subject)
: await enrollStudentByInvitationCode(session.user.id, code)
if (role === "student") {
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")

View File

@@ -1,6 +1,6 @@
import Link from "next/link"
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"

View File

@@ -42,7 +42,7 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
return (
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
{WEEKDAYS.slice(0, 5).map((day, i) => (
{WEEKDAYS.slice(0, 5).map((day) => (
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
{day}
</div>

View File

@@ -6,7 +6,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avat
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 { formatDate } from "@/shared/lib/utils"
interface StudentSummary {
id: string

View File

@@ -1,13 +1,12 @@
"use client"
import { useState, useMemo } from "react"
import { useState } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
@@ -31,7 +30,6 @@ interface AssignmentSummary {
}
interface ClassTrendsWidgetProps {
classId: string
assignments: AssignmentSummary[]
compact?: boolean
className?: string
@@ -121,7 +119,7 @@ export function ClassSubmissionTrendChart({
)
}
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
const [selectedSubject, setSelectedSubject] = useState<string>("all")

View File

@@ -1,7 +1,7 @@
"use client"
import Link from "next/link"
import { useMemo, useState } from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import {
Plus,
@@ -10,11 +10,9 @@ import {
Users,
MapPin,
GraduationCap,
Search,
} from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -30,30 +28,35 @@ import {
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
import type { TeacherClass, ClassScheduleItem } from "../types"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { TeacherClass } from "../types"
import {
ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction,
joinClassByInvitationCodeAction,
} from "../actions"
const GRADIENTS = [
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
]
function getClassGradient(id: string) {
return "bg-card border-border shadow-sm hover:shadow-md"
const getSeededValue = (seed: string, index: number) => {
let h = 2166136261
const str = `${seed}:${index}`
for (let i = 0; i < str.length; i += 1) {
h ^= str.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return (h >>> 0) / 4294967296
}
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
export function MyClassesGrid({
classes,
subjectOptions,
}: {
classes: TeacherClass[]
subjectOptions: string[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
const [joinSubject, setJoinSubject] = useState("")
const handleJoin = async (formData: FormData) => {
setIsWorking(true)
@@ -62,6 +65,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
if (res.success) {
toast.success(res.message || "Joined class successfully")
setJoinOpen(false)
setJoinSubject("")
router.refresh()
} else {
toast.error(res.message || "Failed to join class")
@@ -83,6 +87,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
onOpenChange={(open) => {
if (isWorking) return
setJoinOpen(open)
if (!open) setJoinSubject("")
}}
>
<DialogTrigger asChild>
@@ -140,12 +145,30 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
Ask your administrator for the code if you don&apos;t have one.
</p>
</div>
<div className="space-y-3">
<Label htmlFor="join-subject" className="text-sm font-medium">
</Label>
<Select value={joinSubject} onValueChange={(v) => setJoinSubject(v)}>
<SelectTrigger id="join-subject" className="h-12">
<SelectValue placeholder={subjectOptions.length === 0 ? "暂无可选科目" : "选择教学科目"} />
</SelectTrigger>
<SelectContent>
{subjectOptions.map((subject) => (
<SelectItem key={subject} value={subject}>
{subject}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="subject" value={joinSubject} />
</div>
</div>
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
<Button type="submit" disabled={isWorking || !joinSubject || subjectOptions.length === 0} className="min-w-[100px]">
{isWorking ? "Joining..." : "Join Class"}
</Button>
</DialogFooter>
@@ -167,7 +190,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
/>
) : (
classes.map((c) => (
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} />
))
)}
</div>
@@ -182,11 +205,9 @@ import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
function ClassTicket({
c,
isWorking,
onWorkingChange,
}: {
c: TeacherClass
isWorking: boolean
onWorkingChange: (v: boolean) => void
}) {
const router = useRouter()
@@ -256,7 +277,11 @@ function ClassTicket({
{/* Decorative Barcode Strip */}
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
<div
key={i}
className="w-full h-px bg-primary/20"
style={{ marginBottom: `${2 + getSeededValue(c.id, i) * 8}px` }}
></div>
))}
</div>
@@ -320,7 +345,7 @@ function ClassTicket({
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
{Array.from({ length: 16 }).map((_, i) => (
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
<div key={i} className={cn("bg-transparent", getSeededValue(`${c.id}-qr`, i) > 0.5 && "bg-black")}></div>
))}
</div>
</div>
@@ -373,12 +398,7 @@ function ClassTicket({
{/* Real Chart */}
<div className="h-[140px] w-full">
<ClassTrendsWidget
classId={c.id}
assignments={recentAssignments}
compact
className="h-full w-full"
/>
<ClassTrendsWidget assignments={recentAssignments} compact className="h-full w-full" />
</div>
</div>

View File

@@ -2,11 +2,9 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
@@ -518,4 +516,4 @@ export function ScheduleView({
</AlertDialog>
</div>
)
}
}

View File

@@ -1,9 +1,9 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input"
@@ -33,7 +33,6 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions"
@@ -78,8 +77,6 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
const hasFilters = search || classId !== "all" || status !== "all"
return (
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">

View File

@@ -5,11 +5,10 @@ import { useRouter } from "next/navigation"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils"
import { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,

View File

@@ -2,9 +2,10 @@ import "server-only";
import { randomInt } from "node:crypto"
import { cache } from "react"
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import {
classes,
@@ -19,7 +20,9 @@ import {
schools,
subjects,
exams,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types"
import type {
@@ -43,16 +46,22 @@ import type {
UpdateTeacherClassInput,
} from "./types"
const getDefaultTeacherId = cache(async () => {
const [row] = await db
const getSessionTeacherId = async (): Promise<string | null> => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
const [teacher] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.role, "teacher"))
.orderBy(asc(users.createdAt))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), eq(roles.name, "teacher")))
.limit(1)
return teacher?.id ?? null
}
return row?.id
})
// Strict subjectId-based mapping: no aliasing
const isDuplicateInvitationCodeError = (err: unknown) => {
if (!err) return false
@@ -80,11 +89,20 @@ const generateUniqueInvitationCode = async (): Promise<string> => {
}
export const getTeacherIdForMutations = async (): Promise<string> => {
const teacherId = await getDefaultTeacherId()
if (!teacherId) throw new Error("No teacher available")
const teacherId = await getSessionTeacherId()
if (!teacherId) throw new Error("Teacher not found")
return teacherId
}
export const getClassSubjects = async (): Promise<string[]> => {
const rows = await db.query.subjects.findMany({
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
return Array.from(new Set(names))
}
const normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "")
const parseFirstInt = (v: string) => {
@@ -118,23 +136,30 @@ const compareClassLike = (
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room))
}
const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId))
const assignedIds = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, teacherId))
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
}
const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise<string[]> => {
const rows = await db
.select({ subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.teacherId, teacherId), eq(classSubjectTeachers.classId, classId)))
return Array.from(new Set(rows.map((r) => String(r.subjectId))))
}
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const rows = await (async () => {
try {
const ownedIds = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, teacherId))
const enrolledIds = await db
.select({ id: classEnrollments.classId })
.from(classEnrollments)
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
if (allIds.length === 0) return []
@@ -206,7 +231,9 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(eq(users.role, "teacher"))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(roles.name, "teacher"))
.orderBy(asc(users.createdAt))
return rows.map((r) => ({
@@ -216,6 +243,23 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
}))
})
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
const teacherId = await getSessionTeacherId()
if (!teacherId) return []
const rows = await db
.select({ subject: subjects.name })
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(eq(classSubjectTeachers.teacherId, teacherId))
.groupBy(subjects.name)
.orderBy(asc(subjects.name))
return rows
.map((r) => r.subject as ClassSubject)
.filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s))
})
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
const [rows, subjectRows] = await Promise.all([
(async () => {
@@ -304,14 +348,15 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
db
.select({
classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject,
subject: subjects.name,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
@@ -425,16 +470,17 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
db
.select({
classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject,
subject: subjects.name,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.where(inArray(classes.gradeId, gradeIds))
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
@@ -589,14 +635,17 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
export const getClassStudents = cache(
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const q = params?.q?.trim().toLowerCase()
const status = params?.status?.trim().toLowerCase()
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0) return []
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) {
conditions.push(eq(classes.id, classId))
@@ -647,12 +696,15 @@ export const getClassStudents = cache(
export const getClassSchedule = cache(
async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0) return []
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) conditions.push(eq(classSchedule.classId, classId))
const rows = await db
@@ -707,11 +759,13 @@ const toScoreStats = (scores: number[]): ScoreStats => {
export const getClassHomeworkInsights = cache(
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
const teacherId = params.teacherId ?? (await getDefaultTeacherId())
const teacherId = params.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return null
const classId = params.classId.trim()
if (!classId) return null
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return null
const [classRow] = await db
.select({
@@ -721,12 +775,15 @@ export const getClassHomeworkInsights = cache(
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
teacherId: classes.teacherId,
})
.from(classes)
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
.where(and(eq(classes.id, classId), inArray(classes.id, accessibleIds)))
.limit(1)
if (!classRow) return null
const isHomeroomTeacher = classRow.teacherId === teacherId
const subjectIdFilter = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
const enrollments = await db
.select({
@@ -735,12 +792,29 @@ export const getClassHomeworkInsights = cache(
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(and(eq(classes.teacherId, teacherId), eq(classEnrollments.classId, classId)))
.where(and(inArray(classes.id, accessibleIds), eq(classEnrollments.classId, classId)))
const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId)
const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId)
const studentIds = enrollments.map((e) => e.studentId)
if (!isHomeroomTeacher && subjectIdFilter.length === 0) {
return {
class: {
id: classRow.id,
name: classRow.name,
grade: classRow.grade,
homeroom: classRow.homeroom,
room: classRow.room,
invitationCode: classRow.invitationCode ?? null,
},
studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}
}
if (studentIds.length === 0) {
return {
class: {
@@ -782,6 +856,10 @@ export const getClassHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
if (subjectIdFilter.length > 0) {
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
}
const assignments = await db
.select({
id: homeworkAssignments.id,
@@ -795,7 +873,7 @@ export const getClassHomeworkInsights = cache(
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
.where(and(...assignmentConditions))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
@@ -1239,6 +1317,12 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode()
try {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
await db.transaction(async (tx) => {
await tx.insert(classes).values({
id,
@@ -1253,13 +1337,14 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
teacherId,
})
await tx.insert(classSubjectTeachers).values(
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId: id,
subject,
subjectId: idByName.get(name)!,
teacherId: null,
}))
)
await tx.insert(classSubjectTeachers).values(values)
})
return id
} catch (err) {
@@ -1291,13 +1376,21 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
const [teacher] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, teacherId), eq(users.role, "teacher")))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, teacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode()
try {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
await db.transaction(async (tx) => {
await tx.insert(classes).values({
id,
@@ -1312,13 +1405,14 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
teacherId,
})
await tx.insert(classSubjectTeachers).values(
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId: id,
subject,
subjectId: idByName.get(name)!,
teacherId: null,
}))
)
await tx.insert(classSubjectTeachers).values(values)
})
return id
} catch (err) {
@@ -1410,6 +1504,123 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
return cls.id
}
export async function enrollTeacherByInvitationCode(
teacherId: string,
invitationCode: string,
subject: string | null
): Promise<string> {
const tid = teacherId.trim()
const code = invitationCode.trim()
if (!tid) throw new Error("Missing teacher id")
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
const [teacher] = await db
.select({ id: users.id })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, tid), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
const [cls] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.invitationCode, code))
.limit(1)
if (!cls) throw new Error("Invalid invitation code")
if (cls.teacherId === tid) return cls.id
const subjectValue = typeof subject === "string" ? subject.trim() : ""
const [existing] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (existing && !subjectValue) return cls.id
if (subjectValue) {
const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
if (!subRow) throw new Error("Subject not found")
const sid = subRow.id
const [mapping] = await db
.select({ teacherId: classSubjectTeachers.teacherId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
.limit(1)
if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
if (mapping?.teacherId === tid) return cls.id
if (!mapping) {
await db
.insert(classSubjectTeachers)
.values({ classId: cls.id, subjectId: sid, teacherId: null })
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
}
const [existingSubject] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (existingSubject) return cls.id
await db
.update(classSubjectTeachers)
.set({ teacherId: tid })
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
const [assigned] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (!assigned) throw new Error("Subject already assigned")
} else {
const subjectRows = await db
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
if (!preferred) throw new Error("Class already has assigned teachers")
const sid = subjectRows.find((r) => r.name === preferred)!.id
await db
.update(classSubjectTeachers)
.set({ teacherId: tid })
.where(
and(
eq(classSubjectTeachers.classId, cls.id),
eq(classSubjectTeachers.subjectId, sid),
isNull(classSubjectTeachers.teacherId)
)
)
const [assigned] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(
and(
eq(classSubjectTeachers.classId, cls.id),
eq(classSubjectTeachers.subjectId, sid),
eq(classSubjectTeachers.teacherId, tid)
)
)
.limit(1)
if (!assigned) throw new Error("Class already has assigned teachers")
}
return cls.id
}
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
const teacherId = await getTeacherIdForMutations()
@@ -1468,7 +1679,9 @@ export async function updateAdminClass(
const [teacher] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, nextTeacherId), eq(users.role, "teacher")))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, nextTeacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
@@ -1498,7 +1711,9 @@ export async function setClassSubjectTeachers(params: {
const rows = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, "teacher"), inArray(users.id, teacherIds)))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(roles.name, "teacher"), inArray(users.id, teacherIds)))
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
}
@@ -1508,15 +1723,24 @@ export async function setClassSubjectTeachers(params: {
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
}
// Map subject names to ids
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId,
subjectId: idByName.get(name)!,
teacherId: teacherBySubject.get(name) ?? null,
}))
await db
.insert(classSubjectTeachers)
.values(
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
classId,
subject,
teacherId: teacherBySubject.get(subject) ?? null,
}))
)
.values(values)
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
}
@@ -1564,13 +1788,19 @@ export async function enrollStudentByEmail(classId: string, email: string): Prom
if (!owned) throw new Error("Class not found")
const [student] = await db
.select({ id: users.id, role: users.role })
.select({ id: users.id })
.from(users)
.where(eq(users.email, normalized))
.limit(1)
if (!student) throw new Error("Student not found")
if (student.role !== "student") throw new Error("User is not a student")
const [studentRole] = await db
.select({ id: usersToRoles.userId })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, "student")))
.limit(1)
if (!studentRole) throw new Error("User is not a student")
await db
.insert(classEnrollments)
@@ -1823,8 +2053,26 @@ export const getStudentsSubjectScores = cache(
)
export const getClassStudentSubjectScoresV2 = cache(
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
// 1. Get student IDs in the class
async (params: { classId: string; teacherId?: string }): Promise<Map<string, Record<string, number | null>>> => {
const teacherId = params.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return new Map()
const classId = params.classId.trim()
if (!classId) return new Map()
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return new Map()
const [classRow] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) return new Map()
const isHomeroomTeacher = classRow.teacherId === teacherId
const subjectIds = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
if (!isHomeroomTeacher && subjectIds.length === 0) return new Map()
const enrollments = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
@@ -1833,7 +2081,24 @@ export const getClassStudentSubjectScoresV2 = cache(
eq(classEnrollments.status, "active")
))
const studentIds = enrollments.map(e => e.studentId)
return getStudentsSubjectScores(studentIds)
const studentIds = enrollments.map((e) => e.studentId)
const studentScores = await getStudentsSubjectScores(studentIds)
if (subjectIds.length === 0) return studentScores
// Map subjectIds to names for filtering
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
const allowed = new Set(subjectRows.map((s) => s.name))
const filtered = new Map<string, Record<string, number | null>>()
for (const [studentId, scores] of studentScores.entries()) {
const nextScores: Record<string, number | null> = {}
for (const [subject, score] of Object.entries(scores)) {
if (allowed.has(subject)) nextScores[subject] = score
}
filtered.set(studentId, nextScores)
}
return filtered
}
)

View File

@@ -15,10 +15,8 @@ type Stat = {
}
export function StudentStatsGrid({
enrolledClassCount,
dueSoonCount,
overdueCount,
gradedCount,
ranking,
}: {
enrolledClassCount: number

View File

@@ -7,8 +7,6 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import type { TeacherClass } from "@/modules/classes/types"
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">

View File

@@ -14,7 +14,6 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
}
export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
const totalStudents = data.classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
const todayWeekday = toWeekday(new Date())
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))

View File

@@ -1,7 +1,7 @@
import "server-only"
import { cache } from "react"
import { count, desc, eq, gt } from "drizzle-orm"
import { count, desc, eq, gt, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
@@ -11,9 +11,11 @@ import {
homeworkAssignments,
homeworkSubmissions,
questions,
roles,
sessions,
textbooks,
users,
usersToRoles,
} from "@/shared/db/schema"
import type { AdminDashboardData } from "./types"
@@ -23,7 +25,7 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
const [
activeSessionsRow,
userCountRow,
userRoleRows,
userRoleCountRows,
classCountRow,
textbookCountRow,
chapterCountRow,
@@ -37,7 +39,11 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
] = await Promise.all([
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
db.select({ value: count() }).from(users),
db.select({ role: users.role, value: count() }).from(users).groupBy(users.role),
db
.select({ role: roles.name, value: count() })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.groupBy(roles.name),
db.select({ value: count() }).from(classes),
db.select({ value: count() }).from(textbooks),
db.select({ value: count() }).from(chapters),
@@ -52,7 +58,6 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
id: users.id,
name: users.name,
email: users.email,
role: users.role,
createdAt: users.createdAt,
})
.from(users)
@@ -72,17 +77,55 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0)
const userRoleCounts = userRoleRows
const userRoleCounts = userRoleCountRows
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
.sort((a, b) => b.count - a.count)
const recentUsers = recentUserRows.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
createdAt: u.createdAt.toISOString(),
}))
const normalizeRole = (value: string) => {
const role = value.trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return ""
}
const recentUserIds = recentUserRows.map((u) => u.id)
const recentRoleRows = recentUserIds.length
? await db
.select({
userId: usersToRoles.userId,
roleName: roles.name,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(usersToRoles.userId, recentUserIds))
: []
const rolesByUserId = new Map<string, string[]>()
for (const row of recentRoleRows) {
const list = rolesByUserId.get(row.userId) ?? []
list.push(row.roleName)
rolesByUserId.set(row.userId, list)
}
const resolvePrimaryRole = (roleNames: string[]) => {
const mapped = roleNames.map(normalizeRole).filter(Boolean)
if (mapped.includes("admin")) return "admin"
if (mapped.includes("teacher")) return "teacher"
if (mapped.includes("parent")) return "parent"
if (mapped.includes("student")) return "student"
return "student"
}
const recentUsers = recentUserRows.map((u) => {
const roleNames = rolesByUserId.get(u.id) ?? []
return {
id: u.id,
name: u.name,
email: u.email,
role: resolvePrimaryRole(roleNames),
createdAt: u.createdAt.toISOString(),
}
})
return {
activeSessionsCount,

View File

@@ -25,6 +25,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
// Helper to flatten questions for continuous numbering
let questionCounter = 0
const parseContent = (raw: unknown): QuestionContent => {
if (raw && typeof raw === "object") return raw as QuestionContent
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === "object") return parsed as QuestionContent
return { text: raw }
} catch {
return { text: raw }
}
}
return {}
}
const renderNode = (node: ExamNode, depth: number = 0) => {
if (node.type === 'group') {
return (
@@ -45,7 +59,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
if (node.type === 'question' && node.question) {
questionCounter++
const q = node.question
const content = q.content as QuestionContent
const content = parseContent(q.content)
return (
<div key={node.id} className="mb-6 break-inside-avoid">

View File

@@ -28,13 +28,26 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
<div className="space-y-3 pb-4">
{questions.map((q) => {
const added = isAdded(q.id)
const content = q.content as { text?: string }
const parsedContent = (() => {
if (q.content && typeof q.content === "object") return q.content as { text?: string }
if (typeof q.content === "string") {
try {
const parsed = JSON.parse(q.content) as unknown
if (parsed && typeof parsed === "object") return parsed as { text?: string }
return { text: q.content }
} catch {
return { text: q.content }
}
}
return { text: "" }
})()
const typeLabel = typeof q.type === "string" ? q.type.replace("_", " ") : "unknown"
return (
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] uppercase">
{q.type.replace("_", " ")}
{typeLabel}
</Badge>
<Badge variant="secondary" className="text-[10px]">
Lvl {q.difficulty}
@@ -46,7 +59,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
))}
</div>
<p className="text-sm line-clamp-2 text-muted-foreground">
{content.text || "No content preview"}
{parsedContent.text || "No content preview"}
</p>
</div>
<div className="flex items-center">

View File

@@ -118,7 +118,22 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
onMove: (dir: 'up' | 'down') => void
onScoreChange: (score: number) => void
}) {
const content = item.question?.content as { text?: string }
const rawContent = item.question?.content
const parsedContent = (() => {
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
if (typeof rawContent === "string") {
try {
const parsed = JSON.parse(rawContent) as unknown
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
return { text: rawContent }
} catch {
return { text: rawContent }
}
}
return { text: "" }
})()
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
return (
<div className="group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between gap-3">
@@ -127,7 +142,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
{index + 1}
</span>
<p className="text-sm line-clamp-2 pt-0.5">
{content?.text || "Question content"}
{parsedContent.text || "Question content"}
</p>
</div>
<Button
@@ -139,6 +154,16 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
<Trash2 className="h-4 w-4" />
</Button>
</div>
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
<div className="grid grid-cols-1 gap-1 pl-8 text-xs text-muted-foreground">
{options.map((opt, idx) => (
<div key={opt.id ?? idx} className="flex gap-2">
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
<span>{opt.text ?? ""}</span>
</div>
))}
</div>
)}
<div className="flex items-center justify-between pl-8">
<div className="flex items-center gap-1">

View File

@@ -82,7 +82,22 @@ function SortableItem({
opacity: isDragging ? 0.5 : 1,
}
const content = item.question?.content as { text?: string }
const rawContent = item.question?.content
const parsedContent = (() => {
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
if (typeof rawContent === "string") {
try {
const parsed = JSON.parse(rawContent) as unknown
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
return { text: rawContent }
} catch {
return { text: rawContent }
}
}
return { text: "" }
})()
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
return (
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
@@ -92,7 +107,7 @@ function SortableItem({
<GripVertical className="h-4 w-4" />
</button>
<p className="text-sm line-clamp-2 pt-0.5 select-none">
{content?.text || "Question content"}
{parsedContent.text || "Question content"}
</p>
</div>
<Button
@@ -104,6 +119,16 @@ function SortableItem({
<Trash2 className="h-4 w-4" />
</Button>
</div>
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 pl-8 text-xs text-muted-foreground">
{options.map((opt, idx) => (
<div key={opt.id ?? idx} className="flex gap-2">
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
<span>{opt.text ?? ""}</span>
</div>
))}
</div>
)}
<div className="flex items-center justify-end pl-8">
<div className="flex items-center gap-2">

View File

@@ -79,7 +79,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
}
} catch (e) {
} catch {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
} finally {

View File

@@ -1,7 +1,6 @@
"use client"
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
import { useFormStatus } from "react-dom"
import { useCallback, useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Search, Eye } from "lucide-react"
@@ -34,15 +33,6 @@ type ExamAssemblyProps = {
questionOptions: Question[]
}
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Saving..." : label}
</Button>
)
}
export function ExamAssembly(props: ExamAssemblyProps) {
const router = useRouter()
const [search, setSearch] = useState("")
@@ -83,7 +73,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return []
})
const fetchQuestions = (reset: boolean = false) => {
const fetchQuestions = useCallback((reset: boolean = false) => {
startBankTransition(async () => {
const nextPage = reset ? 1 : page + 1
try {
@@ -107,11 +97,11 @@ export function ExamAssembly(props: ExamAssemblyProps) {
setHasMore(result.data.length === 20)
setPage(nextPage)
}
} catch (error) {
} catch {
toast.error("Failed to load questions")
}
})
}
}, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter])
const isFirstRender = useRef(true)
@@ -123,7 +113,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
}
}
fetchQuestions(true)
}, [deferredSearch, typeFilter, difficultyFilter])
}, [deferredSearch, typeFilter, difficultyFilter, fetchQuestions])
// Recursively calculate total score
const assignedTotal = useMemo(() => {

View File

@@ -149,8 +149,8 @@ export const omitScheduledAtFromDescription = (description: string | null): stri
try {
const meta = JSON.parse(description)
if (typeof meta === "object" && meta !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { scheduledAt, ...rest } = meta as any
const rest = { ...(meta as Record<string, unknown>) }
delete rest.scheduledAt
return JSON.stringify(rest)
}
return description

View File

@@ -1,64 +1,64 @@
"use server"
import { revalidatePath } from "next/cache"
import { headers } from "next/headers"
import { createId } from "@paralleldrive/cuid2"
import { and, count, eq } from "drizzle-orm"
import { and, count, eq, inArray } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
classSubjectTeachers,
exams,
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
type CurrentUser = { id: string; role: "admin" | "teacher" | "student" }
type TeacherRole = "admin" | "teacher"
type StudentRole = "student"
async function getCurrentUser() {
const ref = (await headers()).get("referer") || ""
const roleHint: CurrentUser["role"] = ref.includes("/admin/")
? "admin"
: ref.includes("/student/")
? "student"
: ref.includes("/teacher/")
? "teacher"
: "teacher"
const byRole = await db.query.users.findFirst({
where: eq(users.role, roleHint),
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (byRole) return { id: byRole.id, role: roleHint }
const anyUser = await db.query.users.findFirst({
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (anyUser) return { id: anyUser.id, role: roleHint }
return { id: "user_teacher_math", role: roleHint }
const getSessionUserId = async (): Promise<string | null> => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
return userId.length > 0 ? userId : null
}
async function ensureTeacher() {
const user = await getCurrentUser()
if (!user || (user.role !== "teacher" && user.role !== "admin")) throw new Error("Unauthorized")
return user
async function ensureTeacher(): Promise<{ id: string; role: TeacherRole }> {
const userId = await getSessionUserId()
if (!userId) throw new Error("Unauthorized")
const [row] = await db
.select({ id: users.id, role: roles.name })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
.limit(1)
if (!row) throw new Error("Unauthorized")
return { id: row.id, role: row.role as TeacherRole }
}
async function ensureStudent() {
const user = await getCurrentUser()
if (!user || user.role !== "student") throw new Error("Unauthorized")
return user
async function ensureStudent(): Promise<{ id: string; role: StudentRole }> {
const userId = await getSessionUserId()
if (!userId) throw new Error("Unauthorized")
const [row] = await db
.select({ id: users.id })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), eq(roles.name, "student")))
.limit(1)
if (!row) throw new Error("Unauthorized")
return { id: row.id, role: "student" }
}
const parseStudentIds = (raw: string): string[] => {
@@ -108,12 +108,12 @@ export async function createHomeworkAssignmentAction(
const input = parsed.data
const publish = input.publish ?? true
const [ownedClass] = await db
.select({ id: classes.id })
const [classRow] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(user.role === "admin" ? eq(classes.id, input.classId) : and(eq(classes.id, input.classId), eq(classes.teacherId, user.id)))
.where(eq(classes.id, input.classId))
.limit(1)
if (!ownedClass) return { success: false, message: "Class not found" }
if (!classRow) return { success: false, message: "Class not found" }
const exam = await db.query.exams.findFirst({
where: eq(exams.id, input.sourceExamId),
@@ -126,23 +126,43 @@ export async function createHomeworkAssignmentAction(
if (!exam) return { success: false, message: "Exam not found" }
if (user.role !== "admin" && classRow.teacherId !== user.id) {
const assignedSubjectRows = await db
.select({ subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, user.id)))
if (assignedSubjectRows.length === 0) {
return { success: false, message: "Not assigned to this class" }
}
const assignedSubjectIds = new Set(assignedSubjectRows.map((r) => r.subjectId))
if (!exam.subjectId) {
return { success: false, message: "Exam subject not set" }
}
if (!assignedSubjectIds.has(exam.subjectId)) {
return { success: false, message: "Not assigned to this subject" }
}
}
const assignmentId = createId()
const availableAt = input.availableAt ? new Date(input.availableAt) : null
const dueAt = input.dueAt ? new Date(input.dueAt) : null
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
const classScope =
user.role === "admin"
? eq(classes.id, input.classId)
: classRow.teacherId === user.id
? eq(classes.teacherId, user.id)
: eq(classes.id, input.classId)
const classStudentIds = (
await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(
and(
eq(classEnrollments.classId, input.classId),
eq(classEnrollments.status, "active"),
user.role === "admin" ? eq(classes.id, input.classId) : eq(classes.teacherId, user.id)
)
and(eq(classEnrollments.classId, input.classId), eq(classEnrollments.status, "active"), classScope)
)
).map((r) => r.studentId)

View File

@@ -12,7 +12,6 @@ import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
import type { StudentHomeworkTakeData } from "../types"

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/sha
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { CheckCircle2, FileText, ChevronLeft } from "lucide-react"
import { FileText, ChevronLeft } from "lucide-react"
import Link from "next/link"
import type { StudentHomeworkTakeData } from "../types"
@@ -57,7 +57,6 @@ type HomeworkReviewViewProps = {
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
const submissionStatus = initialData.submission?.status ?? "not_started"
const isGraded = submissionStatus === "graded"
const isSubmitted = submissionStatus === "submitted"
const answersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()

View File

@@ -3,6 +3,7 @@ import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import {
classEnrollments,
@@ -11,7 +12,9 @@ import {
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import type {
@@ -550,17 +553,20 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
})
export const getDemoStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => {
const student = await db.query.users.findFirst({
where: eq(users.role, "student"),
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (student) return { id: student.id, name: student.name || "Student" }
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
const anyUser = await db.query.users.findFirst({
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (!anyUser) return null
return { id: anyUser.id, name: anyUser.name || "User" }
const [student] = await db
.select({ id: users.id, name: users.name })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), eq(roles.name, "student")))
.limit(1)
if (!student) return null
return { id: student.id, name: student.name || "Student" }
})
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
@@ -592,19 +598,23 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
orderBy: [desc(homeworkSubmissions.createdAt)],
orderBy: [desc(homeworkSubmissions.updatedAt)],
})
const attemptsByAssignmentId = new Map<string, number>()
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
const latestSubmittedByAssignmentId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
if (s.status === "submitted" || s.status === "graded") {
if (!latestSubmittedByAssignmentId.has(s.assignmentId)) latestSubmittedByAssignmentId.set(s.assignmentId, s)
}
}
return assignments.map((a) => {
const latest = latestByAssignmentId.get(a.id) ?? null
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
const item: StudentHomeworkAssignmentListItem = {

View File

@@ -5,7 +5,6 @@ import {
LayoutDashboard,
Settings,
Users,
FileText,
MessageSquare,
Shield,
CreditCard,
@@ -156,11 +155,6 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
icon: Calendar,
href: "/student/schedule",
},
{
title: "Resources",
icon: FileText,
href: "/student/resources",
},
],
parent: [
{

View File

@@ -1,29 +1,51 @@
"use server";
import { db } from "@/shared/db";
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks, roles, users, usersToRoles } from "@/shared/db/schema";
import { CreateQuestionSchema } from "./schema";
import type { CreateQuestionInput } from "./schema";
import { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import { createId } from "@paralleldrive/cuid2";
import { and, eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { getQuestions, type GetQuestionsParams } from "./data-access";
import type { KnowledgePointOption } from "./types";
import { auth } from "@/auth";
async function getCurrentUser() {
return {
id: "user_teacher_math",
role: "teacher",
};
}
const getSessionUserId = async (): Promise<string | null> => {
const session = await auth();
const userId = String(session?.user?.id ?? "").trim();
return userId.length > 0 ? userId : null;
};
async function ensureTeacher() {
const user = await getCurrentUser();
if (!user || (user.role !== "teacher" && user.role !== "admin")) {
const userId = await getSessionUserId();
if (!userId) {
const [fallback] = await db
.select({ id: users.id, role: roles.name })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(roles.name, ["teacher", "admin"]))
.orderBy(asc(users.createdAt))
.limit(1);
if (!fallback) {
throw new Error("Unauthorized: Only teachers can perform this action.");
}
return { id: fallback.id, role: fallback.role as "teacher" | "admin" };
}
const [row] = await db
.select({ id: users.id, role: roles.name })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
.limit(1);
if (!row) {
throw new Error("Unauthorized: Only teachers can perform this action.");
}
return user;
return { id: row.id, role: row.role as "teacher" | "admin" };
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
@@ -244,3 +266,40 @@ export async function getQuestionsAction(params: GetQuestionsParams) {
await ensureTeacher();
return await getQuestions(params);
}
export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOption[]> {
await ensureTeacher();
const rows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
chapterId: chapters.id,
chapterTitle: chapters.title,
textbookId: textbooks.id,
textbookTitle: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
})
.from(knowledgePoints)
.leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.leftJoin(textbooks, eq(textbooks.id, chapters.textbookId))
.orderBy(
asc(textbooks.title),
asc(chapters.order),
asc(chapters.title),
asc(knowledgePoints.order),
asc(knowledgePoints.name)
);
return rows.map((row) => ({
id: row.id,
name: row.name,
chapterId: row.chapterId ?? null,
chapterTitle: row.chapterTitle ?? null,
textbookId: row.textbookId ?? null,
textbookTitle: row.textbookTitle ?? null,
subject: row.subject ?? null,
grade: row.grade ?? null,
}));
}

View File

@@ -27,6 +27,7 @@ import {
FormMessage,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Select,
SelectContent,
@@ -36,9 +37,9 @@ import {
} from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { BaseQuestionSchema } from "../schema"
import { createNestedQuestion, updateQuestionAction } from "../actions"
import { createNestedQuestion, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
import { toast } from "sonner"
import { Question } from "../types"
import { KnowledgePointOption, Question } from "../types"
const QuestionFormSchema = BaseQuestionSchema.extend({
difficulty: z.number().min(1).max(5),
@@ -111,6 +112,10 @@ export function CreateQuestionDialog({
const router = useRouter()
const [isPending, setIsPending] = useState(false)
const isEdit = !!initialData
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
const [isLoadingKnowledgePoints, setIsLoadingKnowledgePoints] = useState(false)
const form = useForm<QuestionFormValues>({
resolver: zodResolver(QuestionFormSchema),
@@ -151,7 +156,60 @@ export function CreateQuestionDialog({
}
}, [initialData, form, open, defaultContent, defaultType])
useEffect(() => {
if (!open) return
setIsLoadingKnowledgePoints(true)
getKnowledgePointOptionsAction()
.then((rows) => {
setKnowledgePointOptions(rows)
})
.catch(() => {
toast.error("Failed to load knowledge points")
})
.finally(() => {
setIsLoadingKnowledgePoints(false)
})
}, [open])
useEffect(() => {
if (!open) return
if (initialData) {
const nextIds = initialData.knowledgePoints.map((kp) => kp.id)
setSelectedKnowledgePointIds((prev) => {
if (prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx])) {
return prev
}
return nextIds
})
return
}
setSelectedKnowledgePointIds((prev) => {
if (
prev.length === defaultKnowledgePointIds.length &&
prev.every((id, idx) => id === defaultKnowledgePointIds[idx])
) {
return prev
}
return defaultKnowledgePointIds
})
}, [open, initialData, defaultKnowledgePointIds])
const questionType = form.watch("type")
const filteredKnowledgePoints = knowledgePointOptions.filter((kp) => {
const query = knowledgePointQuery.trim().toLowerCase()
if (!query) return true
const fullLabel = [
kp.textbookTitle,
kp.chapterTitle,
kp.name,
kp.subject,
kp.grade,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
return fullLabel.includes(query)
})
const buildContent = (data: QuestionFormValues) => {
const text = data.content.trim()
@@ -194,7 +252,7 @@ export function CreateQuestionDialog({
type: data.type,
difficulty: data.difficulty,
content: buildContent(data),
knowledgePointIds: isEdit ? [] : defaultKnowledgePointIds,
knowledgePointIds: selectedKnowledgePointIds,
}
const fd = new FormData()
fd.set("json", JSON.stringify(payload))
@@ -306,6 +364,58 @@ export function CreateQuestionDialog({
)}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>Knowledge Points</FormLabel>
<span className="text-xs text-muted-foreground">
{selectedKnowledgePointIds.length > 0 ? `${selectedKnowledgePointIds.length} selected` : "Optional"}
</span>
</div>
<Input
placeholder="Search knowledge points..."
value={knowledgePointQuery}
onChange={(e) => setKnowledgePointQuery(e.target.value)}
/>
<div className="rounded-md border">
<ScrollArea className="h-48">
{isLoadingKnowledgePoints ? (
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
) : filteredKnowledgePoints.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No knowledge points found.</div>
) : (
<div className="space-y-1 p-2">
{filteredKnowledgePoints.map((kp) => {
const labelParts = [
kp.textbookTitle,
kp.chapterTitle,
kp.name,
].filter(Boolean)
const label = labelParts.join(" · ")
return (
<label key={kp.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<Checkbox
checked={selectedKnowledgePointIds.includes(kp.id)}
onCheckedChange={(checked) => {
const isChecked = checked === true
setSelectedKnowledgePointIds((prev) => {
if (isChecked) {
if (prev.includes(kp.id)) return prev
return [...prev, kp.id]
}
return prev.filter((id) => id !== kp.id)
})
}}
/>
<span className="text-sm">{label}</span>
</label>
)
})}
</div>
)}
</ScrollArea>
</div>
</div>
{(questionType === "single_choice" || questionType === "multiple_choice") && (
<div className="space-y-4">
<div className="flex items-center justify-between">

View File

@@ -1,5 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Search, X } from "lucide-react"
@@ -12,11 +13,25 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button"
import { getKnowledgePointOptionsAction } from "../actions"
import type { KnowledgePointOption } from "../types"
export function QuestionFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
const [knowledgePointId, setKnowledgePointId] = useQueryState("kp", parseAsString.withDefault("all"))
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
useEffect(() => {
getKnowledgePointOptionsAction()
.then((rows) => {
setKnowledgePointOptions(rows)
})
.catch(() => {
setKnowledgePointOptions([])
})
}, [])
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -56,14 +71,32 @@ export function QuestionFilters() {
<SelectItem value="5">Hard (5)</SelectItem>
</SelectContent>
</Select>
<Select value={knowledgePointId} onValueChange={(val) => setKnowledgePointId(val === "all" ? null : val)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Knowledge Point" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Knowledge Points</SelectItem>
{knowledgePointOptions.map((kp) => {
const labelParts = [kp.textbookTitle, kp.chapterTitle, kp.name].filter(Boolean)
const label = labelParts.join(" · ")
return (
<SelectItem key={kp.id} value={kp.id}>
{label || kp.name}
</SelectItem>
)
})}
</SelectContent>
</Select>
{(search || type !== "all" || difficulty !== "all") && (
{(search || type !== "all" || difficulty !== "all" || knowledgePointId !== "all") && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setType(null)
setDifficulty(null)
setKnowledgePointId(null)
}}
className="h-8 px-2 lg:px-3"
>

View File

@@ -21,3 +21,14 @@ export interface Question {
}[]
childrenCount?: number
}
export type KnowledgePointOption = {
id: string
name: string
chapterId: string | null
chapterTitle: string | null
textbookId: string | null
textbookTitle: string | null
subject: string | null
grade: string | null
}

View File

@@ -4,7 +4,7 @@ import { cache } from "react"
import { asc, eq, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { academicYears, departments, grades, schools, users } from "@/shared/db/schema"
import { academicYears, departments, grades, roles, schools, users, usersToRoles } from "@/shared/db/schema"
import type { AcademicYearListItem, DepartmentListItem, GradeListItem, SchoolListItem, StaffOption } from "./types"
const toIso = (d: Date) => d.toISOString()
@@ -114,7 +114,10 @@ export const getStaffOptions = cache(async (): Promise<StaffOption[]> => {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(inArray(users.role, ["teacher", "admin"]))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(roles.name, ["teacher", "admin"]))
.groupBy(users.id, users.name, users.email)
.orderBy(asc(users.name), asc(users.email))
return rows.map((r) => ({

View File

@@ -10,7 +10,6 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form"
import { UserProfile } from "@/modules/users/data-access"

View File

@@ -224,30 +224,11 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
}
const activeParent = findParent(chapters, active.id as string)
const overParent = findParent(chapters, over.id as string)
// If parents don't match (and neither is root), we can't reorder easily in this simplified version
// But actually, we need to check if they are in the same list.
// If both are root items (activeParent is null), they are siblings.
const getSiblings = (parentId: string | null) => {
if (!parentId) return chapters
const parent = chapters.find(c => c.id === parentId) // This only finds root parents, we need recursive find
const findNode = (nodes: Chapter[], id: string): Chapter | null => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findNode(node.children, id)
if (found) return found
}
}
return null
}
return findNode(chapters, parentId)?.children || []
}
// Simplified logic: We trust dnd-kit's SortableContext to only allow valid drops if we restricted it?
// No, dnd-kit allows dropping anywhere by default unless restricted.
@@ -271,7 +252,6 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
// Check if over is in the same list
if (activeList.some(c => c.id === over.id)) {
const oldIndex = activeList.findIndex((item) => item.id === active.id)
const newIndex = activeList.findIndex((item) => item.id === over.id)
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)

View File

@@ -24,29 +24,35 @@ interface TextbookCardProps {
}
const subjectColorMap: Record<string, string> = {
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
Mathematics: "bg-blue-50 text-blue-700 border-blue-200/70 dark:bg-blue-950/50 dark:text-blue-200 dark:border-blue-900/60",
Physics: "bg-purple-50 text-purple-700 border-purple-200/70 dark:bg-purple-950/50 dark:text-purple-200 dark:border-purple-900/60",
Chemistry: "bg-teal-50 text-teal-700 border-teal-200/70 dark:bg-teal-950/50 dark:text-teal-200 dark:border-teal-900/60",
English: "bg-orange-50 text-orange-700 border-orange-200/70 dark:bg-orange-950/50 dark:text-orange-200 dark:border-orange-900/60",
History: "bg-amber-50 text-amber-700 border-amber-200/70 dark:bg-amber-950/50 dark:text-amber-200 dark:border-amber-900/60",
Biology: "bg-emerald-50 text-emerald-700 border-emerald-200/70 dark:bg-emerald-950/50 dark:text-emerald-200 dark:border-emerald-900/60",
Geography: "bg-sky-50 text-sky-700 border-sky-200/70 dark:bg-sky-950/50 dark:text-sky-200 dark:border-sky-900/60",
};
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
const base = hrefBase || "/teacher/textbooks";
const colorClass = subjectColorMap[textbook.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800";
const colorClass = subjectColorMap[textbook.subject] || "bg-zinc-50 text-zinc-700 border-zinc-200/70 dark:bg-zinc-950/50 dark:text-zinc-200 dark:border-zinc-800/70";
return (
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
<Card className="group flex flex-col h-full overflow-hidden border-border/60 transition-all duration-300 hover:shadow-md hover:border-primary/50">
<Link href={`${base}/${textbook.id}`} className="flex-1">
<div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
<div className={cn("relative h-32 w-full overflow-hidden p-5", colorClass)}>
<div className="relative z-10 flex h-full flex-col justify-between">
<Badge variant="secondary" className="w-fit bg-background/50 backdrop-blur-sm border-transparent shadow-none">
<Badge variant="secondary" className="w-fit bg-background/80 border border-border/60 shadow-sm">
{textbook.subject}
</Badge>
<Book className="h-8 w-8 opacity-50" />
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-background text-foreground shadow-sm ring-1 ring-border/60">
<Book className="h-5 w-5" />
</div>
<div className="text-xs font-medium text-foreground/70">
{textbook.grade || "Grade N/A"}
</div>
</div>
</div>
</div>
@@ -74,9 +80,11 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
</CardContent>
</Link>
<CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
<CardFooter className="p-4 pt-2 mt-auto border-t border-border/60 bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<BookOpen className="h-3.5 w-3.5" />
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-background/80 ring-1 ring-border/60">
<BookOpen className="h-3.5 w-3.5" />
</div>
<span>{textbook._count?.chapters || 0} Chapters</span>
</div>

View File

@@ -1,7 +1,7 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { Search, Filter, X } from "lucide-react"
import { Search, X } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"

View File

@@ -5,13 +5,8 @@ import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { useQueryState, parseAsString } from "nuqs"
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, ChevronDown, ChevronUp } from "lucide-react"
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, Share2 } from "lucide-react"
import { toast } from "sonner"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import type { Chapter, KnowledgePoint } from "../types"
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
@@ -243,6 +238,96 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
return knowledgePoints.filter(kp => kp.chapterId === selectedId)
}, [knowledgePoints, selectedId])
const graphLayout = useMemo(() => {
if (currentChapterKPs.length === 0) {
return { nodes: [], edges: [], width: 0, height: 0 }
}
const byId = new Map<string, KnowledgePoint>()
for (const kp of currentChapterKPs) byId.set(kp.id, kp)
const children = new Map<string, string[]>()
const roots: string[] = []
for (const kp of currentChapterKPs) {
if (kp.parentId && byId.has(kp.parentId)) {
const arr = children.get(kp.parentId) ?? []
arr.push(kp.id)
children.set(kp.parentId, arr)
} else {
roots.push(kp.id)
}
}
const levelMap = new Map<string, number>()
const levels: string[][] = []
const queue = [...roots].map((id) => ({ id, level: 0 }))
if (queue.length === 0) {
for (const kp of currentChapterKPs) queue.push({ id: kp.id, level: 0 })
}
while (queue.length > 0) {
const item = queue.shift()
if (!item) continue
if (levelMap.has(item.id)) continue
levelMap.set(item.id, item.level)
if (!levels[item.level]) levels[item.level] = []
levels[item.level].push(item.id)
const kids = children.get(item.id) ?? []
for (const kid of kids) {
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
}
}
for (const kp of currentChapterKPs) {
if (!levelMap.has(kp.id)) {
const level = levels.length
levelMap.set(kp.id, level)
if (!levels[level]) levels[level] = []
levels[level].push(kp.id)
}
}
const nodeWidth = 160
const nodeHeight = 52
const gapX = 40
const gapY = 90
const maxCount = Math.max(...levels.map((l) => l.length), 1)
const width = maxCount * (nodeWidth + gapX) + gapX
const height = levels.length * (nodeHeight + gapY) + gapY
const positions = new Map<string, { x: number; y: number }>()
levels.forEach((ids, level) => {
ids.forEach((id, index) => {
const x = gapX + index * (nodeWidth + gapX)
const y = gapY + level * (nodeHeight + gapY)
positions.set(id, { x, y })
})
})
const nodes = currentChapterKPs.map((kp) => {
const pos = positions.get(kp.id) ?? { x: gapX, y: gapY }
return { ...kp, x: pos.x, y: pos.y }
})
const edges = currentChapterKPs
.filter((kp) => kp.parentId && positions.has(kp.parentId))
.map((kp) => {
const parentPos = positions.get(kp.parentId as string)!
const childPos = positions.get(kp.id)!
return {
id: `${kp.parentId}-${kp.id}`,
x1: parentPos.x + nodeWidth / 2,
y1: parentPos.y + nodeHeight,
x2: childPos.x + nodeWidth / 2,
y2: childPos.y,
}
})
return { nodes, edges, width, height }
}, [currentChapterKPs])
// Pre-process content to mark knowledge points
const processedContent = useMemo(() => {
if (!selected?.content) return ""
@@ -293,7 +378,7 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" />
@@ -305,6 +390,10 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
<Share2 className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
@@ -399,6 +488,62 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
</ScrollArea>
)}
</TabsContent>
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : currentChapterKPs.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<ScrollArea className="flex-1 h-full px-2">
<div
className="relative"
style={{ width: graphLayout.width, height: graphLayout.height }}
>
<svg
width={graphLayout.width}
height={graphLayout.height}
className="absolute inset-0"
>
{graphLayout.edges.map((edge) => (
<line
key={edge.id}
x1={edge.x1}
y1={edge.y1}
x2={edge.x2}
y2={edge.y2}
stroke="hsl(var(--border))"
strokeWidth={2}
/>
))}
</svg>
{graphLayout.nodes.map((node) => (
<button
key={node.id}
type="button"
className={cn(
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
highlightedKpId === node.id && "border-primary bg-primary/5"
)}
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
onClick={() => setHighlightedKpId(node.id)}
>
<div className="font-medium truncate">{node.name}</div>
{node.description && (
<div className="text-[10px] text-muted-foreground truncate">
{node.description}
</div>
)}
</button>
))}
</div>
</ScrollArea>
)}
</TabsContent>
</Tabs>
</div>

View File

@@ -9,7 +9,6 @@ import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema"
import type {
Chapter,
CreateChapterInput,
CreateKnowledgePointInput,
CreateTextbookInput,
KnowledgePoint,
Textbook,

View File

@@ -4,7 +4,7 @@ import { cache } from "react"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import { roles, users, usersToRoles } from "@/shared/db/schema"
export type UserProfile = {
id: string
@@ -21,6 +21,25 @@ export type UserProfile = {
updatedAt: Date
}
const rolePriority = ["admin", "teacher", "parent", "student"] as const
const normalizeRoleName = (value: string) => {
const role = value.trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "teacher" || role === "parent" || role === "student") return role
return ""
}
const resolvePrimaryRole = (roleNames: string[]) => {
const mapped = roleNames.map(normalizeRoleName).filter(Boolean)
if (mapped.length) {
for (const role of rolePriority) {
if (mapped.includes(role)) return role
}
}
return "student"
}
export const getUserProfile = cache(async (userId: string): Promise<UserProfile | null> => {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
@@ -28,12 +47,19 @@ export const getUserProfile = cache(async (userId: string): Promise<UserProfile
if (!user) return null
const roleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId))
const role = resolvePrimaryRole(roleRows.map((r) => r.name))
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
role: user.role,
role,
phone: user.phone,
address: user.address,
gender: user.gender,

View File

@@ -25,7 +25,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
export function OnboardingGate() {
const router = useRouter()
const { status, data: session } = useSession()
const { status, data: session, update } = useSession()
const [required, setRequired] = useState(false)
const [currentRole, setCurrentRole] = useState<Role>("student")
const [open, setOpen] = useState(false)
@@ -142,6 +142,7 @@ export function OnboardingGate() {
throw new Error(msg || "提交失败")
}
await update?.()
toast.success("配置完成")
setRequired(false)
setOpen(false)

View File

@@ -18,19 +18,12 @@ import {
ListOrdered,
Quote,
Undo,
Redo,
MoreHorizontal
Redo
} from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { Separator } from "@/shared/components/ui/separator"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
// Since we don't have Toggle component yet, let's create a local one or use Button
// We will use Button for simplicity and to avoid dependency issues if Radix Toggle isn't installed

View File

@@ -25,10 +25,6 @@ export const users = mysqlTable("users", {
email: varchar("email", { length: 255 }).notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: varchar("image", { length: 255 }),
// Custom Role Field for RBAC (Default Role)
role: varchar("role", { length: 50 }).default("student"),
// Credentials Auth (Optional)
password: varchar("password", { length: 255 }),
@@ -338,23 +334,27 @@ export const classes = mysqlTable("classes", {
}).onDelete("set null"),
}));
export const classSubjectEnum = mysqlEnum("subject", ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"]);
export const classSubjectTeachers = mysqlTable("class_subject_teachers", {
classId: varchar("class_id", { length: 128 }).notNull(),
subject: classSubjectEnum.notNull(),
subjectId: varchar("subject_id", { length: 128 }).notNull(),
teacherId: varchar("teacher_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.classId, table.subject] }),
pk: primaryKey({ columns: [table.classId, table.subjectId] }),
classIdx: index("class_subject_teachers_class_idx").on(table.classId),
teacherIdx: index("class_subject_teachers_teacher_idx").on(table.teacherId),
subjectIdIdx: index("class_subject_teachers_subject_id_idx").on(table.subjectId),
classFk: foreignKey({
columns: [table.classId],
foreignColumns: [classes.id],
name: "cst_c_fk",
}).onDelete("cascade"),
subjectFk: foreignKey({
columns: [table.subjectId],
foreignColumns: [subjects.id],
name: "cst_s_fk",
}).onDelete("cascade"),
}));
export const classEnrollments = mysqlTable("class_enrollments", {