完整性更新
Some checks failed
CI / build-and-test (push) Failing after 3m50s
CI / deploy (push) Has been skipped

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

View File

@@ -1,4 +1,3 @@
import Link from "next/link"
import { GraduationCap } from "lucide-react"
interface AuthLayoutProps {

View File

@@ -2,6 +2,8 @@
import * as React from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { signIn } from "next-auth/react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
@@ -12,14 +14,32 @@ type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
export function LoginForm({ className, ...props }: LoginFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const router = useRouter()
const searchParams = useSearchParams()
async function onSubmit(event: React.SyntheticEvent) {
event.preventDefault()
setIsLoading(true)
setTimeout(() => {
setIsLoading(false)
}, 3000)
const form = event.currentTarget as HTMLFormElement
const formData = new FormData(form)
const email = String(formData.get("email") ?? "")
const password = String(formData.get("password") ?? "")
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"
const result = await signIn("credentials", {
redirect: false,
email,
password,
callbackUrl,
})
setIsLoading(false)
if (!result?.error) {
router.push(result?.url ?? callbackUrl)
router.refresh()
}
}
return (
@@ -38,6 +58,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
@@ -58,6 +79,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
</div>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
disabled={isLoading}

View File

@@ -0,0 +1,434 @@
"use server";
import { revalidatePath } from "next/cache"
import { auth } from "@/auth"
import type { ActionState } from "@/shared/types/action-state"
import {
createAdminClass,
createClassScheduleItem,
createTeacherClass,
deleteAdminClass,
deleteClassScheduleItem,
deleteTeacherClass,
enrollStudentByEmail,
enrollStudentByInvitationCode,
ensureClassInvitationCode,
regenerateClassInvitationCode,
setClassSubjectTeachers,
setStudentEnrollmentStatus,
updateAdminClass,
updateClassScheduleItem,
updateTeacherClass,
} from "./data-access"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "./types"
const isClassSubject = (v: string): v is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(v as ClassSubject)
export async function createTeacherClassAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof grade !== "string" || grade.trim().length === 0) {
return { success: false, message: "Grade is required" }
}
try {
const id = await createTeacherClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
name,
grade,
gradeId: typeof gradeId === "string" ? gradeId : null,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
})
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
}
}
export async function updateTeacherClassAction(
classId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await updateTeacherClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
})
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
}
}
export async function deleteTeacherClassAction(classId: string): Promise<ActionState> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await deleteTeacherClass(classId)
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
}
}
export async function enrollStudentByEmailAction(
classId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const email = formData.get("email")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Please select a class" }
}
if (typeof email !== "string" || email.trim().length === 0) {
return { success: false, message: "Student email is required" }
}
try {
await enrollStudentByEmail(classId, email)
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my")
return { success: true, message: "Student added successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to add student" }
}
}
export async function joinClassByInvitationCodeAction(
prevState: ActionState<{ classId: string }> | null,
formData: FormData
): Promise<ActionState<{ classId: string }>> {
const code = formData.get("code")
if (typeof code !== "string" || code.trim().length === 0) {
return { success: false, message: "Invitation code is required" }
}
const session = await auth()
if (!session?.user?.id || String(session.user.role ?? "") !== "student") {
return { success: false, message: "Unauthorized" }
}
try {
const classId = await enrollStudentByInvitationCode(session.user.id, code)
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")
revalidatePath("/profile")
return { success: true, message: "Joined class successfully", data: { classId } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
}
}
export async function ensureClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const code = await ensureClassInvitationCode(classId)
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
return { success: true, message: "Invitation code ready", data: { code } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" }
}
}
export async function regenerateClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const code = await regenerateClassInvitationCode(classId)
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
return { success: true, message: "Invitation code updated", data: { code } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" }
}
}
export async function setStudentEnrollmentStatusAction(
classId: string,
studentId: string,
status: "active" | "inactive"
): Promise<ActionState> {
if (!classId?.trim() || !studentId?.trim()) {
return { success: false, message: "Missing enrollment info" }
}
try {
await setStudentEnrollmentStatus(classId, studentId, status)
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my")
return { success: true, message: "Student updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update student" }
}
}
export async function createClassScheduleItemAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const classId = formData.get("classId")
const weekday = formData.get("weekday")
const startTime = formData.get("startTime")
const endTime = formData.get("endTime")
const course = formData.get("course")
const location = formData.get("location")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Please select a class" }
}
if (typeof weekday !== "string" || weekday.trim().length === 0) {
return { success: false, message: "Weekday is required" }
}
const weekdayNum = Number(weekday)
if (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7) {
return { success: false, message: "Invalid weekday" }
}
if (typeof course !== "string" || course.trim().length === 0) {
return { success: false, message: "Course is required" }
}
if (typeof startTime !== "string" || typeof endTime !== "string") {
return { success: false, message: "Time is required" }
}
try {
const id = await createClassScheduleItem({
classId,
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7,
startTime,
endTime,
course,
location: typeof location === "string" ? location : null,
})
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" }
}
}
export async function updateClassScheduleItemAction(
scheduleId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const classId = formData.get("classId")
const weekday = formData.get("weekday")
const startTime = formData.get("startTime")
const endTime = formData.get("endTime")
const course = formData.get("course")
const location = formData.get("location")
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
return { success: false, message: "Missing schedule id" }
}
const weekdayNum = typeof weekday === "string" && weekday.trim().length > 0 ? Number(weekday) : undefined
if (weekdayNum !== undefined && (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7)) {
return { success: false, message: "Invalid weekday" }
}
try {
await updateClassScheduleItem(scheduleId, {
classId: typeof classId === "string" ? classId : undefined,
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined,
startTime: typeof startTime === "string" ? startTime : undefined,
endTime: typeof endTime === "string" ? endTime : undefined,
course: typeof course === "string" ? course : undefined,
location: typeof location === "string" ? location : undefined,
})
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" }
}
}
export async function deleteClassScheduleItemAction(scheduleId: string): Promise<ActionState> {
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
return { success: false, message: "Missing schedule id" }
}
try {
await deleteClassScheduleItem(scheduleId)
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" }
}
}
export async function createAdminClassAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof grade !== "string" || grade.trim().length === 0) {
return { success: false, message: "Grade is required" }
}
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
return { success: false, message: "Teacher is required" }
}
try {
const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
name,
grade,
gradeId: typeof gradeId === "string" ? gradeId : null,
teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
})
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
}
}
export async function updateAdminClassAction(
classId: string,
prevState: ActionState | undefined,
formData: FormData
): Promise<ActionState> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const subjectTeachers = formData.get("subjectTeachers")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await updateAdminClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
teacherId: typeof teacherId === "string" ? teacherId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
})
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
const parsed = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
await setClassSubjectTeachers({
classId,
assignments: parsed.flatMap((item) => {
if (!item || typeof item !== "object") return []
const subject = (item as { subject?: unknown }).subject
const teacherId = (item as { teacherId?: unknown }).teacherId
if (typeof subject !== "string" || !isClassSubject(subject)) return []
if (teacherId === null || typeof teacherId === "undefined") {
return [{ subject, teacherId: null }]
}
if (typeof teacherId !== "string") return []
const trimmed = teacherId.trim()
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
}),
})
}
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
}
}
export async function deleteAdminClassAction(classId: string): Promise<ActionState> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await deleteAdminClass(classId)
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
}
}

View File

@@ -0,0 +1,434 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
import { DEFAULT_CLASS_SUBJECTS } from "../types"
import { createAdminClassAction, deleteAdminClassAction, updateAdminClassAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
export function AdminClassesClient({
classes,
teachers,
}: {
classes: AdminClassListItem[]
teachers: TeacherOption[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
const [editTeacherId, setEditTeacherId] = useState("")
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
useEffect(() => {
if (!createOpen) return
setCreateTeacherId(defaultTeacherId)
}, [createOpen, defaultTeacherId])
useEffect(() => {
if (!editItem) return
setEditTeacherId(editItem.teacher.id)
setEditSubjectTeachers(
DEFAULT_CLASS_SUBJECTS.map((s) => ({
subject: s,
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
}))
)
}, [editItem])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createAdminClassAction(undefined, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create class")
}
} catch {
toast.error("Failed to create class")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
const res = await updateAdminClassAction(editItem.id, undefined, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteAdminClassAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete class")
}
} catch {
toast.error("Failed to delete class")
} finally {
setIsWorking(false)
}
}
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
}
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
const pairs = list
.filter((x) => x.teacher)
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
.filter((x) => x.length > 0)
return pairs.length > 0 ? pairs.join("") : "-"
}
return (
<>
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
<Plus className="mr-2 h-4 w-4" />
New class
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All classes</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{classes.length}
</Badge>
</CardHeader>
<CardContent>
{classes.length === 0 ? (
<EmptyState
title="No classes"
description="Create classes to manage students and schedules."
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>School</TableHead>
<TableHead>Name</TableHead>
<TableHead>Grade</TableHead>
<TableHead>Homeroom</TableHead>
<TableHead>Room</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{classes.map((c) => (
<TableRow key={c.id}>
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(c)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(c)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>New class</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-school-name" className="text-right">
School
</Label>
<Input id="create-school-name" name="schoolName" className="col-span-3" placeholder="e.g. First Primary School" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-name" className="text-right">
Name
</Label>
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Grade 10 · Class 3" autoFocus />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade" className="text-right">
Grade
</Label>
<Input id="create-grade" name="grade" className="col-span-3" placeholder="e.g. Grade 10" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-homeroom" className="text-right">
Homeroom
</Label>
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-room" className="text-right">
Room
</Label>
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Teacher</Label>
<div className="col-span-3">
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={createTeacherId} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(open) => {
if (isWorking) return
if (!open) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-school-name" className="text-right">
School
</Label>
<Input
id="edit-school-name"
name="schoolName"
className="col-span-3"
defaultValue={editItem.schoolName ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-name" className="text-right">
Name
</Label>
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade" className="text-right">
Grade
</Label>
<Input id="edit-grade" name="grade" className="col-span-3" defaultValue={editItem.grade} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-homeroom" className="text-right">
Homeroom
</Label>
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-room" className="text-right">
Room
</Label>
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={editTeacherId} />
</div>
</div>
<div className="space-y-3 rounded-md border p-4">
<div className="text-sm font-medium"></div>
<div className="grid gap-3">
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
return (
<div key={subject} className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{subject}</Label>
<div className="col-span-3">
<Select
value={selected ?? ""}
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
disabled={teachers.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
})}
</div>
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || !editTeacherId}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete class</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this class"}.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { X } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { TeacherClass } from "../types"
export function InsightsFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Select a class</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{classId !== "all" && (
<Button variant="ghost" onClick={() => setClassId(null)} className="h-8 px-2 lg:px-3">
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,533 @@
"use client"
import Link from "next/link"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Calendar, Copy, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Users } from "lucide-react"
import { toast } from "sonner"
import { parseAsString, useQueryState } from "nuqs"
import { Card, CardContent, 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"
import { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
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 type { TeacherClass } from "../types"
import {
createTeacherClassAction,
deleteTeacherClassAction,
ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction,
updateTeacherClassAction,
} from "../actions"
export function MyClassesGrid({ classes }: { classes: TeacherClass[] }) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
const gradeOptions = useMemo(() => {
const set = new Set<string>()
for (const c of classes) set.add(c.grade)
return Array.from(set).sort((a, b) => a.localeCompare(b))
}, [classes])
const filteredClasses = useMemo(() => {
const needle = q.trim().toLowerCase()
return classes.filter((c) => {
const gradeOk = grade === "all" ? true : c.grade === grade
const qOk = needle.length === 0 ? true : c.name.toLowerCase().includes(needle)
return gradeOk && qOk
})
}, [classes, grade, q])
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createTeacherClassAction(null, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create class")
}
} catch {
toast.error("Failed to create class")
} finally {
setIsWorking(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm">
<Input
placeholder="Search classes..."
value={q}
onChange={(e) => setQ(e.target.value || null)}
/>
</div>
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Grade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All grades</SelectItem>
{gradeOptions.map((g) => (
<SelectItem key={g} value={g}>
{g}
</SelectItem>
))}
</SelectContent>
</Select>
{(q || grade !== "all") && (
<Button
variant="ghost"
className="h-9"
onClick={() => {
setQ(null)
setGrade(null)
}}
>
Reset
</Button>
)}
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
if (isWorking) return
setCreateOpen(open)
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={isWorking}>
<Plus className="size-4" />
New class
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create class</DialogTitle>
<DialogDescription>Add a new class to start managing students.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-school-name" className="text-right">
School
</Label>
<Input
id="create-school-name"
name="schoolName"
className="col-span-3"
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-name" className="text-right">
Name
</Label>
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 1A" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade" className="text-right">
Grade
</Label>
<Input
id="create-grade"
name="grade"
className="col-span-3"
placeholder="e.g. Grade 7"
defaultValue={defaultGrade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-homeroom" className="text-right">
Homeroom
</Label>
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-room" className="text-right">
Room
</Label>
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{classes.length === 0 ? (
<EmptyState
title="No classes yet"
description="Create your first class to start managing students and schedules."
icon={Users}
action={{ label: "Create class", onClick: () => setCreateOpen(true) }}
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
/>
) : filteredClasses.length === 0 ? (
<EmptyState
title="No classes match your filters"
description="Try clearing filters or adjusting keywords."
icon={Users}
action={{ label: "Clear filters", onClick: () => {
setQ(null)
setGrade(null)
}}}
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
/>
) : (
filteredClasses.map((c) => (
<ClassCard
key={c.id}
c={c}
onWorkingChange={setIsWorking}
isWorking={isWorking}
/>
))
)}
</div>
</div>
)
}
function ClassCard({
c,
isWorking,
onWorkingChange,
}: {
c: TeacherClass
isWorking: boolean
onWorkingChange: (v: boolean) => void
}) {
const router = useRouter()
const [showEdit, setShowEdit] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const handleEnsureCode = async () => {
onWorkingChange(true)
try {
const res = await ensureClassInvitationCodeAction(c.id)
if (res.success) {
toast.success(res.message || "Invitation code ready")
router.refresh()
} else {
toast.error(res.message || "Failed to generate invitation code")
}
} catch {
toast.error("Failed to generate invitation code")
} finally {
onWorkingChange(false)
}
}
const handleRegenerateCode = async () => {
onWorkingChange(true)
try {
const res = await regenerateClassInvitationCodeAction(c.id)
if (res.success) {
toast.success(res.message || "Invitation code updated")
router.refresh()
} else {
toast.error(res.message || "Failed to regenerate invitation code")
}
} catch {
toast.error("Failed to regenerate invitation code")
} finally {
onWorkingChange(false)
}
}
const handleCopyCode = async () => {
const code = c.invitationCode ?? ""
if (!code) return
try {
await navigator.clipboard.writeText(code)
toast.success("Copied invitation code")
} catch {
toast.error("Failed to copy")
}
}
const handleEdit = async (formData: FormData) => {
onWorkingChange(true)
try {
const res = await updateTeacherClassAction(c.id, null, formData)
if (res.success) {
toast.success(res.message)
setShowEdit(false)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
onWorkingChange(false)
}
}
const handleDelete = async () => {
onWorkingChange(true)
try {
const res = await deleteTeacherClassAction(c.id)
if (res.success) {
toast.success(res.message)
setShowDelete(false)
router.refresh()
} else {
toast.error(res.message || "Failed to delete class")
}
} catch {
toast.error("Failed to delete class")
} finally {
onWorkingChange(false)
}
}
return (
<Card className="shadow-none">
<CardHeader className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-base truncate">
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
{c.name}
</Link>
</CardTitle>
<div className="text-muted-foreground text-sm mt-1">
{c.room ? `Room: ${c.room}` : "Room: Not set"}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{c.grade}</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEdit(true)}>
<Pencil className="mr-2 size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDelete(true)}
>
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium tabular-nums">{c.studentCount} students</div>
{c.homeroom ? <Badge variant="outline">{c.homeroom}</Badge> : null}
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase text-muted-foreground">Invitation code</div>
<div className="font-mono tabular-nums text-sm">{c.invitationCode ?? "-"}</div>
</div>
<div className="flex items-center gap-2">
{c.invitationCode ? (
<>
<Button variant="outline" size="sm" className="gap-2" onClick={handleCopyCode} disabled={isWorking}>
<Copy className="size-4" />
Copy
</Button>
<Button variant="outline" size="sm" className="gap-2" onClick={handleRegenerateCode} disabled={isWorking}>
<RefreshCw className="size-4" />
Regenerate
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={handleEnsureCode} disabled={isWorking}>
Generate
</Button>
)}
</div>
</div>
<div className={cn("grid gap-2", "grid-cols-2")}>
<Button asChild variant="outline" className="w-full justify-start gap-2">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
<Users className="size-4" />
Students
</Link>
</Button>
<Button asChild variant="outline" className="w-full justify-start gap-2">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
<Calendar className="size-4" />
Schedule
</Link>
</Button>
</div>
</CardContent>
<Dialog
open={showEdit}
onOpenChange={(open) => {
if (isWorking) return
setShowEdit(open)
}}
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
<DialogDescription>Update basic class information.</DialogDescription>
</DialogHeader>
<form action={handleEdit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-school-name-${c.id}`} className="text-right">
School
</Label>
<Input
id={`edit-school-name-${c.id}`}
name="schoolName"
className="col-span-3"
defaultValue={c.schoolName ?? ""}
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-name-${c.id}`} className="text-right">
Name
</Label>
<Input
id={`edit-name-${c.id}`}
name="name"
className="col-span-3"
defaultValue={c.name}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-grade-${c.id}`} className="text-right">
Grade
</Label>
<Input
id={`edit-grade-${c.id}`}
name="grade"
className="col-span-3"
defaultValue={c.grade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-homeroom-${c.id}`} className="text-right">
Homeroom
</Label>
<Input
id={`edit-homeroom-${c.id}`}
name="homeroom"
className="col-span-3"
defaultValue={c.homeroom ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-room-${c.id}`} className="text-right">
Room
</Label>
<Input
id={`edit-room-${c.id}`}
name="room"
className="col-span-3"
defaultValue={c.room ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={showDelete}
onOpenChange={(open) => {
if (isWorking) return
setShowDelete(open)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete class?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <span className="font-medium text-foreground">{c.name}</span> and remove all
enrollments.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={isWorking}
>
{isWorking ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
}

View File

@@ -0,0 +1,195 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Plus, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import type { TeacherClass } from "../types"
import { createClassScheduleItemAction } from "../actions"
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
const router = useRouter()
const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
const [createClassId, setCreateClassId] = useState(defaultClassId)
const [weekday, setWeekday] = useState<string>("1")
useEffect(() => {
if (!open) return
setCreateClassId(defaultClassId)
setWeekday("1")
}, [open, defaultClassId])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("classId", createClassId)
const res = await createClassScheduleItemAction(null, formData)
if (res.success) {
toast.success(res.message)
setOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create schedule item")
}
} catch {
toast.error("Failed to create schedule item")
} finally {
setIsWorking(false)
}
}
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{classId !== "all" && (
<Button
variant="ghost"
onClick={() => setClassId(null)}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<Dialog
open={open}
onOpenChange={(v) => {
if (isWorking) return
setOpen(v)
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={classes.length === 0}>
<Plus className="size-4" />
Add item
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add schedule item</DialogTitle>
<DialogDescription>Create a class schedule entry.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={createClassId} onValueChange={setCreateClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="weekday" className="text-right">
Weekday
</Label>
<div className="col-span-3">
<Select value={weekday} onValueChange={setWeekday}>
<SelectTrigger>
<SelectValue placeholder="Select weekday" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Mon</SelectItem>
<SelectItem value="2">Tue</SelectItem>
<SelectItem value="3">Wed</SelectItem>
<SelectItem value="4">Thu</SelectItem>
<SelectItem value="5">Fri</SelectItem>
<SelectItem value="6">Sat</SelectItem>
<SelectItem value="7">Sun</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="weekday" value={weekday} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="startTime" className="text-right">
Start
</Label>
<Input id="startTime" name="startTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="endTime" className="text-right">
End
</Label>
<Input id="endTime" name="endTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="course" className="text-right">
Course
</Label>
<Input id="course" name="course" className="col-span-3" placeholder="e.g. Math" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location" className="text-right">
Location
</Label>
<Input id="location" name="location" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !createClassId}>
{isWorking ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,465 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Clock, MapPin, 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
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 type { ClassScheduleItem, TeacherClass } from "../types"
import {
createClassScheduleItemAction,
deleteClassScheduleItemAction,
updateClassScheduleItemAction,
} from "../actions"
const WEEKDAYS: Array<{ key: ClassScheduleItem["weekday"]; label: string }> = [
{ key: 1, label: "Mon" },
{ key: 2, label: "Tue" },
{ key: 3, label: "Wed" },
{ key: 4, label: "Thu" },
{ key: 5, label: "Fri" },
{ key: 6, label: "Sat" },
{ key: 7, label: "Sun" },
]
export function ScheduleView({
schedule,
classes,
}: {
schedule: ClassScheduleItem[]
classes: TeacherClass[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [editItem, setEditItem] = useState<ClassScheduleItem | null>(null)
const [deleteItem, setDeleteItem] = useState<ClassScheduleItem | null>(null)
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
const [createOpen, setCreateOpen] = useState(false)
const [createClassId, setCreateClassId] = useState<string>("")
const [editClassId, setEditClassId] = useState<string>("")
const [editWeekday, setEditWeekday] = useState<string>("1")
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
useEffect(() => {
if (!editItem) return
setEditClassId(editItem.classId)
setEditWeekday(String(editItem.weekday))
}, [editItem])
useEffect(() => {
if (!createOpen) return
setCreateClassId(defaultClassId)
}, [createOpen, defaultClassId])
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
for (const d of WEEKDAYS) byDay.set(d.key, [])
for (const item of schedule) byDay.get(item.weekday)?.push(item)
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("classId", createClassId || defaultClassId)
formData.set("weekday", String(createWeekday))
const res = await createClassScheduleItemAction(null, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create schedule item")
}
} catch {
toast.error("Failed to create schedule item")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
formData.set("classId", editClassId)
formData.set("weekday", editWeekday)
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update schedule item")
}
} catch {
toast.error("Failed to update schedule item")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteClassScheduleItemAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete schedule item")
}
} catch {
toast.error("Failed to delete schedule item")
} finally {
setIsWorking(false)
}
}
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{WEEKDAYS.map((d) => {
const items = byDay.get(d.key) ?? []
return (
<Card key={d.key} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{d.label}</CardTitle>
<Badge variant="secondary" className={cn(items.length === 0 && "opacity-60")}>
{items.length} items
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={classes.length === 0}
onClick={() => {
setCreateWeekday(d.key)
setCreateOpen(true)
}}
>
<Plus className="size-4" />
</Button>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<div className="text-muted-foreground text-sm">No classes scheduled.</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="space-y-1 border-b pb-4 last:border-0 last:pb-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium leading-none">{item.course}</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{classNameById.get(item.classId) ?? "Class"}</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(item)}>
<Pencil className="mr-2 size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(item)}
>
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="text-muted-foreground flex items-center gap-3 text-sm">
<span className="inline-flex items-center gap-1 tabular-nums">
<Clock className="h-3.5 w-3.5" />
{item.startTime}{item.endTime}
</span>
{item.location ? (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{item.location}
</span>
) : null}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
})}
<Dialog
open={createOpen}
onOpenChange={(v) => {
if (isWorking) return
setCreateOpen(v)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add schedule item</DialogTitle>
<DialogDescription>Create a class schedule entry.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={createClassId} onValueChange={setCreateClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={createClassId} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Weekday</Label>
<Input value={WEEKDAYS.find((w) => w.key === createWeekday)?.label ?? ""} readOnly className="col-span-3" />
<input type="hidden" name="weekday" value={String(createWeekday)} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-startTime" className="text-right">
Start
</Label>
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-endTime" className="text-right">
End
</Label>
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-course" className="text-right">
Course
</Label>
<Input id="create-course" name="course" className="col-span-3" placeholder="e.g. Math" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-location" className="text-right">
Location
</Label>
<Input id="create-location" name="location" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !createClassId}>
{isWorking ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit schedule item</DialogTitle>
<DialogDescription>Update this schedule entry.</DialogDescription>
</DialogHeader>
{editItem ? (
<form action={handleUpdate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={editClassId} onValueChange={setEditClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={editClassId} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Weekday</Label>
<div className="col-span-3">
<Select value={editWeekday} onValueChange={setEditWeekday}>
<SelectTrigger>
<SelectValue placeholder="Select weekday" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Mon</SelectItem>
<SelectItem value="2">Tue</SelectItem>
<SelectItem value="3">Wed</SelectItem>
<SelectItem value="4">Thu</SelectItem>
<SelectItem value="5">Fri</SelectItem>
<SelectItem value="6">Sat</SelectItem>
<SelectItem value="7">Sun</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="weekday" value={editWeekday} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-startTime" className="text-right">
Start
</Label>
<Input
id="edit-startTime"
name="startTime"
type="time"
className="col-span-3"
defaultValue={editItem.startTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-endTime" className="text-right">
End
</Label>
<Input
id="edit-endTime"
name="endTime"
type="time"
className="col-span-3"
defaultValue={editItem.endTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-course" className="text-right">
Course
</Label>
<Input
id="edit-course"
name="course"
className="col-span-3"
defaultValue={editItem.course}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-location" className="text-right">
Location
</Label>
<Input
id="edit-location"
name="location"
className="col-span-3"
defaultValue={editItem.location ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete schedule item?</AlertDialogTitle>
<AlertDialogDescription>
{deleteItem ? (
<>
This will permanently delete <span className="font-medium text-foreground">{deleteItem.course}</span>{" "}
({deleteItem.startTime}{deleteItem.endTime}).
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={isWorking}
>
{isWorking ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,169 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, X } from "lucide-react"
import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Label } from "@/shared/components/ui/label"
import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions"
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
const router = useRouter()
const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
const [enrollClassId, setEnrollClassId] = useState(defaultClassId)
useEffect(() => {
if (!open) return
setEnrollClassId(defaultClassId)
}, [open, defaultClassId])
const handleEnroll = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await enrollStudentByEmailAction(enrollClassId, null, formData)
if (res.success) {
toast.success(res.message)
setOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to add student")
}
} catch {
toast.error("Failed to add student")
} finally {
setIsWorking(false)
}
}
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm">
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
<Input
placeholder="Search students..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{(search || classId !== "all") && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setClassId(null)
}}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<Dialog
open={open}
onOpenChange={(v) => {
if (isWorking) return
setOpen(v)
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={classes.length === 0}>
<UserPlus className="size-4" />
Add student
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Add student</DialogTitle>
<DialogDescription>Enroll a student by email to a class.</DialogDescription>
</DialogHeader>
<form action={handleEnroll}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={enrollClassId} onValueChange={setEnrollClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="student-email" className="text-right">
Email
</Label>
<Input
id="student-email"
name="email"
type="email"
className="col-span-3"
placeholder="student@example.com"
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !enrollClassId}>
{isWorking ? "Adding..." : "Add"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,158 @@
"use client"
import { useState } from "react"
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 { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import type { ClassStudent } from "../types"
import { setStudentEnrollmentStatusAction } from "../actions"
export function StudentsTable({ students }: { students: ClassStudent[] }) {
const router = useRouter()
const [workingKey, setWorkingKey] = useState<string | null>(null)
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
const key = `${student.classId}:${student.id}:${status}`
setWorkingKey(key)
try {
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to update student")
}
} catch {
toast.error("Failed to update student")
} finally {
setWorkingKey(null)
}
}
return (
<>
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Email</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s) => (
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-12", s.status !== "active" && "opacity-70")}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>{s.className}</TableCell>
<TableCell>
<Badge variant={s.status === "active" ? "secondary" : "outline"}>
{s.status === "active" ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{s.status !== "active" ? (
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
<UserCheck className="mr-2 size-4" />
Set active
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
<UserX className="mr-2 size-4" />
Set inactive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setRemoveTarget(s)}
className="text-destructive focus:text-destructive"
disabled={s.status === "inactive" || workingKey !== null}
>
<UserX className="mr-2 size-4" />
Remove from class
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<AlertDialog
open={Boolean(removeTarget)}
onOpenChange={(open) => {
if (workingKey !== null) return
if (!open) setRemoveTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove student from class?</AlertDialogTitle>
<AlertDialogDescription>
{removeTarget ? (
<>
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
<span className="font-medium text-foreground">{removeTarget.className}</span>.
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={workingKey !== null}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={workingKey !== null}
onClick={() => {
if (!removeTarget) return
setRemoveTarget(null)
setStatus(removeTarget, "inactive")
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
export type TeacherClass = {
id: string
schoolName?: string | null
name: string
grade: string
homeroom?: string | null
room?: string | null
invitationCode?: string | null
studentCount: number
}
export type TeacherOption = {
id: string
name: string
email: string
}
export const DEFAULT_CLASS_SUBJECTS = ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"] as const
export type ClassSubject = (typeof DEFAULT_CLASS_SUBJECTS)[number]
export type ClassSubjectTeacherAssignment = {
subject: ClassSubject
teacher: TeacherOption | null
}
export type AdminClassListItem = {
id: string
schoolName?: string | null
schoolId?: string | null
name: string
grade: string
gradeId?: string | null
homeroom?: string | null
room?: string | null
invitationCode?: string | null
teacher: TeacherOption
subjectTeachers: ClassSubjectTeacherAssignment[]
studentCount: number
createdAt: string
updatedAt: string
}
export type CreateTeacherClassInput = {
schoolName?: string | null
schoolId?: string | null
name: string
grade: string
gradeId?: string | null
homeroom?: string | null
room?: string | null
}
export type UpdateTeacherClassInput = {
schoolName?: string | null
schoolId?: string | null
name?: string
grade?: string
gradeId?: string | null
homeroom?: string | null
room?: string | null
}
export type ClassStudent = {
id: string
name: string
email: string
classId: string
className: string
status: "active" | "inactive"
}
export type ClassScheduleItem = {
id: string
classId: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
}
export type StudentEnrolledClass = {
id: string
schoolName?: string | null
name: string
grade: string
homeroom?: string | null
room?: string | null
}
export type StudentScheduleItem = {
id: string
classId: string
className: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
}
export type CreateClassScheduleItemInput = {
classId: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
}
export type UpdateClassScheduleItemInput = {
classId?: string
weekday?: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime?: string
endTime?: string
course?: string
location?: string | null
}
export type ClassBasicInfo = {
id: string
name: string
grade: string
homeroom?: string | null
room?: string | null
invitationCode?: string | null
}
export type ScoreStats = {
count: number
avg: number | null
median: number | null
min: number | null
max: number | null
}
export type ClassHomeworkAssignmentStats = {
assignmentId: string
title: string
status: string
createdAt: string
dueAt: string | null
isActive: boolean
isOverdue: boolean
maxScore: number
targetCount: number
submittedCount: number
gradedCount: number
scoreStats: ScoreStats
}
export type ClassHomeworkInsights = {
class: ClassBasicInfo
studentCounts: {
total: number
active: number
inactive: number
}
assignments: ClassHomeworkAssignmentStats[]
latest: ClassHomeworkAssignmentStats | null
overallScores: ScoreStats
}
export type GradeHomeworkClassSummary = {
class: ClassBasicInfo
studentCounts: {
total: number
active: number
inactive: number
}
latestAvg: number | null
prevAvg: number | null
deltaAvg: number | null
overallScores: ScoreStats
}
export type GradeHomeworkInsights = {
grade: {
id: string
name: string
school: { id: string; name: string }
}
classCount: number
studentCounts: {
total: number
active: number
inactive: number
}
assignments: ClassHomeworkAssignmentStats[]
latest: ClassHomeworkAssignmentStats | null
overallScores: ScoreStats
classes: GradeHomeworkClassSummary[]
}

View File

@@ -0,0 +1,158 @@
import type { ReactNode } from "react"
import { Users, LayoutDashboard, BookOpen, FileText, ClipboardList, Library, Activity } from "lucide-react"
import type { AdminDashboardData } from "@/modules/dashboard/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<div className="text-sm text-muted-foreground">System overview across users, learning content, and activity.</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-2">
<Activity className="h-4 w-4" />
{data.activeSessionsCount} active sessions
</Badge>
<Badge variant="outline" className="gap-2">
<Users className="h-4 w-4" />
{data.userCount} users
</Badge>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard title="Users" value={data.userCount} icon={<Users className="h-4 w-4" />} />
<KpiCard title="Classes" value={data.classCount} icon={<LayoutDashboard className="h-4 w-4" />} />
<KpiCard title="Homework (published)" value={data.homeworkAssignmentPublishedCount} icon={<ClipboardList className="h-4 w-4" />} />
<KpiCard title="To grade" value={data.homeworkSubmissionToGradeCount} icon={<FileText className="h-4 w-4" />} />
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>User Roles</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.userRoleCounts.length === 0 ? (
<EmptyState title="No users" description="No user records found." />
) : (
data.userRoleCounts.map((r) => (
<div key={r.role} className="flex items-center justify-between">
<Badge variant="secondary">{r.role}</Badge>
<div className="text-sm font-medium tabular-nums">{r.count}</div>
</div>
))
)}
</CardContent>
</Card>
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Content</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label="Textbooks" value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="Chapters" value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="Questions" value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="Exams" value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Homework Activity</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label="Assignments" value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="Submissions" value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="To grade" value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Recent Users</CardTitle>
</CardHeader>
<CardContent>
{data.recentUsers.length === 0 ? (
<EmptyState title="No users yet" description="Seed the database to see users here." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recentUsers.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name || "-"}</TableCell>
<TableCell className="text-muted-foreground">{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role ?? "unknown"}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}
function KpiCard({
title,
value,
icon,
}: {
title: string
value: number
icon: ReactNode
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{value}</div>
</CardContent>
</Card>
)
}
function ContentRow({
label,
value,
icon,
}: {
label: string
value: number
icon: ReactNode
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon}
<div className="text-sm text-muted-foreground">{label}</div>
</div>
<div className="text-sm font-medium tabular-nums">{value}</div>
</div>
)
}

View File

@@ -1,25 +0,0 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
export function AdminDashboard() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader><CardTitle>System Status</CardTitle></CardHeader>
<CardContent className="text-green-600 font-bold">Operational</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Total Users</CardTitle></CardHeader>
<CardContent>2,450</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Active Sessions</CardTitle></CardHeader>
<CardContent>142</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import Link from "next/link"
import { Button } from "@/shared/components/ui/button"
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
return (
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<div className="text-sm text-muted-foreground">Welcome back, {studentName}.</div>
</div>
<Button asChild variant="outline">
<Link href="/student/learning/assignments">View assignments</Link>
</Button>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import type { StudentDashboardProps } from "@/modules/dashboard/types"
import { StudentDashboardHeader } from "./student-dashboard-header"
import { StudentGradesCard } from "./student-grades-card"
import { StudentRankingCard } from "./student-ranking-card"
import { StudentStatsGrid } from "./student-stats-grid"
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
export function StudentDashboard({
studentName,
enrolledClassCount,
dueSoonCount,
overdueCount,
gradedCount,
todayScheduleItems,
upcomingAssignments,
grades,
}: StudentDashboardProps) {
return (
<div className="space-y-6">
<StudentDashboardHeader studentName={studentName} />
<StudentStatsGrid
enrolledClassCount={enrolledClassCount}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
/>
<div className="grid gap-4 md:grid-cols-2">
<StudentGradesCard grades={grades} />
<StudentRankingCard ranking={grades.ranking} />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<StudentTodayScheduleCard items={todayScheduleItems} />
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
import Link from "next/link"
import { BarChart3 } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
const hasGradeTrend = grades.trend.length > 0
const hasRecentGrades = grades.recent.length > 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
Recent Grades
</CardTitle>
</CardHeader>
<CardContent>
{!hasGradeTrend ? (
<EmptyState
icon={BarChart3}
title="No graded work yet"
description="Finish and submit assignments to see your score trend."
className="border-none h-72"
/>
) : (
<div className="space-y-4">
<div className="rounded-md border bg-card p-4">
<svg viewBox="0 0 100 40" className="h-24 w-full">
<polyline
fill="none"
stroke="currentColor"
strokeWidth="2"
points={grades.trend
.map((p, i) => {
const t = grades.trend.length > 1 ? i / (grades.trend.length - 1) : 0
const x = t * 100
const v = Number.isFinite(p.percentage) ? Math.max(0, Math.min(100, p.percentage)) : 0
const y = 40 - (v / 100) * 40
return `${x},${y}`
})
.join(" ")}
className="text-primary"
/>
</svg>
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
<div>
Latest:{" "}
<span className="font-medium text-foreground tabular-nums">
{Math.round(grades.trend[grades.trend.length - 1]?.percentage ?? 0)}%
</span>
</div>
<div>
Points:{" "}
<span className="font-medium text-foreground tabular-nums">
{grades.trend[grades.trend.length - 1]?.score ?? 0}/{grades.trend[grades.trend.length - 1]?.maxScore ?? 0}
</span>
</div>
</div>
</div>
{!hasRecentGrades ? null : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Assignment</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">When</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grades.recent.map((r) => (
<TableRow key={r.assignmentId} className="h-12">
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${r.assignmentId}`} className="hover:underline">
{r.assignmentTitle}
</Link>
</TableCell>
<TableCell className="tabular-nums">
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,47 @@
import { Trophy } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { StudentRanking } from "@/modules/homework/types"
export function StudentRankingCard({ ranking }: { ranking: StudentRanking | null }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-muted-foreground" />
Ranking
</CardTitle>
</CardHeader>
<CardContent>
{!ranking ? (
<EmptyState
icon={Trophy}
title="No ranking available"
description="Join a class and complete graded work to see your rank."
className="border-none h-72"
/>
) : (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-md border bg-card p-4">
<div className="text-sm text-muted-foreground">Class Rank</div>
<div className="mt-1 text-3xl font-bold tabular-nums">
{ranking.rank}/{ranking.classSize}
</div>
</div>
<div className="rounded-md border bg-card p-4">
<div className="text-sm text-muted-foreground">Overall</div>
<div className="mt-1 text-3xl font-bold tabular-nums">{Math.round(ranking.percentage)}%</div>
<div className="text-xs text-muted-foreground tabular-nums">
{ranking.totalScore}/{ranking.totalMaxScore} pts
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">Based on latest graded submissions per assignment for your class.</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,66 @@
import { BookOpen, CheckCircle2, PenTool, TriangleAlert } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
type Stat = {
title: string
value: string
description: string
icon: typeof BookOpen
}
export function StudentStatsGrid({
enrolledClassCount,
dueSoonCount,
overdueCount,
gradedCount,
}: {
enrolledClassCount: number
dueSoonCount: number
overdueCount: number
gradedCount: number
}) {
const stats: readonly Stat[] = [
{
title: "My Classes",
value: String(enrolledClassCount),
description: "Enrolled classes",
icon: BookOpen,
},
{
title: "Due Soon",
value: String(dueSoonCount),
description: "Next 7 days",
icon: PenTool,
},
{
title: "Overdue",
value: String(overdueCount),
description: "Needs attention",
icon: TriangleAlert,
},
{
title: "Graded",
value: String(gradedCount),
description: "With score",
icon: CheckCircle2,
},
]
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{stat.value}</div>
<div className="text-xs text-muted-foreground">{stat.description}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { CalendarDays, CalendarX, Clock, MapPin } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { StudentTodayScheduleItem } from "@/modules/dashboard/types"
export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) {
const hasSchedule = items.length > 0
return (
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
Today&apos;s Schedule
</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (
<EmptyState
icon={CalendarX}
title="No classes today"
description="Your timetable is clear for today."
className="border-none h-72"
/>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0">
<div className="space-y-1 min-w-0">
<div className="font-medium leading-none truncate">{item.course}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="flex items-center">
<Clock className="mr-1 h-3 w-3" />
<span>
{item.startTime}{item.endTime}
</span>
</div>
{item.location ? (
<div className="flex items-center">
<MapPin className="mr-1 h-3 w-3" />
<span className="truncate">{item.location}</span>
</div>
) : null}
</div>
</div>
<Badge variant="secondary" className="shrink-0">
{item.className}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,83 @@
import Link from "next/link"
import { PenTool } from "lucide-react"
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 { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded") return "default"
if (status === "submitted") return "secondary"
if (status === "in_progress") return "secondary"
return "outline"
}
const getStatusLabel = (status: string) => {
if (status === "graded") return "Graded"
if (status === "submitted") return "Submitted"
if (status === "in_progress") return "In progress"
return "Not started"
}
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
const hasAssignments = upcomingAssignments.length > 0
return (
<Card className="lg:col-span-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<PenTool className="h-4 w-4 text-muted-foreground" />
Upcoming Assignments
</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href="/student/learning/assignments">View all</Link>
</Button>
</CardHeader>
<CardContent>
{!hasAssignments ? (
<EmptyState
icon={PenTool}
title="No assignments"
description="You have no assigned homework right now."
className="border-none h-72"
/>
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Title</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{upcomingAssignments.map((a) => (
<TableRow key={a.id} className="h-12">
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
{a.title}
</Link>
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,21 +0,0 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
export function StudentDashboard() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Student Dashboard</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader><CardTitle>My Courses</CardTitle></CardHeader>
<CardContent>Enrolled in 5 courses</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Assignments</CardTitle></CardHeader>
<CardContent>2 due this week</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,62 +1,22 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { Inbox } from "lucide-react";
import { formatDate } from "@/shared/lib/utils";
import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
interface SubmissionItem {
id: string;
studentName: string;
studentAvatar?: string;
assignment: string;
submittedAt: string;
status: "submitted" | "late";
}
const MOCK_SUBMISSIONS: SubmissionItem[] = [
{
id: "1",
studentName: "Alice Johnson",
assignment: "React Component Composition",
submittedAt: "10 minutes ago",
status: "submitted",
},
{
id: "2",
studentName: "Bob Smith",
assignment: "Design System Analysis",
submittedAt: "1 hour ago",
status: "submitted",
},
{
id: "3",
studentName: "Charlie Brown",
assignment: "React Component Composition",
submittedAt: "2 hours ago",
status: "late",
},
{
id: "4",
studentName: "Diana Prince",
assignment: "CSS Grid Layout",
submittedAt: "Yesterday",
status: "submitted",
},
{
id: "5",
studentName: "Evan Wright",
assignment: "Design System Analysis",
submittedAt: "Yesterday",
status: "submitted",
},
];
export function RecentSubmissions() {
const hasSubmissions = MOCK_SUBMISSIONS.length > 0;
export function RecentSubmissions({ submissions }: { submissions: HomeworkSubmissionListItem[] }) {
const hasSubmissions = submissions.length > 0;
return (
<Card className="col-span-4 lg:col-span-4">
<CardHeader>
<CardTitle>Recent Submissions</CardTitle>
<CardTitle className="flex items-center gap-2">
<Inbox className="h-4 w-4 text-muted-foreground" />
Recent Submissions
</CardTitle>
</CardHeader>
<CardContent>
{!hasSubmissions ? (
@@ -64,15 +24,16 @@ export function RecentSubmissions() {
icon={Inbox}
title="No New Submissions"
description="All caught up! There are no new submissions to review."
action={{ label: "View submissions", href: "/teacher/homework/submissions" }}
className="border-none h-[300px]"
/>
) : (
<div className="space-y-6">
{MOCK_SUBMISSIONS.map((item) => (
{submissions.map((item) => (
<div key={item.id} className="flex items-center justify-between group">
<div className="flex items-center space-x-4">
<Avatar className="h-9 w-9">
<AvatarImage src={item.studentAvatar} alt={item.studentName} />
<AvatarImage src={undefined} alt={item.studentName} />
<AvatarFallback>{item.studentName.charAt(0)}</AvatarFallback>
</Avatar>
<div className="space-y-1">
@@ -80,16 +41,20 @@ export function RecentSubmissions() {
{item.studentName}
</p>
<p className="text-sm text-muted-foreground">
Submitted <span className="font-medium text-foreground">{item.assignment}</span>
<Link
href={`/teacher/homework/submissions/${item.id}`}
className="font-medium text-foreground hover:underline"
>
{item.assignmentTitle}
</Link>
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="text-sm text-muted-foreground">
{/* Using static date for demo to prevent hydration mismatch */}
{item.submittedAt}
{item.submittedAt ? formatDate(item.submittedAt) : "-"}
</div>
{item.status === "late" && (
{item.isLate && (
<span className="inline-flex items-center rounded-full border border-destructive px-2 py-0.5 text-xs font-semibold text-destructive transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
Late
</span>

View File

@@ -0,0 +1,86 @@
import Link from "next/link"
import { Users } from "lucide-react"
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 { 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)
const topClassesByStudents = [...classes].sort((a, b) => (b.studentCount ?? 0) - (a.studentCount ?? 0)).slice(0, 8)
const maxStudentCount = Math.max(1, ...topClassesByStudents.map((c) => c.studentCount ?? 0))
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
My Classes
</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href="/teacher/classes/my">View all</Link>
</Button>
</CardHeader>
<CardContent className="grid gap-3">
{classes.length === 0 ? (
<EmptyState
icon={Users}
title="No classes yet"
description="Create a class to start managing students and schedules."
action={{ label: "Create class", href: "/teacher/classes/my" }}
className="border-none h-72"
/>
) : (
<>
{topClassesByStudents.length > 0 ? (
<div className="rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Students by class</div>
<div className="text-xs text-muted-foreground tabular-nums">Total {totalStudents}</div>
</div>
<div className="mt-3 grid gap-2">
{topClassesByStudents.map((c) => {
const count = c.studentCount ?? 0
const pct = Math.max(0, Math.min(100, (count / maxStudentCount) * 100))
return (
<div key={c.id} className="grid grid-cols-[minmax(0,1fr)_120px_52px] items-center gap-3">
<div className="truncate text-sm">{c.name}</div>
<div className="h-2 rounded-full bg-muted">
<div className="h-2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
</div>
<div className="text-right text-xs tabular-nums text-muted-foreground">{count}</div>
</div>
)
})}
</div>
</div>
) : null}
{classes.slice(0, 6).map((c) => (
<Link
key={c.id}
href={`/teacher/classes/my/${encodeURIComponent(c.id)}`}
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50"
>
<div className="min-w-0">
<div className="font-medium truncate">{c.name}</div>
<div className="text-sm text-muted-foreground">
{c.grade}
{c.homeroom ? ` · ${c.homeroom}` : ""}
{c.room ? ` · ${c.room}` : ""}
</div>
</div>
<Badge variant="outline" className="flex items-center gap-1">
<Users className="h-3 w-3" />
{c.studentCount} students
</Badge>
</Link>
))}
</>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,13 @@
import { TeacherQuickActions } from "./teacher-quick-actions"
export function TeacherDashboardHeader() {
return (
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Teacher</h2>
<p className="text-muted-foreground">Overview of today&apos;s work and your classes.</p>
</div>
<TeacherQuickActions />
</div>
)
}

View File

@@ -0,0 +1,59 @@
import type { TeacherDashboardData, TeacherTodayScheduleItem } from "@/modules/dashboard/types"
import { TeacherClassesCard } from "./teacher-classes-card"
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
import { TeacherHomeworkCard } from "./teacher-homework-card"
import { RecentSubmissions } from "./recent-submissions"
import { TeacherSchedule } from "./teacher-schedule"
import { TeacherStats } from "./teacher-stats"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay()
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
export 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))
const todayScheduleItems: TeacherTodayScheduleItem[] = data.schedule
.filter((s) => s.weekday === todayWeekday)
.sort((a, b) => a.startTime.localeCompare(b.startTime))
.map((s): TeacherTodayScheduleItem => ({
id: s.id,
classId: s.classId,
className: classNameById.get(s.classId) ?? "Class",
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt))
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
const recentSubmissions = submittedSubmissions.slice(0, 6)
return (
<div className="flex h-full flex-col space-y-8 p-8">
<TeacherDashboardHeader />
<TeacherStats
totalStudents={totalStudents}
classCount={data.classes.length}
toGradeCount={toGradeCount}
todayScheduleCount={todayScheduleItems.length}
/>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<TeacherSchedule items={todayScheduleItems} />
<RecentSubmissions submissions={recentSubmissions} />
</div>
<div className="grid gap-6 lg:grid-cols-2">
<TeacherClassesCard classes={data.classes} />
<TeacherHomeworkCard assignments={data.assignments} />
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import Link from "next/link"
import { PenTool } from "lucide-react"
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 { EmptyState } from "@/shared/components/ui/empty-state"
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<PenTool className="h-4 w-4 text-muted-foreground" />
Homework
</CardTitle>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/teacher/homework/assignments">Open list</Link>
</Button>
<Button asChild size="sm">
<Link href="/teacher/homework/assignments/create">New</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{assignments.length === 0 ? (
<EmptyState
icon={PenTool}
title="No homework assignments yet"
description="Create an assignment from an exam and publish it to students."
action={{ label: "Create assignment", href: "/teacher/homework/assignments/create" }}
className="border-none h-72"
/>
) : (
assignments.slice(0, 6).map((a) => (
<Link
key={a.id}
href={`/teacher/homework/assignments/${encodeURIComponent(a.id)}`}
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50"
>
<div className="min-w-0">
<div className="font-medium truncate">{a.title}</div>
<div className="text-sm text-muted-foreground truncate">{a.sourceExamTitle}</div>
</div>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</Link>
))
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,29 @@
import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import { PlusCircle, CheckSquare, Users } from "lucide-react";
export function TeacherQuickActions() {
return (
<div className="flex items-center space-x-2">
<Button asChild size="sm">
<Link href="/teacher/homework/assignments/create">
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/teacher/homework/submissions">
<CheckSquare className="mr-2 h-4 w-4" />
Grade
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/teacher/classes/my">
<Users className="mr-2 h-4 w-4" />
My Classes
</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { Clock, MapPin, CalendarDays, CalendarX } from "lucide-react";
import { EmptyState } from "@/shared/components/ui/empty-state";
type TeacherTodayScheduleItem = {
id: string;
classId: string;
className: string;
course: string;
startTime: string;
endTime: string;
location: string | null;
};
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
const hasSchedule = items.length > 0;
return (
<Card className="col-span-3">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
Today&apos;s Schedule
</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (
<EmptyState
icon={CalendarX}
title="No Classes Today"
description="No timetable entries for today."
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
className="border-none h-[300px]"
/>
) : (
<div className="space-y-4">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
>
<div className="space-y-1">
<Link
href={`/teacher/classes/schedule?classId=${encodeURIComponent(item.classId)}`}
className="font-medium leading-none hover:underline"
>
{item.course}
</Link>
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-1 h-3 w-3" />
<span className="mr-3">{item.startTime}{item.endTime}</span>
{item.location ? (
<>
<MapPin className="mr-1 h-3 w-3" />
<span>{item.location}</span>
</>
) : null}
</div>
</div>
<Badge variant="secondary">
{item.className}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -2,45 +2,21 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
import { Users, BookOpen, FileCheck, Calendar } from "lucide-react";
import { Skeleton } from "@/shared/components/ui/skeleton";
interface StatItem {
title: string;
value: string;
description: string;
icon: React.ElementType;
}
const MOCK_STATS: StatItem[] = [
{
title: "Total Students",
value: "1,248",
description: "+12% from last semester",
icon: Users,
},
{
title: "Active Courses",
value: "4",
description: "2 lectures, 2 workshops",
icon: BookOpen,
},
{
title: "To Grade",
value: "28",
description: "5 submissions pending review",
icon: FileCheck,
},
{
title: "Upcoming Classes",
value: "3",
description: "Today's schedule",
icon: Calendar,
},
];
interface TeacherStatsProps {
totalStudents: number;
classCount: number;
toGradeCount: number;
todayScheduleCount: number;
isLoading?: boolean;
}
export function TeacherStats({ isLoading = false }: TeacherStatsProps) {
export function TeacherStats({
totalStudents,
classCount,
toGradeCount,
todayScheduleCount,
isLoading = false,
}: TeacherStatsProps) {
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -60,9 +36,36 @@ export function TeacherStats({ isLoading = false }: TeacherStatsProps) {
);
}
const stats = [
{
title: "Total Students",
value: String(totalStudents),
description: "Across all your classes",
icon: Users,
},
{
title: "My Classes",
value: String(classCount),
description: "Active classes you manage",
icon: BookOpen,
},
{
title: "To Grade",
value: String(toGradeCount),
description: "Submitted homework waiting for grading",
icon: FileCheck,
},
{
title: "Today",
value: String(todayScheduleCount),
description: "Scheduled items today",
icon: Calendar,
},
] as const;
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{MOCK_STATS.map((stat, i) => (
{stats.map((stat, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">

View File

@@ -1,21 +0,0 @@
import { Button } from "@/shared/components/ui/button";
import { PlusCircle, CheckSquare, MessageSquare } from "lucide-react";
export function TeacherQuickActions() {
return (
<div className="flex items-center space-x-2">
<Button size="sm">
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Button>
<Button variant="outline" size="sm">
<CheckSquare className="mr-2 h-4 w-4" />
Grade All
</Button>
<Button variant="outline" size="sm">
<MessageSquare className="mr-2 h-4 w-4" />
Message Class
</Button>
</div>
);
}

View File

@@ -1,81 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { Clock, MapPin, CalendarX } from "lucide-react";
import { EmptyState } from "@/shared/components/ui/empty-state";
interface ScheduleItem {
id: string;
course: string;
time: string;
location: string;
type: "Lecture" | "Workshop" | "Lab";
}
// MOCK_SCHEDULE can be empty to test empty state
const MOCK_SCHEDULE: ScheduleItem[] = [
{
id: "1",
course: "Advanced Web Development",
time: "09:00 AM - 10:30 AM",
location: "Room 304",
type: "Lecture",
},
{
id: "2",
course: "UI/UX Design Principles",
time: "11:00 AM - 12:30 PM",
location: "Design Studio A",
type: "Workshop",
},
{
id: "3",
course: "Frontend Frameworks",
time: "02:00 PM - 03:30 PM",
location: "Online (Zoom)",
type: "Lecture",
},
];
export function TeacherSchedule() {
const hasSchedule = MOCK_SCHEDULE.length > 0;
return (
<Card className="col-span-3">
<CardHeader>
<CardTitle>Today&apos;s Schedule</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (
<EmptyState
icon={CalendarX}
title="No Classes Today"
description="You have no classes scheduled for today. Enjoy your free time!"
className="border-none h-[300px]"
/>
) : (
<div className="space-y-4">
{MOCK_SCHEDULE.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
>
<div className="space-y-1">
<p className="font-medium leading-none">{item.course}</p>
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-1 h-3 w-3" />
<span className="mr-3">{item.time}</span>
<MapPin className="mr-1 h-3 w-3" />
<span>{item.location}</span>
</div>
</div>
<Badge variant={item.type === "Lecture" ? "default" : "secondary"}>
{item.type}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,25 +0,0 @@
"use client"
import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions";
import { TeacherStats } from "@/modules/dashboard/components/teacher-stats";
import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule";
import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions";
// This component is now exclusively for the Teacher Role View
export function TeacherDashboard() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Teacher Dashboard</h1>
<TeacherQuickActions />
</div>
<TeacherStats />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<TeacherSchedule />
<RecentSubmissions />
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import "server-only"
import { cache } from "react"
import { count, desc, eq, gt } from "drizzle-orm"
import { db } from "@/shared/db"
import {
chapters,
classes,
exams,
homeworkAssignments,
homeworkSubmissions,
questions,
sessions,
textbooks,
users,
} from "@/shared/db/schema"
import type { AdminDashboardData } from "./types"
export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData> => {
const now = new Date()
const [
activeSessionsRow,
userCountRow,
userRoleRows,
classCountRow,
textbookCountRow,
chapterCountRow,
questionCountRow,
examCountRow,
homeworkAssignmentCountRow,
homeworkAssignmentPublishedCountRow,
homeworkSubmissionCountRow,
homeworkSubmissionToGradeCountRow,
recentUserRows,
] = 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({ value: count() }).from(classes),
db.select({ value: count() }).from(textbooks),
db.select({ value: count() }).from(chapters),
db.select({ value: count() }).from(questions),
db.select({ value: count() }).from(exams),
db.select({ value: count() }).from(homeworkAssignments),
db.select({ value: count() }).from(homeworkAssignments).where(eq(homeworkAssignments.status, "published")),
db.select({ value: count() }).from(homeworkSubmissions),
db.select({ value: count() }).from(homeworkSubmissions).where(eq(homeworkSubmissions.status, "submitted")),
db
.select({
id: users.id,
name: users.name,
email: users.email,
role: users.role,
createdAt: users.createdAt,
})
.from(users)
.orderBy(desc(users.createdAt))
.limit(8),
])
const activeSessionsCount = Number(activeSessionsRow[0]?.value ?? 0)
const userCount = Number(userCountRow[0]?.value ?? 0)
const classCount = Number(classCountRow[0]?.value ?? 0)
const textbookCount = Number(textbookCountRow[0]?.value ?? 0)
const chapterCount = Number(chapterCountRow[0]?.value ?? 0)
const questionCount = Number(questionCountRow[0]?.value ?? 0)
const examCount = Number(examCountRow[0]?.value ?? 0)
const homeworkAssignmentCount = Number(homeworkAssignmentCountRow[0]?.value ?? 0)
const homeworkAssignmentPublishedCount = Number(homeworkAssignmentPublishedCountRow[0]?.value ?? 0)
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0)
const userRoleCounts = userRoleRows
.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(),
}))
return {
activeSessionsCount,
userCount,
userRoleCounts,
classCount,
textbookCount,
chapterCount,
questionCount,
examCount,
homeworkAssignmentCount,
homeworkAssignmentPublishedCount,
homeworkSubmissionCount,
homeworkSubmissionToGradeCount,
recentUsers,
}
})

View File

@@ -0,0 +1,70 @@
import type { StudentDashboardGradeProps, StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
import type { TeacherClass, ClassScheduleItem } from "@/modules/classes/types"
import type { HomeworkAssignmentListItem, HomeworkSubmissionListItem } from "@/modules/homework/types"
export type AdminDashboardUserRoleCount = {
role: string
count: number
}
export type AdminDashboardRecentUser = {
id: string
name: string | null
email: string
role: string | null
createdAt: string
}
export type AdminDashboardData = {
activeSessionsCount: number
userCount: number
userRoleCounts: AdminDashboardUserRoleCount[]
classCount: number
textbookCount: number
chapterCount: number
questionCount: number
examCount: number
homeworkAssignmentCount: number
homeworkAssignmentPublishedCount: number
homeworkSubmissionCount: number
homeworkSubmissionToGradeCount: number
recentUsers: AdminDashboardRecentUser[]
}
export type StudentTodayScheduleItem = {
id: string
classId: string
className: string
course: string
startTime: string
endTime: string
location: string | null
}
export type StudentDashboardProps = {
studentName: string
enrolledClassCount: number
dueSoonCount: number
overdueCount: number
gradedCount: number
todayScheduleItems: StudentTodayScheduleItem[]
upcomingAssignments: StudentHomeworkAssignmentListItem[]
grades: StudentDashboardGradeProps
}
export type TeacherTodayScheduleItem = {
id: string
classId: string
className: string
course: string
startTime: string
endTime: string
location: string | null
}
export type TeacherDashboardData = {
classes: TeacherClass[]
schedule: ClassScheduleItem[]
assignments: HomeworkAssignmentListItem[]
submissions: HomeworkSubmissionListItem[]
}

View File

@@ -75,8 +75,7 @@ export async function createExamAction(
startTime: scheduled ? new Date(scheduled) : null,
status: "draft",
})
} catch (error) {
console.error("Failed to create exam:", error)
} catch {
return {
success: false,
message: "Database error: Failed to create exam",
@@ -156,8 +155,7 @@ export async function updateExamAction(
await db.update(exams).set(updateData).where(eq(exams.id, examId))
}
} catch (error) {
console.error("Failed to update exam:", error)
} catch {
return {
success: false,
message: "Database error: Failed to update exam",
@@ -197,8 +195,7 @@ export async function deleteExamAction(
try {
await db.delete(exams).where(eq(exams.id, examId))
} catch (error) {
console.error("Failed to delete exam:", error)
} catch {
return {
success: false,
message: "Database error: Failed to delete exam",
@@ -292,8 +289,7 @@ export async function duplicateExamAction(
)
}
})
} catch (error) {
console.error("Failed to duplicate exam:", error)
} catch {
return {
success: false,
message: "Database error: Failed to duplicate exam",

View File

@@ -0,0 +1,184 @@
"use client"
import { useMemo, type ReactNode } from "react"
import { cn } from "@/shared/lib/utils"
type ChoiceOption = {
id: string
text: string
}
type QuestionLike = {
questionId: string
questionType: string
questionContent: unknown
maxScore: number
}
type ExamViewerProps = {
structure: unknown
questions: QuestionLike[]
className?: string
selectedQuestionId?: string | null
onQuestionSelect?: (questionId: string) => void
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
const getOptions = (content: unknown): ChoiceOption[] => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: ChoiceOption[] = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
}
return out
}
export function ExamViewer(props: ExamViewerProps) {
const { structure, questions, className } = props
const questionById = useMemo(() => new Map(questions.map((q) => [q.questionId, q] as const)), [questions])
const questionNumberById = useMemo(() => {
const ids: string[] = []
const visit = (nodes: unknown) => {
if (!Array.isArray(nodes)) return
for (const node of nodes) {
if (!isRecord(node)) continue
if (node.type === "question") {
const questionId = typeof node.questionId === "string" ? node.questionId : ""
if (questionId) ids.push(questionId)
continue
}
if (node.type === "group") {
visit(Array.isArray(node.children) ? node.children : [])
}
}
}
if (Array.isArray(structure) && structure.length > 0) {
visit(structure)
} else {
for (const q of questions) ids.push(q.questionId)
}
const out = new Map<string, number>()
let n = 0
for (const id of ids) {
if (out.has(id)) continue
n += 1
out.set(id, n)
}
return out
}, [structure, questions])
const renderNodes = (rawNodes: unknown, depth: number): ReactNode => {
if (!Array.isArray(rawNodes)) return null
return (
<div className={depth > 0 ? "space-y-4 pl-4 border-l" : "space-y-6"}>
{rawNodes.map((node, idx) => {
if (!isRecord(node)) return null
const type = node.type
if (type === "group") {
const title = typeof node.title === "string" && node.title.trim().length > 0 ? node.title : "Section"
const children = Array.isArray(node.children) ? node.children : []
return (
<div key={`g-${depth}-${idx}`} className="space-y-3">
<div className={depth === 0 ? "text-base font-semibold" : "text-sm font-semibold text-muted-foreground"}>
{title}
</div>
{renderNodes(children, depth + 1)}
</div>
)
}
if (type === "question") {
const questionId = typeof node.questionId === "string" ? node.questionId : ""
if (!questionId) return null
const questionNumber = questionNumberById.get(questionId) ?? 0
const q = questionById.get(questionId) ?? null
const text = getQuestionText(q?.questionContent ?? null)
const options = getOptions(q?.questionContent ?? null)
const scoreFromStructure = typeof node.score === "number" ? node.score : null
const maxScore = scoreFromStructure ?? q?.maxScore ?? 0
const isSelected = props.selectedQuestionId === questionId
const isClickable = typeof props.onQuestionSelect === "function"
return (
<div
key={`q-${questionId}`}
className={cn(
"rounded-md border bg-card p-4",
isClickable && "cursor-pointer hover:bg-muted/30 transition-colors",
isSelected && "ring-2 ring-primary/30"
)}
role={isClickable ? "button" : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={isClickable ? () => props.onQuestionSelect?.(questionId) : undefined}
onKeyDown={
isClickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
props.onQuestionSelect?.(questionId)
}
}
: undefined
}
>
<div className="flex gap-3 min-w-0">
<div className="font-semibold tabular-nums">{questionNumber > 0 ? `${questionNumber}.` : "—"}</div>
<div className="min-w-0">
<div className="whitespace-pre-wrap text-sm">{text || "—"}</div>
<div className="mt-2 text-xs text-muted-foreground">
<span className="capitalize">{q?.questionType ?? "unknown"}</span>
<span className="mx-2"></span>
<span>Score: {maxScore}</span>
</div>
</div>
</div>
{(q?.questionType === "single_choice" || q?.questionType === "multiple_choice") && options.length > 0 ? (
<div className="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{options.map((opt) => (
<div key={opt.id} className="flex gap-2 text-sm text-muted-foreground">
<span className="font-medium">{opt.id}.</span>
<span className="whitespace-pre-wrap">{opt.text}</span>
</div>
))}
</div>
) : null}
</div>
)
}
return null
})}
</div>
)
}
if (Array.isArray(structure) && structure.length > 0) {
return <div className={className}>{renderNodes(structure, 0)}</div>
}
if (questions.length > 0) {
const flatNodes = questions.map((q) => ({ type: "question", questionId: q.questionId, score: q.maxScore }))
return <div className={className}>{renderNodes(flatNodes, 0)}</div>
}
return <div className={className ?? "text-sm text-muted-foreground"}>No questions available.</div>
}

View File

@@ -7,6 +7,8 @@ import { and, count, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
exams,
homeworkAnswers,
homeworkAssignmentQuestions,
@@ -78,6 +80,7 @@ export async function createHomeworkAssignmentAction(
const parsed = CreateHomeworkAssignmentSchema.safeParse({
sourceExamId: formData.get("sourceExamId"),
classId: formData.get("classId"),
title: formData.get("title") || undefined,
description: formData.get("description") || undefined,
availableAt: formData.get("availableAt") || undefined,
@@ -105,6 +108,13 @@ export async function createHomeworkAssignmentAction(
const input = parsed.data
const publish = input.publish ?? true
const [ownedClass] = await db
.select({ id: classes.id })
.from(classes)
.where(user.role === "admin" ? eq(classes.id, input.classId) : and(eq(classes.id, input.classId), eq(classes.teacherId, user.id)))
.limit(1)
if (!ownedClass) return { success: false, message: "Class not found" }
const exam = await db.query.exams.findFirst({
where: eq(exams.id, input.sourceExamId),
with: {
@@ -122,15 +132,30 @@ export async function createHomeworkAssignmentAction(
const dueAt = input.dueAt ? new Date(input.dueAt) : null
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
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)
)
)
).map((r) => r.studentId)
const classStudentIdSet = new Set(classStudentIds)
const targetStudentIds =
input.targetStudentIds && input.targetStudentIds.length > 0
? input.targetStudentIds
: (
await db
.select({ id: users.id })
.from(users)
.where(eq(users.role, "student"))
).map((r) => r.id)
? input.targetStudentIds.filter((id) => classStudentIdSet.has(id))
: classStudentIds
if (publish && targetStudentIds.length === 0) {
return { success: false, message: "No active students in this class" }
}
await db.transaction(async (tx) => {
await tx.insert(homeworkAssignments).values({

View File

@@ -0,0 +1,29 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { HomeworkAssignmentExamErrorExplorerLazy } from "@/modules/homework/components/homework-assignment-exam-error-explorer-lazy"
export function HomeworkAssignmentExamContentCard({
structure,
questions,
gradedSampleCount,
}: {
structure: unknown
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
</CardHeader>
<CardContent>
<HomeworkAssignmentExamErrorExplorerLazy
structure={structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import dynamic from "next/dynamic"
import { Skeleton } from "@/shared/components/ui/skeleton"
function ExamErrorExplorerFallback() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 h-[560px]">
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3 text-sm font-medium"></div>
<div className="flex-1 p-4 space-y-3">
<Skeleton className="h-10 w-[40%]" />
<Skeleton className="h-10 w-[60%]" />
<Skeleton className="h-10 w-[75%]" />
<Skeleton className="h-10 w-[55%]" />
<Skeleton className="h-10 w-[68%]" />
</div>
</div>
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3">
<div className="text-sm font-medium"></div>
<div className="mt-2 flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="min-w-0 flex-1 grid gap-1">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
</div>
</div>
</div>
<div className="flex-1 p-4 space-y-3">
<Skeleton className="h-4 w-[45%]" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
</div>
)
}
const LazyHomeworkAssignmentExamErrorExplorer = dynamic(
() =>
import("./homework-assignment-exam-error-explorer").then((m) => ({
default: m.HomeworkAssignmentExamErrorExplorer,
})),
{ ssr: false, loading: ExamErrorExplorerFallback }
)
export function HomeworkAssignmentExamErrorExplorerLazy({
structure,
questions,
gradedSampleCount,
}: {
structure: unknown
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<LazyHomeworkAssignmentExamErrorExplorer
structure={structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import { useMemo, useState } from "react"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { HomeworkAssignmentExamPreviewPane } from "@/modules/homework/components/homework-assignment-exam-preview-pane"
import { HomeworkAssignmentQuestionErrorDetailPanel } from "@/modules/homework/components/homework-assignment-question-error-detail-panel"
export function HomeworkAssignmentExamErrorExplorer({
structure,
questions,
gradedSampleCount,
heightClassName = "h-[560px]",
}: {
structure: unknown
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
heightClassName?: string
}) {
const firstQuestionId = questions[0]?.questionId ?? null
const [selectedQuestionId, setSelectedQuestionId] = useState<string | null>(firstQuestionId)
const selected = useMemo(() => {
if (!selectedQuestionId) return null
return questions.find((q) => q.questionId === selectedQuestionId) ?? null
}, [questions, selectedQuestionId])
return (
<div className={`grid grid-cols-1 gap-4 md:grid-cols-3 ${heightClassName}`}>
<HomeworkAssignmentExamPreviewPane
structure={structure}
questions={questions.map((q) => ({
questionId: q.questionId,
questionType: q.questionType,
questionContent: q.questionContent,
maxScore: q.maxScore,
}))}
selectedQuestionId={selectedQuestionId}
onQuestionSelect={setSelectedQuestionId}
/>
<HomeworkAssignmentQuestionErrorDetailPanel selected={selected} gradedSampleCount={gradedSampleCount} />
</div>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { ExamViewer } from "@/modules/exams/components/exam-viewer"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
export function HomeworkAssignmentExamPreviewPane({
structure,
questions,
selectedQuestionId,
onQuestionSelect,
}: {
structure: unknown
questions: Array<{
questionId: string
questionType: string
questionContent: unknown
maxScore: number
}>
selectedQuestionId: string | null
onQuestionSelect: (questionId: string) => void
}) {
return (
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3 text-sm font-medium"></div>
<ScrollArea className="flex-1 p-4">
<ExamViewer
structure={structure}
questions={questions}
selectedQuestionId={selectedQuestionId}
onQuestionSelect={onQuestionSelect}
/>
</ScrollArea>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useMemo, useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useSearchParams } from "next/navigation"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
@@ -13,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Textarea } from "@/shared/components/ui/textarea"
import { createHomeworkAssignmentAction } from "../actions"
import type { TeacherClass } from "@/modules/classes/types"
type ExamOption = { id: string; title: string }
@@ -25,11 +27,18 @@ function SubmitButton() {
)
}
export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]; classes: TeacherClass[] }) {
const router = useRouter()
const searchParams = useSearchParams()
const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams])
const [examId, setExamId] = useState<string>(initialExamId)
const initialClassId = useMemo(() => {
const fromQuery = searchParams.get("classId") || ""
if (fromQuery && classes.some((c) => c.id === fromQuery)) return fromQuery
return classes[0]?.id ?? ""
}, [classes, searchParams])
const [classId, setClassId] = useState<string>(initialClassId)
const [allowLate, setAllowLate] = useState<boolean>(false)
const handleSubmit = async (formData: FormData) => {
@@ -37,7 +46,12 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
toast.error("Please select an exam")
return
}
if (!classId) {
toast.error("Please select a class")
return
}
formData.set("sourceExamId", examId)
formData.set("classId", classId)
formData.set("allowLate", allowLate ? "true" : "false")
formData.set("publish", "true")
@@ -58,6 +72,23 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label>Class</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={classId} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Source Exam</Label>
<Select value={examId} onValueChange={setExamId}>
@@ -121,7 +152,7 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
<Textarea
id="targetStudentIdsText"
name="targetStudentIdsText"
placeholder="Leave empty to assign to all students. You can paste IDs separated by comma or newline."
placeholder="Optional. If provided, targets will be limited to students in the selected class."
className="min-h-[90px]"
/>
</div>
@@ -135,4 +166,3 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
</Card>
)
}

View File

@@ -0,0 +1,150 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const getOptions = (content: unknown): Array<{ id: string; text: string }> => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Array<{ id: string; text: string }> = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
}
return out
}
const safeInlineJson = (v: unknown) => {
try {
const s = JSON.stringify(v)
if (typeof s === "string" && s.length > 180) return `${s.slice(0, 180)}`
return s ?? String(v)
} catch {
return String(v)
}
}
const formatAnswer = (answerContent: unknown, question: HomeworkAssignmentQuestionAnalytics | null) => {
if (isRecord(answerContent) && "answer" in answerContent) answerContent = answerContent.answer
if (answerContent === null || answerContent === undefined) return "未作答"
const options = getOptions(question?.questionContent ?? null)
const optionTextById = new Map(options.map((o) => [o.id, o.text] as const))
if (typeof answerContent === "boolean") return answerContent ? "True" : "False"
if (typeof answerContent === "string") return optionTextById.get(answerContent) ?? answerContent
if (Array.isArray(answerContent)) {
const parts = answerContent
.map((x) => (typeof x === "string" ? optionTextById.get(x) ?? x : x))
.map((x) => (typeof x === "string" ? x : safeInlineJson(x)))
return parts.join(", ")
}
return safeInlineJson(answerContent)
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function ErrorRatePieChart({ errorRate }: { errorRate: number }) {
const pct = clamp01(errorRate) * 100
const r = 15.91549430918954
const dashA = pct
const dashB = 100 - pct
const showError = pct > 0
return (
<svg viewBox="0 0 36 36" className="size-12" role="img" aria-label={`错误率 ${pct.toFixed(1)}%`}>
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-border" />
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-chart-2" />
{showError ? (
<circle
cx="18"
cy="18"
r={r}
fill="none"
strokeWidth="3.5"
strokeLinecap="round"
strokeDasharray={`${dashA} ${dashB}`}
transform="rotate(-90 18 18)"
className="stroke-destructive"
/>
) : null}
<text x="18" y="19.2" textAnchor="middle" className="fill-foreground text-[8px] font-medium tabular-nums">
{pct.toFixed(0)}%
</text>
</svg>
)
}
export function HomeworkAssignmentQuestionErrorDetailPanel({
selected,
gradedSampleCount,
}: {
selected: HomeworkAssignmentQuestionAnalytics | null
gradedSampleCount: number
}) {
const wrongAnswers = selected?.wrongAnswers ?? []
const errorCount = selected?.errorCount ?? 0
const errorRate = selected?.errorRate ?? 0
return (
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3">
<div className="text-sm font-medium"></div>
{selected ? (
<div className="mt-2 flex items-center gap-3">
<div className="shrink-0">
<ErrorRatePieChart errorRate={errorRate} />
</div>
<div className="min-w-0 flex-1 grid gap-1 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{errorCount}</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{(errorRate * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{gradedSampleCount}</span>
</div>
</div>
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground"></div>
)}
</div>
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full p-4">
{!selected ? (
<div className="text-sm text-muted-foreground"></div>
) : wrongAnswers.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground"></div>
<div className="space-y-2">
{wrongAnswers.map((item, idx) => (
<div key={`${selected.questionId}-${idx}`} className="rounded-md border bg-muted/20 px-3 py-2">
<div className="flex items-start gap-3">
<div className="w-24 shrink-0 text-xs text-muted-foreground truncate">{item.studentName}</div>
<div className="min-w-0 flex-1 text-sm wrap-break-word whitespace-pre-wrap">
{formatAnswer(item.answerContent, selected)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</ScrollArea>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
export function HomeworkAssignmentQuestionErrorDetailsCard({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card className="md:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Details</CardTitle>
</CardHeader>
<CardContent className="p-0">
{questions.length === 0 || gradedSampleCount === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No data available.</div>
) : (
<ScrollArea className="h-72">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[70px]">Question</TableHead>
<TableHead className="text-right">Error Count</TableHead>
<TableHead className="text-right">Error Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questions.map((q, index) => (
<TableRow key={q.questionId}>
<TableCell className="text-sm">
<div className="font-medium">Q{index + 1}</div>
</TableCell>
<TableCell className="text-right text-sm tabular-nums">{q.errorCount}</TableCell>
<TableCell className="text-right text-sm tabular-nums">{(q.errorRate * 100).toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,134 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
function ErrorRateChart({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
const w = 100
const h = 60
const padL = 10
const padR = 3
const padT = 4
const padB = 10
const plotW = w - padL - padR
const plotH = h - padT - padB
const n = questions.length
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
const xFor = (i: number) => padL + (n <= 1 ? 0 : (i / (n - 1)) * plotW)
const yFor = (rate: number) => padT + (1 - clamp01(rate)) * plotH
const points = questions.map((q, i) => `${xFor(i)},${yFor(q.errorRate)}`).join(" ")
const areaD =
n === 0
? ""
: `M ${padL} ${padT + plotH} L ${points.split(" ").join(" L ")} L ${padL + plotW} ${padT + plotH} Z`
const gridYs = [
{ v: 1, label: "100%" },
{ v: 0.5, label: "50%" },
{ v: 0, label: "0%" },
]
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-full w-full">
{gridYs.map((g) => {
const y = yFor(g.v)
return (
<g key={g.label}>
<line x1={padL} y1={y} x2={padL + plotW} y2={y} className="stroke-border" strokeWidth={0.5} />
<text x={2} y={y + 1.2} className="fill-muted-foreground text-[3px]">
{g.label}
</text>
</g>
)
})}
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} className="stroke-border" strokeWidth={0.7} />
<line
x1={padL}
y1={padT + plotH}
x2={padL + plotW}
y2={padT + plotH}
className="stroke-border"
strokeWidth={0.7}
/>
{n >= 2 ? <path d={areaD} className="fill-primary/10" /> : null}
<polyline
points={points}
fill="none"
className="stroke-primary"
strokeWidth={1.2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{questions.map((q, i) => {
const cx = xFor(i)
const cy = yFor(q.errorRate)
const label = `Q${i + 1}`
return (
<g key={q.questionId}>
<circle cx={cx} cy={cy} r={1.2} className="fill-primary" />
<title>{`${label}: ${(q.errorRate * 100).toFixed(1)}% (${q.errorCount} / ${gradedSampleCount})`}</title>
</g>
)
})}
{questions.map((q, i) => {
if (n > 12 && i % 2 === 1) return null
const x = xFor(i)
return (
<text
key={`x-${q.questionId}`}
x={x}
y={h - 2}
textAnchor="middle"
className="fill-muted-foreground text-[3px]"
>
{i + 1}
</text>
)
})}
</svg>
)
}
export function HomeworkAssignmentQuestionErrorOverviewCard({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card className="md:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Overview</CardTitle>
</CardHeader>
<CardContent>
{questions.length === 0 || gradedSampleCount === 0 ? (
<div className="text-sm text-muted-foreground">
No graded submissions yet. Error analytics will appear here after grading.
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Graded students</span>
<span className="font-medium text-foreground">{gradedSampleCount}</span>
</div>
<div className="h-56 rounded-md border bg-muted/40 px-3 py-2">
<ErrorRateChart questions={questions} gradedSampleCount={gradedSampleCount} />
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Check, MessageSquarePlus, X } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -11,6 +12,7 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
import { gradeHomeworkSubmissionAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
@@ -44,12 +46,22 @@ export function HomeworkGradingView({
answers: initialAnswers,
}: HomeworkGradingViewProps) {
const router = useRouter()
const [answers, setAnswers] = useState(initialAnswers)
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
const [isSubmitting, setIsSubmitting] = useState(false)
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>({})
const handleScoreChange = (id: string, val: string) => {
const score = val === "" ? 0 : parseInt(val)
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score } : a)))
const handleManualScoreChange = (id: string, val: string) => {
const parsed = val === "" ? 0 : Number(val)
const nextScore = Number.isFinite(parsed) ? parsed : 0
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
}
const handleMarkCorrect = (id: string) => {
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: a.maxScore } : a)))
}
const handleMarkIncorrect = (id: string) => {
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: 0 } : a)))
}
const handleFeedbackChange = (id: string, val: string) => {
@@ -57,14 +69,18 @@ export function HomeworkGradingView({
}
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
const binaryAnswers = answers.filter(shouldUseBinaryGrading)
const correctCount = binaryAnswers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
const incorrectCount = binaryAnswers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
const ungradedCount = binaryAnswers.length - correctCount - incorrectCount
const handleSubmit = async () => {
setIsSubmitting(true)
const payload = answers.map((a) => ({
id: a.id,
score: a.score || 0,
feedback: a.feedback,
}))
const payload = answers.map((a) => {
const feedback =
typeof a.feedback === "string" && a.feedback.trim().length > 0 ? a.feedback.trim() : undefined
return { id: a.id, score: a.score || 0, feedback }
})
const formData = new FormData()
formData.set("submissionId", submissionId)
@@ -102,9 +118,7 @@ export function HomeworkGradingView({
<div className="rounded-md bg-muted/50 p-4">
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
<p className="text-sm font-medium">
{isRecord(ans.studentAnswer) && typeof ans.studentAnswer.answer === "string"
? ans.studentAnswer.answer
: JSON.stringify(ans.studentAnswer)}
{formatStudentAnswer(ans.studentAnswer)}
</p>
</div>
@@ -122,6 +136,21 @@ export function HomeworkGradingView({
<span className="text-muted-foreground">Total Score</span>
<span className="font-bold text-lg text-primary">{currentTotal}</span>
</div>
{binaryAnswers.length > 0 ? (
<div className="mt-3 flex items-center gap-2">
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-700">
Correct {correctCount}
</Badge>
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700">
Incorrect {incorrectCount}
</Badge>
{ungradedCount > 0 ? (
<Badge variant="outline" className="text-muted-foreground">
Ungraded {ungradedCount}
</Badge>
) : null}
</div>
) : null}
</div>
<ScrollArea className="flex-1 p-4">
@@ -129,32 +158,90 @@ export function HomeworkGradingView({
{answers.map((ans, index) => (
<Card key={ans.id} className="border-l-4 border-l-primary/20">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex justify-between">
Q{index + 1}
<span className="text-xs text-muted-foreground">Max: {ans.maxScore}</span>
</CardTitle>
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<span>Q{index + 1}</span>
<span className="text-xs text-muted-foreground font-normal">Max: {ans.maxScore}</span>
{shouldUseBinaryGrading(ans) ? (
<Badge
variant="outline"
className={getCorrectnessBadgeClassName(ans)}
>
{getCorrectnessLabel(ans)}
</Badge>
) : null}
</CardTitle>
<div className="flex items-center gap-1">
{shouldUseBinaryGrading(ans) ? (
<>
<Button
type="button"
variant="outline"
size="icon"
aria-label="mark correct"
className={getMarkCorrectButtonClassName(ans)}
onClick={() => handleMarkCorrect(ans.id)}
>
<Check />
</Button>
<Button
type="button"
variant="outline"
size="icon"
aria-label="mark incorrect"
className={getMarkIncorrectButtonClassName(ans)}
onClick={() => handleMarkIncorrect(ans.id)}
>
<X />
</Button>
</>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="add feedback"
className={getFeedbackIconButtonClassName(ans, showFeedbackByAnswerId[ans.id] ?? false)}
onClick={() =>
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: !(prev[ans.id] ?? false) }))
}
>
<MessageSquarePlus />
</Button>
</TooltipTrigger>
<TooltipContent>add feedback</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="py-3 px-4 space-y-3">
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleScoreChange(ans.id, e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`fb-${ans.id}`}>Feedback</Label>
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
/>
{!shouldUseBinaryGrading(ans) ? (
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
/>
</div>
) : null}
{(showFeedbackByAnswerId[ans.id] ?? false) ? (
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
/>
) : null}
</div>
</CardContent>
</Card>
@@ -171,3 +258,156 @@ export function HomeworkGradingView({
</div>
)
}
type ChoiceOption = { id?: unknown; isCorrect?: unknown }
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
const extractAnswerValue = (studentAnswer: unknown): unknown => {
if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer
return studentAnswer
}
const getChoiceCorrectIds = (content: QuestionContent | null): string[] => {
if (!content) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const ids: string[] = []
for (const item of raw) {
const opt = item as ChoiceOption
const id = typeof opt.id === "string" ? opt.id : null
const isCorrect = opt.isCorrect === true
if (id && isCorrect) ids.push(id)
}
return ids
}
const getTextCorrectAnswers = (content: QuestionContent | null): string[] => {
if (!content) return []
const raw = content.correctAnswer
if (typeof raw === "string") return [raw]
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
return []
}
const getJudgmentCorrectAnswer = (content: QuestionContent | null): boolean | null => {
if (!content) return null
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
}
const shouldUseBinaryGrading = (ans: Answer): boolean => {
if (ans.questionType === "single_choice") return true
if (ans.questionType === "multiple_choice") return true
if (ans.questionType === "judgment") return true
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
return false
}
const isAutoGradable = (ans: Answer): boolean => {
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
return false
}
const computeIsCorrect = (ans: Answer): boolean | null => {
const studentVal = extractAnswerValue(ans.studentAnswer)
if (ans.questionType === "single_choice") {
const correct = getChoiceCorrectIds(ans.questionContent)
if (correct.length === 0) return null
if (typeof studentVal !== "string") return false
return correct.includes(studentVal)
}
if (ans.questionType === "multiple_choice") {
const correct = getChoiceCorrectIds(ans.questionContent)
if (correct.length === 0) return null
const studentArr = Array.isArray(studentVal) ? studentVal.filter((x): x is string => typeof x === "string") : []
const correctSet = new Set(correct)
const studentSet = new Set(studentArr)
if (studentSet.size !== correctSet.size) return false
for (const id of correctSet) {
if (!studentSet.has(id)) return false
}
return true
}
if (ans.questionType === "judgment") {
const correct = getJudgmentCorrectAnswer(ans.questionContent)
if (correct === null) return null
if (typeof studentVal !== "boolean") return false
return studentVal === correct
}
if (ans.questionType === "text") {
const correctAnswers = getTextCorrectAnswers(ans.questionContent)
if (correctAnswers.length === 0) return null
if (typeof studentVal !== "string") return false
const normalizedStudent = normalizeText(studentVal)
return correctAnswers.some((c) => normalizeText(c) === normalizedStudent)
}
return null
}
const applyAutoGrades = (incoming: Answer[]): Answer[] => {
return incoming.map((a) => {
if (a.score !== null) return a
if (!isAutoGradable(a)) return a
const isCorrect = computeIsCorrect(a)
if (isCorrect === null) return a
return { ...a, score: isCorrect ? a.maxScore : 0 }
})
}
type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
const getCorrectnessState = (ans: Answer): CorrectnessState => {
if (ans.score === null) return "ungraded"
if (ans.score === ans.maxScore) return "correct"
if (ans.score === 0) return "incorrect"
return "partial"
}
const getCorrectnessLabel = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "Correct"
if (s === "incorrect") return "Incorrect"
if (s === "partial") return "Partial"
return "Ungraded"
}
const getCorrectnessBadgeClassName = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "border-emerald-200 bg-emerald-50 text-emerald-700"
if (s === "incorrect") return "border-red-200 bg-red-50 text-red-700"
if (s === "partial") return "border-amber-200 bg-amber-50 text-amber-800"
return "text-muted-foreground"
}
const getMarkCorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "correct"
return active ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100" : "text-muted-foreground"
}
const getMarkIncorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "incorrect"
return active ? "border-red-300 bg-red-50 text-red-700 hover:bg-red-100" : "text-muted-foreground"
}
const getFeedbackIconButtonClassName = (ans: Answer, isOpen: boolean): string => {
const hasFeedback = typeof ans.feedback === "string" && ans.feedback.trim().length > 0
if (isOpen) return "text-primary"
if (hasFeedback) return "text-primary/80"
return "text-muted-foreground"
}
const formatStudentAnswer = (studentAnswer: unknown): string => {
const v = extractAnswerValue(studentAnswer)
if (typeof v === "string") return v
if (typeof v === "boolean") return v ? "True" : "False"
if (Array.isArray(v)) return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ")
if (v == null) return "—"
return JSON.stringify(v)
}

View File

@@ -1,10 +1,11 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray, isNull, lte, or } from "drizzle-orm"
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classEnrollments,
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
@@ -15,13 +16,19 @@ import {
import type {
HomeworkAssignmentListItem,
HomeworkAssignmentReviewListItem,
HomeworkQuestionContent,
HomeworkAssignmentStatus,
HomeworkSubmissionDetails,
HomeworkSubmissionListItem,
HomeworkAssignmentAnalytics,
HomeworkAssignmentQuestionAnalytics,
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
StudentHomeworkTakeData,
StudentDashboardGradeProps,
StudentHomeworkScoreAnalytics,
StudentRanking,
} from "./types"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
@@ -31,11 +38,42 @@ const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
return v as HomeworkQuestionContent
}
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[] }) => {
const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
const ids = assignmentIds.filter((v) => v.trim().length > 0)
if (ids.length === 0) return new Map()
const rows = await db
.select({
assignmentId: homeworkAssignmentQuestions.assignmentId,
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, ids))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const map = new Map<string, number>()
for (const r of rows) map.set(r.assignmentId, Number(r.maxScore ?? 0))
return map
}
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string }) => {
const conditions = []
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
if (params?.ids && params.ids.length > 0) conditions.push(inArray(homeworkAssignments.id, params.ids))
if (params?.classId) {
const classStudentIds = db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(eq(classEnrollments.classId, params.classId))
const targetAssignmentIds = db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
}
const data = await db.query.homeworkAssignments.findMany({
where: conditions.length ? and(...conditions) : undefined,
@@ -64,9 +102,102 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
})
})
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string }) => {
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string }) => {
const creatorId = params.creatorId.trim()
if (!creatorId) return []
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.creatorId, creatorId),
orderBy: [desc(homeworkAssignments.createdAt)],
with: { sourceExam: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const targetCountRows = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId)
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submittedCountRows = await db
.select({
assignmentId: homeworkSubmissions.assignmentId,
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId)
const submittedCountByAssignmentId = new Map<string, number>()
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
const gradedCountRows = await db
.select({
assignmentId: homeworkSubmissions.assignmentId,
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
.groupBy(homeworkSubmissions.assignmentId)
const gradedCountByAssignmentId = new Map<string, number>()
for (const r of gradedCountRows) gradedCountByAssignmentId.set(r.assignmentId, Number(r.gradedCount ?? 0))
return assignments.map((a) => {
const item: HomeworkAssignmentReviewListItem = {
id: a.id,
title: a.title,
status: (a.status as HomeworkAssignmentReviewListItem["status"]) ?? "draft",
sourceExamTitle: a.sourceExam.title,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
targetCount: targetCountByAssignmentId.get(a.id) ?? 0,
submittedCount: submittedCountByAssignmentId.get(a.id) ?? 0,
gradedCount: gradedCountByAssignmentId.get(a.id) ?? 0,
createdAt: a.createdAt.toISOString(),
}
return item
})
})
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string }) => {
const conditions = []
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
if (params?.classId) {
const classStudentIds = db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(eq(classEnrollments.classId, params.classId))
const targetAssignmentIds = db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
conditions.push(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
}
if (params?.creatorId) {
const creatorAssignmentIds = db
.select({ assignmentId: homeworkAssignments.id })
.from(homeworkAssignments)
.where(eq(homeworkAssignments.creatorId, params.creatorId))
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
}
const data = await db.query.homeworkSubmissions.findMany({
where: conditions.length ? and(...conditions) : undefined,
@@ -112,6 +243,18 @@ export const getHomeworkAssignmentById = cache(async (id: string) => {
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, id))
const [submittedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"]))
)
const [gradedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, id), eq(homeworkSubmissions.status, "graded")))
return {
id: assignment.id,
title: assignment.title,
@@ -119,6 +262,7 @@ export const getHomeworkAssignmentById = cache(async (id: string) => {
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title,
structure: assignment.structure as unknown,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
@@ -126,11 +270,159 @@ export const getHomeworkAssignmentById = cache(async (id: string) => {
maxAttempts: assignment.maxAttempts,
targetCount: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
submittedCount: submittedRow?.c ?? 0,
gradedCount: gradedRow?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
}
})
export const getHomeworkAssignmentAnalytics = cache(
async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
with: {
sourceExam: true,
},
})
if (!assignment) return null
const [targetsRow] = await db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
const [submissionsRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
const [submittedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
eq(homeworkSubmissions.assignmentId, assignmentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
const [gradedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: { question: true },
orderBy: (q, { asc }) => [asc(q.order)],
})
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
for (const aq of assignmentQuestions) {
statsByQuestionId.set(aq.questionId, {
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
errorCount: 0,
errorRate: 0,
})
}
const gradedSubmissionsAll = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
with: {
answers: true,
student: true,
},
})
const latestByStudentId = new Map<string, (typeof gradedSubmissionsAll)[number]>()
for (const s of gradedSubmissionsAll) {
if (!latestByStudentId.has(s.studentId)) latestByStudentId.set(s.studentId, s)
}
const gradedSubmissions = Array.from(latestByStudentId.values())
const scoreBySubmissionQuestion = new Map<string, number>()
const answerBySubmissionQuestion = new Map<string, unknown>()
for (const sub of gradedSubmissions) {
for (const ans of sub.answers) {
const key = `${sub.id}|${ans.questionId}`
if (scoreBySubmissionQuestion.has(key)) continue
scoreBySubmissionQuestion.set(key, ans.score ?? 0)
const raw = ans.answerContent
if (isRecord(raw) && "answer" in raw) {
answerBySubmissionQuestion.set(key, raw.answer)
} else {
answerBySubmissionQuestion.set(key, raw)
}
}
}
const denom = gradedSubmissions.length
if (denom > 0) {
for (const q of statsByQuestionId.values()) {
if (q.maxScore <= 0) continue
let errors = 0
const wrongAnswers: Array<{ studentId: string; studentName: string; answerContent: unknown }> = []
for (const sub of gradedSubmissions) {
const key = `${sub.id}|${q.questionId}`
const score = scoreBySubmissionQuestion.get(key) ?? 0
if (score < q.maxScore) {
errors += 1
wrongAnswers.push({
studentId: sub.studentId,
studentName: sub.student.name || "Unknown",
answerContent: answerBySubmissionQuestion.get(key),
})
}
}
q.errorCount = errors
q.errorRate = errors / denom
q.wrongAnswers = wrongAnswers.slice(0, 500)
}
}
const questions: HomeworkAssignmentQuestionAnalytics[] = Array.from(statsByQuestionId.values())
.sort((a, b) => a.order - b.order)
const analytics: HomeworkAssignmentAnalytics = {
assignment: {
id: assignment.id,
title: assignment.title,
description: assignment.description,
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title,
structure: assignment.structure as unknown,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
targetCount: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
submittedCount: submittedRow?.c ?? 0,
gradedCount: gradedRow?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
},
gradedSampleCount: gradedSubmissions.length,
questions,
}
return analytics
}
)
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
@@ -332,3 +624,158 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
})),
}
})
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
const id = studentId.trim()
if (!id) return { trend: [], recent: [], ranking: null }
const targetAssignmentIdsRows = await db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, id))
const targetAssignmentIds = Array.from(new Set(targetAssignmentIdsRows.map((r) => r.assignmentId)))
if (targetAssignmentIds.length === 0) return { trend: [], recent: [], ranking: null }
const gradedSubmissions = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.studentId, id),
inArray(homeworkSubmissions.assignmentId, targetAssignmentIds),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
limit: 200,
})
const latestByAssignmentId = new Map<string, (typeof gradedSubmissions)[number]>()
for (const s of gradedSubmissions) {
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
}
const unique = Array.from(latestByAssignmentId.values()).sort((a, b) => {
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
return aTime - bTime
})
const trendSubmissions = unique.slice(-10)
const recentSubmissions = [...trendSubmissions].sort((a, b) => {
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
return bTime - aTime
})
const assignmentIds = Array.from(new Set(trendSubmissions.map((s) => s.assignmentId)))
const assignments = await db.query.homeworkAssignments.findMany({
where: inArray(homeworkAssignments.id, assignmentIds),
})
const titleByAssignmentId = new Map(assignments.map((a) => [a.id, a.title] as const))
const maxScoreByAssignmentId = await getAssignmentMaxScoreById(assignmentIds)
const toAnalytics = (s: (typeof trendSubmissions)[number]): StudentHomeworkScoreAnalytics => {
const maxScore = maxScoreByAssignmentId.get(s.assignmentId) ?? 0
const score = s.score ?? 0
const percentage = maxScore > 0 ? (score / maxScore) * 100 : 0
return {
assignmentId: s.assignmentId,
assignmentTitle: titleByAssignmentId.get(s.assignmentId) ?? "Untitled",
score,
maxScore,
percentage,
submittedAt: (s.submittedAt ?? s.updatedAt).toISOString(),
}
}
const trend = trendSubmissions.map(toAnalytics)
const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
const enrollment = await db.query.classEnrollments.findFirst({
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")),
orderBy: (e, { asc }) => [asc(e.createdAt)],
})
if (!enrollment) return { trend, recent, ranking: null }
const classStudents = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, enrollment.classId), eq(classEnrollments.status, "active")))
const classStudentIds = Array.from(new Set(classStudents.map((r) => r.studentId)))
const classSize = classStudentIds.length
if (classSize === 0) return { trend, recent, ranking: null }
const classAssignmentIdsRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
const classAssignmentIds = Array.from(new Set(classAssignmentIdsRows.map((r) => r.assignmentId)))
if (classAssignmentIds.length === 0) return { trend, recent, ranking: null }
const classMaxScoreByAssignmentId = await getAssignmentMaxScoreById(classAssignmentIds)
const classGradedSubmissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.studentId, classStudentIds),
inArray(homeworkSubmissions.assignmentId, classAssignmentIds),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
limit: 5000,
})
const latestByStudentAssignment = new Map<string, (typeof classGradedSubmissions)[number]>()
for (const s of classGradedSubmissions) {
const key = `${s.studentId}|${s.assignmentId}`
if (!latestByStudentAssignment.has(key)) latestByStudentAssignment.set(key, s)
}
const totalsByStudentId = new Map<string, { score: number; maxScore: number }>()
for (const sub of latestByStudentAssignment.values()) {
const maxScore = classMaxScoreByAssignmentId.get(sub.assignmentId) ?? 0
const score = sub.score ?? 0
const prev = totalsByStudentId.get(sub.studentId) ?? { score: 0, maxScore: 0 }
totalsByStudentId.set(sub.studentId, {
score: prev.score + score,
maxScore: prev.maxScore + maxScore,
})
}
const classUsers = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, classStudentIds))
const nameByStudentId = new Map(classUsers.map((u) => [u.id, u.name ?? "Student"] as const))
const myName = nameByStudentId.get(id) ?? "Student"
const ranked = classStudentIds
.map((studentId) => {
const totals = totalsByStudentId.get(studentId) ?? { score: 0, maxScore: 0 }
const percentage = totals.maxScore > 0 ? (totals.score / totals.maxScore) * 100 : 0
return { studentId, percentage, totals }
})
.sort((a, b) => {
if (b.percentage !== a.percentage) return b.percentage - a.percentage
return a.studentId.localeCompare(b.studentId)
})
const myIndex = ranked.findIndex((r) => r.studentId === id)
if (myIndex < 0) return { trend, recent, ranking: null }
const myTotals = ranked[myIndex]?.totals ?? { score: 0, maxScore: 0 }
const myPercentage = myTotals.maxScore > 0 ? (myTotals.score / myTotals.maxScore) * 100 : 0
const ranking: StudentRanking = {
studentId: id,
studentName: myName,
rank: myIndex + 1,
classSize,
totalScore: myTotals.score,
totalMaxScore: myTotals.maxScore,
percentage: myPercentage,
}
return { trend, recent, ranking }
})

View File

@@ -2,6 +2,7 @@ import { z } from "zod"
export const CreateHomeworkAssignmentSchema = z.object({
sourceExamId: z.string().min(1),
classId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
availableAt: z.string().optional(),
@@ -25,4 +26,3 @@ export const GradeHomeworkSchema = z.object({
})
),
})

View File

@@ -17,6 +17,18 @@ export interface HomeworkAssignmentListItem {
updatedAt: string
}
export interface HomeworkAssignmentReviewListItem {
id: string
title: string
status: HomeworkAssignmentStatus
sourceExamTitle: string
dueAt: string | null
targetCount: number
submittedCount: number
gradedCount: number
createdAt: string
}
export interface HomeworkSubmissionListItem {
id: string
assignmentId: string
@@ -68,6 +80,23 @@ export interface StudentHomeworkAssignmentListItem {
latestScore: number | null
}
export type StudentHomeworkPerformanceItem = {
assignmentId: string
title: string
gradedAt: string
score: number
maxScore: number
percent: number
rank: number
gradedCount: number
}
export type StudentHomeworkPerformanceSummary = {
averagePercent: number | null
bestPercent: number | null
latestRank: { rank: number; gradedCount: number; percent: number } | null
}
export type StudentHomeworkTakeQuestion = {
questionId: string
questionType: string
@@ -97,3 +126,64 @@ export type StudentHomeworkTakeData = {
} | null
questions: StudentHomeworkTakeQuestion[]
}
export type HomeworkAssignmentQuestionAnalytics = {
questionId: string
questionType: string
questionContent: HomeworkQuestionContent | null
maxScore: number
order: number
errorCount: number
errorRate: number
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown }>
}
export type HomeworkAssignmentAnalytics = {
assignment: {
id: string
title: string
description: string | null
status: HomeworkAssignmentStatus
sourceExamId: string
sourceExamTitle: string
structure: unknown | null
availableAt: string | null
dueAt: string | null
allowLate: boolean
lateDueAt: string | null
maxAttempts: number
targetCount: number
submissionCount: number
submittedCount: number
gradedCount: number
createdAt: string
updatedAt: string
}
gradedSampleCount: number
questions: HomeworkAssignmentQuestionAnalytics[]
}
export interface StudentHomeworkScoreAnalytics {
assignmentId: string
assignmentTitle: string
score: number
maxScore: number
percentage: number
submittedAt: string
}
export interface StudentRanking {
studentId: string
studentName: string
rank: number
classSize: number
totalScore: number
totalMaxScore: number
percentage: number
}
export interface StudentDashboardGradeProps {
trend: StudentHomeworkScoreAnalytics[]
recent: StudentHomeworkScoreAnalytics[]
ranking: StudentRanking | null
}

View File

@@ -3,6 +3,7 @@
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useSession } from "next-auth/react"
import { ChevronRight } from "lucide-react"
import {
@@ -17,13 +18,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { cn } from "@/shared/lib/utils"
import { useSidebar } from "./sidebar-provider"
import { NAV_CONFIG, Role } from "../config/navigation"
@@ -35,11 +29,10 @@ interface AppSidebarProps {
export function AppSidebar({ mode }: AppSidebarProps) {
const { expanded, toggleSidebar, isMobile } = useSidebar()
const pathname = usePathname()
// MOCK ROLE: In real app, get this from auth context / session
const [currentRole, setCurrentRole] = React.useState<Role>("admin")
const { data } = useSession()
const currentRole = (data?.user?.role ?? "teacher") as Role
const navItems = NAV_CONFIG[currentRole]
const navItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher
// Ensure consistent state for hydration
if (!expanded && mode === 'mobile') return null
@@ -62,26 +55,6 @@ export function AppSidebar({ mode }: AppSidebarProps) {
)}
</div>
{/* Role Switcher (Dev Only - for Demo) */}
{(expanded || isMobile) && (
<div className="px-4">
<label className="text-muted-foreground mb-2 block text-xs font-medium uppercase">
View As (Dev Mode)
</label>
<Select value={currentRole} onValueChange={(v) => setCurrentRole(v as Role)}>
<SelectTrigger>
<SelectValue placeholder="Select Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="teacher">Teacher</SelectItem>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="parent">Parent</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Navigation */}
<ScrollArea className="flex-1 px-3">
<nav className="flex flex-col gap-2 py-4">

View File

@@ -1,7 +1,6 @@
"use client"
import * as React from "react"
import { Menu } from "lucide-react"
import {
Sheet,

View File

@@ -1,7 +1,9 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { Bell, Menu, Search } from "lucide-react"
import { signOut } from "next-auth/react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
@@ -14,7 +16,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
@@ -78,7 +80,6 @@ export function SiteHeader() {
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative size-8 rounded-full">
<Avatar className="size-8">
<AvatarImage src="/avatars/01.png" alt="@user" />
<AvatarFallback>AD</AvatarFallback>
</Avatar>
</Button>
@@ -91,10 +92,20 @@ export function SiteHeader() {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/profile">Profile</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:bg-destructive/10">
<DropdownMenuItem
className="text-destructive focus:bg-destructive/10"
onSelect={(e) => {
e.preventDefault()
signOut({ callbackUrl: "/login" })
}}
>
Log out
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -37,8 +37,11 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
icon: Shield,
href: "/admin/school",
items: [
{ title: "Schools", href: "/admin/school/schools" },
{ title: "Grades", href: "/admin/school/grades" },
{ title: "Grade Insights", href: "/admin/school/grades/insights" },
{ title: "Departments", href: "/admin/school/departments" },
{ title: "Classrooms", href: "/admin/school/classrooms" },
{ title: "Classes", href: "/admin/school/classes" },
{ title: "Academic Year", href: "/admin/school/academic-year" },
]
},
@@ -82,7 +85,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{
title: "Dashboard",
icon: LayoutDashboard,
href: "/dashboard",
href: "/teacher/dashboard",
},
{
title: "Textbooks",
@@ -120,6 +123,8 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{ title: "My Classes", href: "/teacher/classes/my" },
{ title: "Students", href: "/teacher/classes/students" },
{ title: "Schedule", href: "/teacher/classes/schedule" },
{ title: "Insights", href: "/teacher/classes/insights" },
{ title: "Grade Insights", href: "/teacher/grades/insights" },
]
},
],
@@ -127,7 +132,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{
title: "Dashboard",
icon: LayoutDashboard,
href: "/dashboard",
href: "/student/dashboard",
},
{
title: "My Learning",
@@ -136,7 +141,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
items: [
{ title: "Courses", href: "/student/learning/courses" },
{ title: "Assignments", href: "/student/learning/assignments" },
{ title: "Grades", href: "/student/learning/grades" },
{ title: "Textbooks", href: "/student/learning/textbooks" },
]
},
{

View File

@@ -104,13 +104,11 @@ export async function createNestedQuestion(
};
} catch (error) {
console.error("Failed to create question:", error);
if (error instanceof Error) {
return {
success: false,
message: error.message || "Database error occurred",
};
return {
success: false,
message: error.message || "Database error occurred",
};
}
return {

View File

@@ -0,0 +1,291 @@
"use server"
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { academicYears, departments, grades, schools } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { UpsertAcademicYearSchema, UpsertDepartmentSchema, UpsertGradeSchema, UpsertSchoolSchema } from "./schema"
export async function createDepartmentAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertDepartmentSchema.parse({
name: formData.get("name"),
description: formData.get("description"),
})
await db.insert(departments).values({
id: createId(),
name: parsed.name,
description: parsed.description ?? null,
})
revalidatePath("/admin/school/departments")
return { success: true, message: "Department created" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to create department" }
}
}
export async function updateDepartmentAction(
departmentId: string,
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertDepartmentSchema.parse({
name: formData.get("name"),
description: formData.get("description"),
})
await db
.update(departments)
.set({
name: parsed.name,
description: parsed.description ?? null,
})
.where(eq(departments.id, departmentId))
revalidatePath("/admin/school/departments")
return { success: true, message: "Department updated" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to update department" }
}
}
export async function deleteDepartmentAction(departmentId: string): Promise<ActionState<string>> {
try {
await db.delete(departments).where(eq(departments.id, departmentId))
revalidatePath("/admin/school/departments")
return { success: true, message: "Department deleted" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to delete department" }
}
}
export async function createAcademicYearAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertAcademicYearSchema.parse({
name: formData.get("name"),
startDate: formData.get("startDate"),
endDate: formData.get("endDate"),
isActive: formData.get("isActive") ?? "false",
})
await db.transaction(async (tx) => {
if (parsed.isActive) {
await tx.update(academicYears).set({ isActive: false })
}
await tx.insert(academicYears).values({
id: createId(),
name: parsed.name,
startDate: new Date(parsed.startDate),
endDate: new Date(parsed.endDate),
isActive: parsed.isActive,
})
})
revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year created" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to create academic year" }
}
}
export async function updateAcademicYearAction(
academicYearId: string,
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertAcademicYearSchema.parse({
name: formData.get("name"),
startDate: formData.get("startDate"),
endDate: formData.get("endDate"),
isActive: formData.get("isActive") ?? "false",
})
await db.transaction(async (tx) => {
if (parsed.isActive) {
await tx.update(academicYears).set({ isActive: false })
}
await tx
.update(academicYears)
.set({
name: parsed.name,
startDate: new Date(parsed.startDate),
endDate: new Date(parsed.endDate),
isActive: parsed.isActive,
})
.where(eq(academicYears.id, academicYearId))
})
revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year updated" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to update academic year" }
}
}
export async function deleteAcademicYearAction(academicYearId: string): Promise<ActionState<string>> {
try {
await db.delete(academicYears).where(eq(academicYears.id, academicYearId))
revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year deleted" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to delete academic year" }
}
}
export async function createSchoolAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertSchoolSchema.parse({
name: formData.get("name"),
code: formData.get("code"),
})
await db.insert(schools).values({
id: createId(),
name: parsed.name,
code: parsed.code?.trim() ? parsed.code.trim() : null,
})
revalidatePath("/admin/school/schools")
return { success: true, message: "School created" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to create school" }
}
}
export async function updateSchoolAction(
schoolId: string,
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertSchoolSchema.parse({
name: formData.get("name"),
code: formData.get("code"),
})
await db
.update(schools)
.set({
name: parsed.name,
code: parsed.code?.trim() ? parsed.code.trim() : null,
})
.where(eq(schools.id, schoolId))
revalidatePath("/admin/school/schools")
return { success: true, message: "School updated" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to update school" }
}
}
export async function deleteSchoolAction(schoolId: string): Promise<ActionState<string>> {
try {
await db.delete(schools).where(eq(schools.id, schoolId))
revalidatePath("/admin/school/schools")
revalidatePath("/admin/school/grades")
return { success: true, message: "School deleted" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to delete school" }
}
}
export async function createGradeAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertGradeSchema.parse({
schoolId: formData.get("schoolId"),
name: formData.get("name"),
order: formData.get("order"),
gradeHeadId: formData.get("gradeHeadId"),
teachingHeadId: formData.get("teachingHeadId"),
})
await db.insert(grades).values({
id: createId(),
schoolId: parsed.schoolId,
name: parsed.name,
order: parsed.order,
gradeHeadId: parsed.gradeHeadId,
teachingHeadId: parsed.teachingHeadId,
})
revalidatePath("/admin/school/grades")
return { success: true, message: "Grade created" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to create grade" }
}
}
export async function updateGradeAction(
gradeId: string,
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertGradeSchema.parse({
schoolId: formData.get("schoolId"),
name: formData.get("name"),
order: formData.get("order"),
gradeHeadId: formData.get("gradeHeadId"),
teachingHeadId: formData.get("teachingHeadId"),
})
await db
.update(grades)
.set({
schoolId: parsed.schoolId,
name: parsed.name,
order: parsed.order,
gradeHeadId: parsed.gradeHeadId,
teachingHeadId: parsed.teachingHeadId,
})
.where(eq(grades.id, gradeId))
revalidatePath("/admin/school/grades")
return { success: true, message: "Grade updated" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to update grade" }
}
}
export async function deleteGradeAction(gradeId: string): Promise<ActionState<string>> {
try {
await db.delete(grades).where(eq(grades.id, gradeId))
revalidatePath("/admin/school/grades")
return { success: true, message: "Grade deleted" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to delete grade" }
}
}

View File

@@ -0,0 +1,315 @@
"use client"
import { useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import type { AcademicYearListItem } from "../types"
import { createAcademicYearAction, deleteAcademicYearAction, updateAcademicYearAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { formatDate } from "@/shared/lib/utils"
const toDateInput = (iso: string) => iso.slice(0, 10)
export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [createActive, setCreateActive] = useState(true)
const [editItem, setEditItem] = useState<AcademicYearListItem | null>(null)
const [editActive, setEditActive] = useState(false)
const [deleteItem, setDeleteItem] = useState<AcademicYearListItem | null>(null)
const activeYear = useMemo(() => years.find((y) => y.isActive) ?? null, [years])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("isActive", createActive ? "true" : "false")
const res = await createAcademicYearAction(undefined, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create academic year")
}
} catch {
toast.error("Failed to create academic year")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
formData.set("isActive", editActive ? "true" : "false")
const res = await updateAcademicYearAction(editItem.id, undefined, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update academic year")
}
} catch {
toast.error("Failed to update academic year")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteAcademicYearAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete academic year")
}
} catch {
toast.error("Failed to delete academic year")
} finally {
setIsWorking(false)
}
}
return (
<>
<div className="flex justify-end">
<Button
onClick={() => {
setCreateActive(activeYear === null)
setCreateOpen(true)
}}
disabled={isWorking}
>
<Plus className="mr-2 h-4 w-4" />
New academic year
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1 shadow-none">
<CardHeader>
<CardTitle className="text-base">Active year</CardTitle>
</CardHeader>
<CardContent>
{activeYear ? (
<div className="space-y-2">
<div className="text-lg font-semibold">{activeYear.name}</div>
<div className="text-sm text-muted-foreground">
{formatDate(activeYear.startDate)} {formatDate(activeYear.endDate)}
</div>
<Badge variant="secondary">Active</Badge>
</div>
) : (
<EmptyState
title="No active year"
description="Set one academic year as active."
className="h-auto border-none shadow-none"
/>
)}
</CardContent>
</Card>
<Card className="lg:col-span-2 shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All years</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{years.length}
</Badge>
</CardHeader>
<CardContent>
{years.length === 0 ? (
<EmptyState
title="No academic years"
description="Create an academic year to define school calendar."
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Range</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{years.map((y) => (
<TableRow key={y.id}>
<TableCell className="font-medium">{y.name}</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(y.startDate)} {formatDate(y.endDate)}
</TableCell>
<TableCell>{y.isActive ? <Badge variant="secondary">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditItem(y)
setEditActive(y.isActive)
}}
>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(y)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New academic year</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="e.g. 2025-2026" autoFocus />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startDate">Start date</Label>
<Input id="startDate" name="startDate" type="date" />
</div>
<div className="space-y-2">
<Label htmlFor="endDate">End date</Label>
<Input id="endDate" name="endDate" type="date" />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={createActive} onCheckedChange={(v) => setCreateActive(Boolean(v))} />
<Label className="cursor-pointer" onClick={() => setCreateActive((v) => !v)}>
Set as active
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editItem)} onOpenChange={(open) => {
if (!open) setEditItem(null)
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit academic year</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name</Label>
<Input id="edit-name" name="name" defaultValue={editItem.name} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-startDate">Start date</Label>
<Input id="edit-startDate" name="startDate" type="date" defaultValue={toDateInput(editItem.startDate)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-endDate">End date</Label>
<Input id="edit-endDate" name="endDate" type="date" defaultValue={toDateInput(editItem.endDate)} />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={editActive} onCheckedChange={(v) => setEditActive(Boolean(v))} />
<Label className="cursor-pointer" onClick={() => setEditActive((v) => !v)}>
Set as active
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deleteItem)} onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete academic year</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this academic year"}.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,247 @@
"use client"
import { useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import type { DepartmentListItem } from "../types"
import { createDepartmentAction, deleteDepartmentAction, updateDepartmentAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { formatDate } from "@/shared/lib/utils"
export function DepartmentsClient({ departments }: { departments: DepartmentListItem[] }) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<DepartmentListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<DepartmentListItem | null>(null)
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createDepartmentAction(undefined, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create department")
}
} catch {
toast.error("Failed to create department")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
const res = await updateDepartmentAction(editItem.id, undefined, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update department")
}
} catch {
toast.error("Failed to update department")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteDepartmentAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete department")
}
} catch {
toast.error("Failed to delete department")
} finally {
setIsWorking(false)
}
}
return (
<>
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
<Plus className="mr-2 h-4 w-4" />
New department
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All departments</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{departments.length}
</Badge>
</CardHeader>
<CardContent>
{departments.length === 0 ? (
<EmptyState
title="No departments"
description="Create your first department to get started."
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{departments.map((d) => (
<TableRow key={d.id}>
<TableCell className="font-medium">{d.name}</TableCell>
<TableCell className="text-muted-foreground">{d.description || "-"}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(d.updatedAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(d)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(d)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New department</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="e.g. Mathematics" autoFocus />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" name="description" placeholder="Optional" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editItem)} onOpenChange={(open) => {
if (!open) setEditItem(null)
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit department</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name</Label>
<Input id="edit-name" name="name" defaultValue={editItem.name} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea id="edit-description" name="description" defaultValue={editItem.description || ""} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deleteItem)} onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete department</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {deleteItem?.name || "this department"}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,779 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { parseAsString, useQueryState } from "nuqs"
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
type FormState = {
schoolId: string
name: string
order: string
gradeHeadId: string
teachingHeadId: string
}
const toFormState = (item: GradeListItem | null, fallbackSchoolId: string): FormState => ({
schoolId: item?.school.id ?? fallbackSchoolId,
name: item?.name ?? "",
order: String(item?.order ?? 0),
gradeHeadId: item?.gradeHead?.id ?? "",
teachingHeadId: item?.teachingHead?.id ?? "",
})
type FormErrors = Partial<Record<keyof FormState, string>>
const normalizeName = (v: string) => v.trim().replace(/\s+/g, " ")
const NONE_SELECT_VALUE = "__none__"
const parseOrder = (raw: string) => {
const v = raw.trim()
if (!v) return 0
const n = Number(v)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return null
return n
}
const validateForm = (
state: FormState,
params: { grades: GradeListItem[]; excludeGradeId?: string }
): { ok: boolean; errors: FormErrors } => {
const errors: FormErrors = {}
const schoolId = state.schoolId.trim()
if (!schoolId) errors.schoolId = "请选择学校"
const name = normalizeName(state.name)
if (!name) errors.name = "请输入年级名称"
if (name.length > 100) errors.name = "年级名称最多 100 个字符"
const order = parseOrder(state.order)
if (order === null) errors.order = "Order 必须是非负整数"
if (schoolId && name) {
const dup = params.grades.find((g) => {
if (params.excludeGradeId && g.id === params.excludeGradeId) return false
return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase()
})
if (dup) errors.name = "该学校下已存在同名年级"
}
return { ok: Object.keys(errors).length === 0, errors }
}
const formatStaffDetail = (u: StaffOption | null) => {
if (!u) return <Badge variant="outline"></Badge>
return (
<div className="min-w-0">
<div className="truncate">{u.name}</div>
<div className="truncate text-xs text-muted-foreground">{u.email}</div>
</div>
)
}
export function GradesClient({
grades,
schools,
staff,
}: {
grades: GradeListItem[]
schools: SchoolListItem[]
staff: StaffOption[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<GradeListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<GradeListItem | null>(null)
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
const [school, setSchool] = useQueryState("school", parseAsString.withDefault("all"))
const [head, setHead] = useQueryState("head", parseAsString.withDefault("all"))
const [sort, setSort] = useQueryState("sort", parseAsString.withDefault("default"))
const defaultSchoolId = useMemo(() => schools[0]?.id ?? "", [schools])
const [createState, setCreateState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
const [editState, setEditState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
useEffect(() => {
if (!createOpen) return
if (createState.schoolId.trim().length > 0) return
if (!defaultSchoolId) return
setCreateState((p) => ({ ...p, schoolId: defaultSchoolId }))
}, [createOpen, createState.schoolId, defaultSchoolId])
useEffect(() => {
if (!editItem) return
if (editState.schoolId.trim().length > 0) return
if (!defaultSchoolId) return
setEditState((p) => ({ ...p, schoolId: defaultSchoolId }))
}, [editItem, editState.schoolId, defaultSchoolId])
const staffOptions = useMemo(() => {
return [...staff].sort((a, b) => {
const byName = a.name.localeCompare(b.name)
if (byName !== 0) return byName
return a.email.localeCompare(b.email)
})
}, [staff])
const filteredGrades = useMemo(() => {
const needle = q.trim().toLowerCase()
const bySchool = school === "all" ? "" : school
return grades
.filter((g) => {
if (bySchool && g.school.id !== bySchool) return false
if (head === "missing") {
if (g.gradeHead || g.teachingHead) return false
}
if (head === "missing_grade_head") {
if (g.gradeHead) return false
}
if (head === "missing_teaching_head") {
if (g.teachingHead) return false
}
if (!needle) return true
const hay = [
g.name,
g.school.name,
g.gradeHead?.name ?? "",
g.gradeHead?.email ?? "",
g.teachingHead?.name ?? "",
g.teachingHead?.email ?? "",
]
.join(" ")
.toLowerCase()
return hay.includes(needle)
})
.sort((a, b) => {
if (sort === "updated_desc") return b.updatedAt.localeCompare(a.updatedAt)
if (sort === "updated_asc") return a.updatedAt.localeCompare(b.updatedAt)
if (sort === "name_asc") return a.name.localeCompare(b.name)
if (sort === "name_desc") return b.name.localeCompare(a.name)
if (sort === "order_asc") return a.order - b.order
if (sort === "order_desc") return b.order - a.order
return 0
})
}, [grades, head, q, school, sort])
const hasFilters = q.length > 0 || school !== "all" || head !== "all" || sort !== "default"
const openEdit = (item: GradeListItem) => {
setEditItem(item)
setEditState(toFormState(item, defaultSchoolId))
}
const openCreate = () => {
setCreateState(toFormState(null, defaultSchoolId))
setCreateOpen(true)
}
const createValidation = useMemo(() => validateForm(createState, { grades }), [createState, grades])
const editValidation = useMemo(
() => validateForm(editState, { grades, excludeGradeId: editItem?.id }),
[editItem?.id, editState, grades]
)
const isEditDirty = useMemo(() => {
if (!editItem) return false
const next = {
schoolId: editState.schoolId.trim(),
name: normalizeName(editState.name),
order: parseOrder(editState.order),
gradeHeadId: editState.gradeHeadId || "",
teachingHeadId: editState.teachingHeadId || "",
}
const prev = {
schoolId: editItem.school.id,
name: normalizeName(editItem.name),
order: editItem.order,
gradeHeadId: editItem.gradeHead?.id ?? "",
teachingHeadId: editItem.teachingHead?.id ?? "",
}
return (
next.schoolId !== prev.schoolId ||
next.name !== prev.name ||
(typeof next.order === "number" ? next.order : null) !== prev.order ||
next.gradeHeadId !== prev.gradeHeadId ||
next.teachingHeadId !== prev.teachingHeadId
)
}, [editItem, editState])
const handleCreate = async () => {
const validation = validateForm(createState, { grades })
if (!validation.ok) {
toast.error(Object.values(validation.errors)[0] || "请完善表单信息")
return
}
setIsWorking(true)
try {
const fd = new FormData()
fd.set("schoolId", createState.schoolId)
fd.set("name", normalizeName(createState.name))
fd.set("order", createState.order)
fd.set("gradeHeadId", createState.gradeHeadId)
fd.set("teachingHeadId", createState.teachingHeadId)
const res = await createGradeAction(undefined, fd)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create grade")
}
} catch {
toast.error("Failed to create grade")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async () => {
if (!editItem) return
const validation = validateForm(editState, { grades, excludeGradeId: editItem.id })
if (!validation.ok) {
toast.error(Object.values(validation.errors)[0] || "请完善表单信息")
return
}
if (!isEditDirty) {
toast.message("没有可保存的变更")
return
}
setIsWorking(true)
try {
const fd = new FormData()
fd.set("schoolId", editState.schoolId)
fd.set("name", normalizeName(editState.name))
fd.set("order", editState.order)
fd.set("gradeHeadId", editState.gradeHeadId)
fd.set("teachingHeadId", editState.teachingHeadId)
const res = await updateGradeAction(editItem.id, undefined, fd)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update grade")
}
} catch {
toast.error("Failed to update grade")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteGradeAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete grade")
}
} catch {
toast.error("Failed to delete grade")
} finally {
setIsWorking(false)
}
}
return (
<>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1 md:max-w-sm">
<Input placeholder="搜索年级/学校/组长..." value={q} onChange={(e) => setQ(e.target.value || null)} />
</div>
<Select value={school} onValueChange={(v) => setSchool(v === "all" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder="学校" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={head} onValueChange={(v) => setHead(v === "all" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder="负责人" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="missing"></SelectItem>
<SelectItem value="missing_grade_head"></SelectItem>
<SelectItem value="missing_teaching_head"></SelectItem>
</SelectContent>
</Select>
<Select value={sort} onValueChange={(v) => setSort(v === "default" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder="排序" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="updated_desc"></SelectItem>
<SelectItem value="updated_asc"></SelectItem>
<SelectItem value="name_asc">AZ</SelectItem>
<SelectItem value="name_desc">ZA</SelectItem>
<SelectItem value="order_asc">Order</SelectItem>
<SelectItem value="order_desc">Order</SelectItem>
</SelectContent>
</Select>
{hasFilters ? (
<Button
variant="outline"
onClick={() => {
setQ(null)
setSchool(null)
setHead(null)
setSort(null)
}}
>
</Button>
) : null}
</div>
<Button onClick={openCreate} disabled={isWorking || schools.length === 0}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base"></CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="tabular-nums">
{filteredGrades.length}
</Badge>
{filteredGrades.length !== grades.length ? (
<div className="text-xs text-muted-foreground tabular-nums">/ {grades.length}</div>
) : null}
</div>
</CardHeader>
<CardContent>
{schools.length === 0 ? (
<EmptyState
title="暂无学校"
description="请先创建学校,再在学校下创建年级。"
className="h-auto border-none shadow-none"
/>
) : filteredGrades.length === 0 ? (
<EmptyState
title={grades.length === 0 ? "暂无年级" : "没有匹配结果"}
description={grades.length === 0 ? "创建年级以便管理负责人和班级。" : "尝试修改筛选条件或清空搜索。"}
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Order</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{filteredGrades.map((g) => (
<TableRow key={g.id}>
<TableCell className="text-muted-foreground">{g.school.name}</TableCell>
<TableCell className="font-medium">{g.name}</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{g.order}</TableCell>
<TableCell className="text-muted-foreground">{formatStaffDetail(g.gradeHead)}</TableCell>
<TableCell className="text-muted-foreground">{formatStaffDetail(g.teachingHead)}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(g.updatedAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`)
}
>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openEdit(g)}>
<Pencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(g)}
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
void handleCreate()
}}
>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">School</Label>
<div className="col-span-3">
<Select
value={createState.schoolId}
onValueChange={(v) => setCreateState((p) => ({ ...p, schoolId: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a school" />
</SelectTrigger>
<SelectContent>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createValidation.errors.schoolId ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{createValidation.errors.schoolId}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade-name" className="text-right">
Grade
</Label>
<Input
id="create-grade-name"
className="col-span-3"
value={createState.name}
onChange={(e) => setCreateState((p) => ({ ...p, name: e.target.value }))}
placeholder="e.g. Grade 10"
autoFocus
/>
{createValidation.errors.name ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{createValidation.errors.name}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade-order" className="text-right">
Order
</Label>
<Input
id="create-grade-order"
className="col-span-3"
type="number"
inputMode="numeric"
min={0}
step={1}
value={createState.order}
onChange={(e) => setCreateState((p) => ({ ...p, order: e.target.value }))}
/>
{createValidation.errors.order ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{createValidation.errors.order}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select
value={createState.gradeHeadId}
onValueChange={(v) =>
setCreateState((p) => ({ ...p, gradeHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select
value={createState.teachingHeadId}
onValueChange={(v) =>
setCreateState((p) => ({ ...p, teachingHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(open) => {
if (!open) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{editItem ? (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
void handleUpdate()
}}
>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">School</Label>
<div className="col-span-3">
<Select
value={editState.schoolId}
onValueChange={(v) => setEditState((p) => ({ ...p, schoolId: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a school" />
</SelectTrigger>
<SelectContent>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editValidation.errors.schoolId ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{editValidation.errors.schoolId}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade-name" className="text-right">
Grade
</Label>
<Input
id="edit-grade-name"
className="col-span-3"
value={editState.name}
onChange={(e) => setEditState((p) => ({ ...p, name: e.target.value }))}
/>
{editValidation.errors.name ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{editValidation.errors.name}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade-order" className="text-right">
Order
</Label>
<Input
id="edit-grade-order"
className="col-span-3"
type="number"
inputMode="numeric"
min={0}
step={1}
value={editState.order}
onChange={(e) => setEditState((p) => ({ ...p, order: e.target.value }))}
/>
{editValidation.errors.order ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{editValidation.errors.order}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select
value={editState.gradeHeadId}
onValueChange={(v) =>
setEditState((p) => ({ ...p, gradeHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select
value={editState.teachingHeadId}
onValueChange={(v) =>
setEditState((p) => ({ ...p, teachingHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription> {deleteItem?.name || "该年级"}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,253 @@
"use client"
import { useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import type { SchoolListItem } from "../types"
import { createSchoolAction, deleteSchoolAction, updateSchoolAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { formatDate } from "@/shared/lib/utils"
export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<SchoolListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<SchoolListItem | null>(null)
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createSchoolAction(undefined, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create school")
}
} catch {
toast.error("Failed to create school")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
const res = await updateSchoolAction(editItem.id, undefined, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update school")
}
} catch {
toast.error("Failed to update school")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteSchoolAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete school")
}
} catch {
toast.error("Failed to delete school")
} finally {
setIsWorking(false)
}
}
return (
<>
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
<Plus className="mr-2 h-4 w-4" />
New school
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All schools</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{schools.length}
</Badge>
</CardHeader>
<CardContent>
{schools.length === 0 ? (
<EmptyState
title="No schools"
description="Create your first school to get started."
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{schools.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.code || "-"}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(s.updatedAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(s)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(s)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New school</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="e.g. First Primary School" autoFocus />
</div>
<div className="space-y-2">
<Label htmlFor="code">Code</Label>
<Input id="code" name="code" placeholder="Optional" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(open) => {
if (!open) setEditItem(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit school</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name</Label>
<Input id="edit-name" name="name" defaultValue={editItem.name} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-code">Code</Label>
<Input id="edit-code" name="code" defaultValue={editItem.code || ""} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete school</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {deleteItem?.name || "this school"} and its grades.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,183 @@
import "server-only"
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 type { AcademicYearListItem, DepartmentListItem, GradeListItem, SchoolListItem, StaffOption } from "./types"
const toIso = (d: Date) => d.toISOString()
export const getDepartments = cache(async (): Promise<DepartmentListItem[]> => {
try {
const rows = await db.select().from(departments).orderBy(asc(departments.name))
return rows.map((r) => ({
id: r.id,
name: r.name,
description: r.description ?? null,
createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt),
}))
} catch {
return []
}
})
export const getAcademicYears = cache(async (): Promise<AcademicYearListItem[]> => {
try {
const rows = await db.select().from(academicYears).orderBy(asc(academicYears.startDate))
return rows.map((r) => ({
id: r.id,
name: r.name,
startDate: toIso(r.startDate),
endDate: toIso(r.endDate),
isActive: Boolean(r.isActive),
createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt),
}))
} catch {
return []
}
})
export const getSchools = cache(async (): Promise<SchoolListItem[]> => {
try {
const rows = await db.select().from(schools).orderBy(asc(schools.name))
return rows.map((r) => ({
id: r.id,
name: r.name,
code: r.code ?? null,
createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt),
}))
} catch {
return []
}
})
export const getGrades = cache(async (): Promise<GradeListItem[]> => {
try {
const rows = await db
.select({
id: grades.id,
name: grades.name,
order: grades.order,
schoolId: schools.id,
schoolName: schools.name,
gradeHeadId: grades.gradeHeadId,
teachingHeadId: grades.teachingHeadId,
createdAt: grades.createdAt,
updatedAt: grades.updatedAt,
})
.from(grades)
.innerJoin(schools, eq(schools.id, grades.schoolId))
.orderBy(asc(schools.name), asc(grades.order), asc(grades.name))
const headIds = Array.from(
new Set(
rows
.flatMap((r) => [r.gradeHeadId, r.teachingHeadId])
.filter((v): v is string => typeof v === "string" && v.length > 0)
)
)
const heads = headIds.length
? await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(inArray(users.id, headIds))
: []
const headById = new Map<string, StaffOption>()
for (const u of heads) {
headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email })
}
return rows.map((r) => ({
id: r.id,
school: { id: r.schoolId, name: r.schoolName },
name: r.name,
order: Number(r.order ?? 0),
gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null,
teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null,
createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt),
}))
} catch {
return []
}
})
export const getStaffOptions = cache(async (): Promise<StaffOption[]> => {
try {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(inArray(users.role, ["teacher", "admin"]))
.orderBy(asc(users.name), asc(users.email))
return rows.map((r) => ({
id: r.id,
name: r.name ?? "Unnamed",
email: r.email,
}))
} catch {
return []
}
})
export const getGradesForStaff = cache(async (staffId: string): Promise<GradeListItem[]> => {
const id = staffId.trim()
if (!id) return []
try {
const rows = await db
.select({
id: grades.id,
name: grades.name,
order: grades.order,
schoolId: schools.id,
schoolName: schools.name,
gradeHeadId: grades.gradeHeadId,
teachingHeadId: grades.teachingHeadId,
createdAt: grades.createdAt,
updatedAt: grades.updatedAt,
})
.from(grades)
.innerJoin(schools, eq(schools.id, grades.schoolId))
.where(or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id)))
.orderBy(asc(schools.name), asc(grades.order), asc(grades.name))
const headIds = Array.from(
new Set(
rows
.flatMap((r) => [r.gradeHeadId, r.teachingHeadId])
.filter((v): v is string => typeof v === "string" && v.length > 0)
)
)
const heads = headIds.length
? await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(inArray(users.id, headIds))
: []
const headById = new Map<string, StaffOption>()
for (const u of heads) headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email })
return rows.map((r) => ({
id: r.id,
school: { id: r.schoolId, name: r.schoolName },
name: r.name,
order: Number(r.order ?? 0),
gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null,
teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null,
createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt),
}))
} catch {
return []
}
})

View File

@@ -0,0 +1,52 @@
import { z } from "zod"
export const UpsertDepartmentSchema = z.object({
name: z.string().trim().min(1).max(255),
description: z.string().trim().max(5000).optional().nullable(),
})
export const UpsertAcademicYearSchema = z
.object({
name: z.string().trim().min(1).max(100),
startDate: z.string().min(1),
endDate: z.string().min(1),
isActive: z.union([z.literal("on"), z.literal("true"), z.literal("false"), z.string()]).optional(),
})
.transform((v) => ({
name: v.name,
startDate: v.startDate,
endDate: v.endDate,
isActive: v.isActive === "on" || v.isActive === "true",
}))
.refine((v) => new Date(v.startDate).getTime() <= new Date(v.endDate).getTime(), {
message: "startDate must be before endDate",
})
export const UpsertSchoolSchema = z.object({
name: z.string().trim().min(1).max(255),
code: z.string().trim().max(50).optional().nullable(),
})
export const UpsertGradeSchema = z
.object({
schoolId: z.string().trim().min(1),
name: z.string().trim().min(1).max(100),
order: z.union([z.string(), z.number()]).optional().nullable(),
gradeHeadId: z.string().trim().optional().nullable(),
teachingHeadId: z.string().trim().optional().nullable(),
})
.transform((v) => ({
schoolId: v.schoolId,
name: v.name,
order:
typeof v.order === "number"
? v.order
: typeof v.order === "string" && v.order.trim().length > 0
? Number(v.order)
: 0,
gradeHeadId: v.gradeHeadId && v.gradeHeadId.length > 0 ? v.gradeHeadId : null,
teachingHeadId: v.teachingHeadId && v.teachingHeadId.length > 0 ? v.teachingHeadId : null,
}))
.refine((v) => Number.isFinite(v.order) && Number.isInteger(v.order) && v.order >= 0, {
message: "order must be a non-negative integer",
})

View File

@@ -0,0 +1,42 @@
export type DepartmentListItem = {
id: string
name: string
description: string | null
createdAt: string
updatedAt: string
}
export type AcademicYearListItem = {
id: string
name: string
startDate: string
endDate: string
isActive: boolean
createdAt: string
updatedAt: string
}
export type SchoolListItem = {
id: string
name: string
code: string | null
createdAt: string
updatedAt: string
}
export type StaffOption = {
id: string
name: string
email: string
}
export type GradeListItem = {
id: string
school: { id: string; name: string }
name: string
order: number
gradeHead: StaffOption | null
teachingHead: StaffOption | null
createdAt: string
updatedAt: string
}

View File

@@ -0,0 +1,158 @@
"use client"
import Link from "next/link"
import { Shield, User, Building2, Lock } from "lucide-react"
import { signOut } from "next-auth/react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Separator } from "@/shared/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { ThemePreferencesCard } from "./theme-preferences-card"
export function AdminSettingsView() {
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<div className="text-sm text-muted-foreground">Manage admin preferences and system defaults.</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/admin/dashboard">Back to dashboard</Link>
</Button>
</div>
</div>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="general" className="gap-2">
<User className="h-4 w-4" />
General
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Shield className="h-4 w-4" />
Appearance
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Lock className="h-4 w-4" />
Security
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Basic profile information for this admin account.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" defaultValue="Admin User" disabled />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" defaultValue="admin@nextedu.com" disabled />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input id="role" defaultValue="admin" className="tabular-nums" disabled />
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Input id="status" defaultValue="active" disabled />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Organization</CardTitle>
<CardDescription>School identity shown across admin surfaces.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="schoolName">School name</Label>
<Input id="schoolName" defaultValue="Next_Edu School" disabled />
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Input id="timezone" defaultValue="System default" disabled />
</div>
</div>
<Separator className="my-6" />
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-background">
<Building2 className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 space-y-1">
<div className="text-sm font-medium">Managed in School Management</div>
<div className="text-sm text-muted-foreground">
Departments, classes, and academic year settings live under the School Management section.
</div>
</div>
<Button asChild variant="outline" className="shrink-0">
<Link href="/admin/school/departments">Open</Link>
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="appearance" className="mt-6 space-y-6">
<ThemePreferencesCard />
</TabsContent>
<TabsContent value="security" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Session</CardTitle>
<CardDescription>Account access and session controls.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Sign out</div>
<div className="text-sm text-muted-foreground">Return to the login screen.</div>
</div>
<Button variant="outline" onClick={() => signOut({ callbackUrl: "/login" })}>
Log out
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Danger zone</CardTitle>
<CardDescription>Destructive actions are disabled in demo mode.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-background">
<Shield className="h-4 w-4 text-destructive" />
</div>
<div className="flex-1 space-y-1">
<div className="text-sm font-medium">Reset system</div>
<div className="text-sm text-muted-foreground">
This action would clear all data and cannot be undone.
</div>
</div>
<Button variant="destructive" disabled className="shrink-0">
Reset
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,136 @@
"use client"
import Link from "next/link"
import { User, Palette, Lock, LayoutDashboard, PenTool, CalendarDays } from "lucide-react"
import { signOut } from "next-auth/react"
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
type SettingsUser = {
id?: string | null
name?: string | null
email?: string | null
role?: string | null
}
export function StudentSettingsView({ user }: { user: SettingsUser }) {
const role = "student"
const name = user.name ?? "-"
const email = user.email ?? "-"
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<div className="text-sm text-muted-foreground">Manage your preferences and account access.</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/student/dashboard">Back to dashboard</Link>
</Button>
</div>
</div>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="general" className="gap-2">
<User className="h-4 w-4" />
General
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Palette className="h-4 w-4" />
Appearance
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Lock className="h-4 w-4" />
Security
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Signed-in user details from session.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input id="role" value={role} className="tabular-nums" disabled />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Quick links</CardTitle>
<CardDescription>Common places you may want to visit.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/profile">Profile</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/student/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/student/learning/assignments">
<PenTool className="h-4 w-4" />
Assignments
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/student/schedule">
<CalendarDays className="h-4 w-4" />
Schedule
</Link>
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="appearance" className="mt-6 space-y-6">
<ThemePreferencesCard />
</TabsContent>
<TabsContent value="security" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Session</CardTitle>
<CardDescription>Account access and session controls.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Sign out</div>
<div className="text-sm text-muted-foreground">Return to the login screen.</div>
</div>
<Button variant="outline" onClick={() => signOut({ callbackUrl: "/login" })}>
Log out
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import Link from "next/link"
import { User, Palette, Lock, LayoutDashboard, PenTool, CalendarDays, Library, FileQuestion } from "lucide-react"
import { signOut } from "next-auth/react"
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
type SettingsUser = {
id?: string | null
name?: string | null
email?: string | null
role?: string | null
}
export function TeacherSettingsView({ user }: { user: SettingsUser }) {
const role = "teacher"
const name = user.name ?? "-"
const email = user.email ?? "-"
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<div className="text-sm text-muted-foreground">Manage your preferences and teaching workspace.</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/dashboard">Back to dashboard</Link>
</Button>
</div>
</div>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="general" className="gap-2">
<User className="h-4 w-4" />
General
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Palette className="h-4 w-4" />
Appearance
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Lock className="h-4 w-4" />
Security
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Signed-in user details from session.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input id="role" value={role} className="tabular-nums" disabled />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Quick links</CardTitle>
<CardDescription>Jump to common teacher areas.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/profile">Profile</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/textbooks">
<Library className="h-4 w-4" />
Textbooks
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/exams/all">
<FileQuestion className="h-4 w-4" />
Exams
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/homework/assignments">
<PenTool className="h-4 w-4" />
Homework
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/classes/schedule">
<CalendarDays className="h-4 w-4" />
Schedule
</Link>
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="appearance" className="mt-6 space-y-6">
<ThemePreferencesCard />
</TabsContent>
<TabsContent value="security" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Session</CardTitle>
<CardDescription>Account access and session controls.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Sign out</div>
<div className="text-sm text-muted-foreground">Return to the login screen.</div>
</div>
<Button variant="outline" onClick={() => signOut({ callbackUrl: "/login" })}>
Log out
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,60 @@
"use client"
import { Monitor, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
type ThemeChoice = "system" | "light" | "dark"
export function ThemePreferencesCard() {
const { theme, setTheme } = useTheme()
const value: ThemeChoice = theme === "light" || theme === "dark" || theme === "system" ? theme : "system"
return (
<Card>
<CardHeader>
<CardTitle>Theme</CardTitle>
<CardDescription>Choose how the admin console looks on this device.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:max-w-md">
<div className="space-y-2">
<Label htmlFor="theme">Color theme</Label>
<Select value={value} onValueChange={(v) => setTheme(v)}>
<SelectTrigger id="theme" suppressHydrationWarning>
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-muted-foreground" />
System
</div>
</SelectItem>
<SelectItem value="light">
<div className="flex items-center gap-2">
<Sun className="h-4 w-4 text-muted-foreground" />
Light
</div>
</SelectItem>
<SelectItem value="dark">
<div className="flex items-center gap-2">
<Moon className="h-4 w-4 text-muted-foreground" />
Dark
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,156 @@
"use client"
import Link from "next/link"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { BookOpen, Building2, Inbox } from "lucide-react"
import type { StudentEnrolledClass } from "@/modules/classes/types"
import { joinClassByInvitationCodeAction } from "@/modules/classes/actions"
export function StudentCoursesView({
classes,
}: {
classes: StudentEnrolledClass[]
}) {
const router = useRouter()
const [code, setCode] = useState("")
const [isWorking, setIsWorking] = useState(false)
const handleJoin = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await joinClassByInvitationCodeAction(null, formData)
if (res.success) {
toast.success(res.message || "Joined class")
setCode("")
router.refresh()
} else {
toast.error(res.message || "Failed to join class")
}
} catch {
toast.error("Failed to join class")
} finally {
setIsWorking(false)
}
}
if (classes.length === 0) {
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Join a class</CardTitle>
</CardHeader>
<CardContent>
<form action={handleJoin} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
<div className="space-y-2">
<Label htmlFor="join-invitation-code">Invitation code</Label>
<Input
id="join-invitation-code"
name="code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6-digit code"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
/>
</div>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Joining..." : "Join"}
</Button>
</form>
</CardContent>
</Card>
<EmptyState
icon={Inbox}
title="No courses"
description="You are not enrolled in any class yet."
className="h-80"
/>
</div>
)
}
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Join a class</CardTitle>
</CardHeader>
<CardContent>
<form action={handleJoin} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
<div className="space-y-2">
<Label htmlFor="join-invitation-code">Invitation code</Label>
<Input
id="join-invitation-code"
name="code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6-digit code"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
/>
</div>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Joining..." : "Join"}
</Button>
</form>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{classes.map((c) => (
<Card key={c.id} className="overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-base font-semibold leading-none">{c.name}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<BookOpen className="h-3 w-3" />
Grade {c.grade}
</span>
{c.homeroom ? (
<Badge variant="outline" className="font-normal">
{c.homeroom}
</Badge>
) : null}
{c.room ? (
<span className="inline-flex items-center gap-1">
<Building2 className="h-3 w-3" />
{c.room}
</span>
) : null}
</div>
</div>
<Badge variant="secondary" className="shrink-0">
Enrolled
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-3 pt-2">
<div className="text-sm text-muted-foreground">Open schedule for this class.</div>
<Button asChild variant="outline" size="sm">
<Link href={`/student/schedule?classId=${encodeURIComponent(c.id)}`}>Schedule</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
"use client"
import { useMemo } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { StudentEnrolledClass } from "@/modules/classes/types"
export function StudentScheduleFilters({ classes }: { classes: StudentEnrolledClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
const options = useMemo(() => [{ id: "all", name: "All classes" }, ...classes], [classes])
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="w-full sm:w-60">
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select class" />
</SelectTrigger>
<SelectContent>
{options.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,89 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { CalendarX, Clock, MapPin } from "lucide-react"
import type { StudentScheduleItem } from "@/modules/classes/types"
const WEEKDAYS: Array<{ key: 1 | 2 | 3 | 4 | 5 | 6 | 7; label: string }> = [
{ key: 1, label: "Mon" },
{ key: 2, label: "Tue" },
{ key: 3, label: "Wed" },
{ key: 4, label: "Thu" },
{ key: 5, label: "Fri" },
{ key: 6, label: "Sat" },
{ key: 7, label: "Sun" },
]
export function StudentScheduleView({ items }: { items: StudentScheduleItem[] }) {
if (items.length === 0) {
return (
<EmptyState
icon={CalendarX}
title="No schedule"
description="No timetable entries found for your enrolled classes."
className="h-80"
/>
)
}
const itemsByDay = new Map<number, StudentScheduleItem[]>()
for (const item of items) {
const list = itemsByDay.get(item.weekday) ?? []
list.push(item)
itemsByDay.set(item.weekday, list)
}
for (const [day, list] of itemsByDay) {
list.sort((a, b) => a.startTime.localeCompare(b.startTime))
itemsByDay.set(day, list)
}
return (
<div className="grid gap-4 lg:grid-cols-2">
{WEEKDAYS.map((d) => {
const dayItems = itemsByDay.get(d.key) ?? []
return (
<Card key={d.key}>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">{d.label}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{dayItems.length === 0 ? (
<div className="text-sm text-muted-foreground">No classes.</div>
) : (
dayItems.map((item) => (
<div
key={item.id}
className="flex items-start justify-between gap-3 rounded-md border bg-card p-3"
>
<div className="min-w-0 space-y-1">
<div className="font-medium leading-none truncate">{item.course}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="inline-flex items-center">
<Clock className="mr-1 h-3 w-3" />
<span className="tabular-nums">
{item.startTime}{item.endTime}
</span>
</div>
{item.location ? (
<div className="inline-flex items-center min-w-0">
<MapPin className="mr-1 h-3 w-3 shrink-0" />
<span className="truncate">{item.location}</span>
</div>
) : null}
</div>
</div>
<Badge variant="secondary" className="shrink-0">
{item.className}
</Badge>
</div>
))
)}
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -47,8 +47,7 @@ export async function createTextbookAction(
success: true,
message: "Textbook created successfully.",
};
} catch (error) {
console.error("Failed to create textbook:", error);
} catch {
return {
success: false,
message: "Failed to create textbook.",
@@ -83,8 +82,7 @@ export async function updateTextbookAction(
success: true,
message: "Textbook updated successfully.",
};
} catch (error) {
console.error("Failed to update textbook:", error);
} catch {
return {
success: false,
message: "Failed to update textbook.",
@@ -102,8 +100,7 @@ export async function deleteTextbookAction(
success: true,
message: "Textbook deleted successfully.",
};
} catch (error) {
console.error("Failed to delete textbook:", error);
} catch {
return {
success: false,
message: "Failed to delete textbook.",
@@ -130,7 +127,7 @@ export async function createChapterAction(
});
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter created successfully" };
} catch (error) {
} catch {
return { success: false, message: "Failed to create chapter" };
}
}
@@ -144,7 +141,7 @@ export async function updateChapterContentAction(
await updateChapterContent({ chapterId, content });
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Content updated successfully" };
} catch (error) {
} catch {
return { success: false, message: "Failed to update content" };
}
}
@@ -157,7 +154,7 @@ export async function deleteChapterAction(
await deleteChapter(chapterId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter deleted successfully" };
} catch (error) {
} catch {
return { success: false, message: "Failed to delete chapter" };
}
}
@@ -177,7 +174,7 @@ export async function createKnowledgePointAction(
await createKnowledgePoint({ name, description, chapterId });
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point created successfully" };
} catch (error) {
} catch {
return { success: false, message: "Failed to create knowledge point" };
}
}
@@ -190,7 +187,7 @@ export async function deleteKnowledgePointAction(
await deleteKnowledgePoint(kpId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point deleted successfully" };
} catch (error) {
} catch {
return { success: false, message: "Failed to delete knowledge point" };
}
}

View File

@@ -1,5 +1,8 @@
"use client"
import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import {
Dialog,
DialogContent,
@@ -32,10 +35,12 @@ export function ChapterContentViewer({
Reading Mode
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 pr-4">
<ScrollArea className="flex-1 pr-4 min-h-0">
<div className="prose prose-sm dark:prose-invert max-w-none">
{chapter.content ? (
<div className="whitespace-pre-wrap">{chapter.content}</div>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
{chapter.content}
</ReactMarkdown>
) : (
<div className="flex h-40 items-center justify-center text-muted-foreground italic">
No content available for this chapter.

View File

@@ -1,7 +1,7 @@
"use client"
import { useState } from "react"
import { ChevronRight, FileText, Folder, MoreHorizontal, Eye, Edit } from "lucide-react"
import { ChevronRight, FileText, Folder, MoreHorizontal, Eye } from "lucide-react"
import { Chapter } from "../types"
import {
Collapsible,
@@ -22,9 +22,10 @@ interface ChapterItemProps {
chapter: Chapter
level?: number
onView: (chapter: Chapter) => void
showActions?: boolean
}
function ChapterItem({ chapter, level = 0, onView }: ChapterItemProps) {
function ChapterItem({ chapter, level = 0, onView, showActions = true }: ChapterItemProps) {
const [isOpen, setIsOpen] = useState(false)
const hasChildren = chapter.children && chapter.children.length > 0
@@ -65,28 +66,26 @@ function ChapterItem({ chapter, level = 0, onView }: ChapterItemProps) {
)}
<span className="truncate">{chapter.title}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-auto h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity focus:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(chapter)}>
<Eye className="mr-2 h-4 w-4" />
View Content
</DropdownMenuItem>
<DropdownMenuItem>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{showActions ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-auto h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity focus:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(chapter)}>
<Eye className="mr-2 h-4 w-4" />
View Content
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
@@ -94,7 +93,13 @@ function ChapterItem({ chapter, level = 0, onView }: ChapterItemProps) {
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<div className="pt-1">
{chapter.children!.map((child) => (
<ChapterItem key={child.id} chapter={child} level={level + 1} onView={onView} />
<ChapterItem
key={child.id}
chapter={child}
level={level + 1}
onView={onView}
showActions={showActions}
/>
))}
</div>
</CollapsibleContent>
@@ -104,7 +109,7 @@ function ChapterItem({ chapter, level = 0, onView }: ChapterItemProps) {
)
}
export function ChapterList({ chapters }: { chapters: Chapter[] }) {
export function ChapterList({ chapters, showActions }: { chapters: Chapter[]; showActions?: boolean }) {
const [viewingChapter, setViewingChapter] = useState<Chapter | null>(null)
const [isViewerOpen, setIsViewerOpen] = useState(false)
@@ -117,7 +122,7 @@ export function ChapterList({ chapters }: { chapters: Chapter[] }) {
<>
<div className="space-y-1">
{chapters.map((chapter) => (
<ChapterItem key={chapter.id} chapter={chapter} onView={handleView} />
<ChapterItem key={chapter.id} chapter={chapter} onView={handleView} showActions={showActions} />
))}
</div>

View File

@@ -1,7 +1,8 @@
"use client"
import { useState } from "react"
import { ChevronRight, FileText, Folder, MoreHorizontal } from "lucide-react"
import { ChevronRight, FileText, Folder, MoreHorizontal, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Chapter } from "../types"
import {
Collapsible,
@@ -9,20 +10,55 @@ import {
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { cn } from "@/shared/lib/utils"
import { CreateChapterDialog } from "./create-chapter-dialog"
import { deleteChapterAction } from "../actions"
interface ChapterItemProps {
chapter: Chapter
level?: number
selectedId?: string
onSelect: (chapter: Chapter) => void
textbookId: string
}
function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemProps) {
function ChapterItem({ chapter, level = 0, selectedId, onSelect, textbookId }: ChapterItemProps) {
const [isOpen, setIsOpen] = useState(false)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const hasChildren = chapter.children && chapter.children.length > 0
const isSelected = chapter.id === selectedId
const handleDelete = async () => {
setIsDeleting(true)
const res = await deleteChapterAction(chapter.id, textbookId)
setIsDeleting(false)
if (res.success) {
toast.success(res.message)
setShowDeleteDialog(false)
} else {
toast.error(res.message)
}
}
return (
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
@@ -53,7 +89,7 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemPr
<div
className={cn(
"flex flex-1 items-center gap-2 px-2 py-1.5 text-sm cursor-pointer",
"flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 text-sm cursor-pointer",
level === 0 ? "font-medium" : "text-muted-foreground",
isSelected && "text-accent-foreground font-medium"
)}
@@ -64,19 +100,33 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemPr
) : (
<FileText className="h-4 w-4 text-muted-foreground/50" />
)}
<span className="truncate">{chapter.title}</span>
<span className="truncate flex-1 min-w-0">{chapter.title}</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
// Dropdown menu logic here
}}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-auto h-6 w-6 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity focus:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setShowCreateDialog(true)}
>
<Plus />
Add Subchapter
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => setShowDeleteDialog(true)}>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
@@ -90,12 +140,42 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemPr
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
textbookId={textbookId}
/>
))}
</div>
</CollapsibleContent>
)}
</Collapsible>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete chapter?</AlertDialogTitle>
<AlertDialogDescription>
This will delete this chapter and all its subchapters and linked knowledge points.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CreateChapterDialog
textbookId={textbookId}
parentId={chapter.id}
trigger={null}
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
/>
</div>
)
}
@@ -103,11 +183,13 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemPr
export function ChapterSidebarList({
chapters,
selectedChapterId,
onSelectChapter
onSelectChapter,
textbookId,
}: {
chapters: Chapter[],
selectedChapterId?: string,
onSelectChapter: (chapter: Chapter) => void
textbookId: string
}) {
return (
<div className="space-y-1">
@@ -117,6 +199,7 @@ export function ChapterSidebarList({
chapter={chapter}
selectedId={selectedChapterId}
onSelect={onSelectChapter}
textbookId={textbookId}
/>
))}
</div>

View File

@@ -30,11 +30,15 @@ function SubmitButton() {
interface CreateChapterDialogProps {
textbookId: string
parentId?: string
trigger?: React.ReactNode
trigger?: React.ReactNode | null
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function CreateChapterDialog({ textbookId, parentId, trigger }: CreateChapterDialogProps) {
const [open, setOpen] = useState(false)
export function CreateChapterDialog({ textbookId, parentId, trigger, open: controlledOpen, onOpenChange }: CreateChapterDialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const open = controlledOpen ?? uncontrolledOpen
const setOpen = onOpenChange ?? setUncontrolledOpen
const handleSubmit = async (formData: FormData) => {
const result = await createChapterAction(textbookId, parentId, null, formData)
@@ -46,15 +50,18 @@ export function CreateChapterDialog({ textbookId, parentId, trigger }: CreateCha
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
const triggerNode =
trigger === null
? null
: trigger || (
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Plus className="h-4 w-4" />
</Button>
)}
</DialogTrigger>
)
return (
<Dialog open={open} onOpenChange={setOpen}>
{triggerNode ? <DialogTrigger asChild>{triggerNode}</DialogTrigger> : null}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Chapter</DialogTitle>

View File

@@ -1,7 +1,8 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Tag, Trash2 } from "lucide-react"
import { KnowledgePoint } from "../types"
@@ -9,6 +10,16 @@ import { CreateKnowledgePointDialog } from "./create-knowledge-point-dialog"
import { deleteKnowledgePointAction } from "../actions"
import { toast } from "sonner"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
interface KnowledgePointPanelProps {
knowledgePoints: KnowledgePoint[]
@@ -21,15 +32,33 @@ export function KnowledgePointPanel({
selectedChapterId,
textbookId
}: KnowledgePointPanelProps) {
const router = useRouter()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<KnowledgePoint | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this knowledge point?")) return
const result = await deleteKnowledgePointAction(id, textbookId)
if (result.success) {
toast.success(result.message)
} else {
toast.error(result.message)
const requestDelete = (kp: KnowledgePoint) => {
setDeleteTarget(kp)
setShowDeleteDialog(true)
}
const handleDelete = async () => {
if (!deleteTarget) return
setIsDeleting(true)
try {
const result = await deleteKnowledgePointAction(deleteTarget.id, textbookId)
if (result.success) {
toast.success(result.message)
setShowDeleteDialog(false)
setDeleteTarget(null)
router.refresh()
} else {
toast.error(result.message)
}
} catch {
toast.error("Failed to delete knowledge point")
} finally {
setIsDeleting(false)
}
}
@@ -53,7 +82,7 @@ export function KnowledgePointPanel({
)}
</div>
<ScrollArea className="flex-1 -mx-2 px-2">
<ScrollArea className="flex-1 min-h-0 -mx-2 px-2">
{selectedChapterId ? (
chapterKPs.length > 0 ? (
<div className="space-y-3">
@@ -74,8 +103,8 @@ export function KnowledgePointPanel({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10 -mt-1 -mr-1"
onClick={() => handleDelete(kp.id)}
className="h-6 w-6 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10 -mt-1 -mr-1"
onClick={() => requestDelete(kp)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -95,6 +124,40 @@ export function KnowledgePointPanel({
</div>
)}
</ScrollArea>
<AlertDialog
open={showDeleteDialog}
onOpenChange={(open) => {
if (isDeleting) return
setShowDeleteDialog(open)
if (!open) setDeleteTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete knowledge point?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget ? (
<>
This will permanently delete <span className="font-medium text-foreground">{deleteTarget.name}</span>.
</>
) : (
"This will permanently delete the selected knowledge point."
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -13,11 +13,13 @@ import { Textbook } from "../types";
interface TextbookCardProps {
textbook: Textbook;
hrefBase?: string;
}
export function TextbookCard({ textbook }: TextbookCardProps) {
export function TextbookCard({ textbook, hrefBase }: TextbookCardProps) {
const base = hrefBase || "/teacher/textbooks";
return (
<Link href={`/teacher/textbooks/${textbook.id}`} className="block h-full">
<Link href={`${base}/${textbook.id}`} className="block h-full">
<Card
className={cn(
"group h-full overflow-hidden transition-all duration-300 ease-out",

View File

@@ -1,12 +1,15 @@
"use client"
import { useState } from "react"
import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { Chapter, KnowledgePoint } from "../types"
import { ChapterSidebarList } from "./chapter-sidebar-list"
import { KnowledgePointPanel } from "./knowledge-point-panel"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button"
import { Edit2, Save, Plus } from "lucide-react"
import { Edit2, Save } from "lucide-react"
import { CreateChapterDialog } from "./create-chapter-dialog"
import { updateChapterContentAction } from "../actions"
import { toast } from "sonner"
@@ -54,17 +57,18 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
<h3 className="font-semibold">Chapters</h3>
<CreateChapterDialog textbookId={textbookId} />
</div>
<ScrollArea className="flex-1 -mx-2 px-2">
<ScrollArea className="flex-1 px-2">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedChapter?.id}
onSelectChapter={handleSelectChapter}
textbookId={textbookId}
/>
</ScrollArea>
</div>
{/* Middle: Content Viewer/Editor (6 cols) */}
<div className="col-span-6 flex flex-col h-full px-2">
<div className="col-span-6 flex flex-col h-full px-2 min-h-0">
{selectedChapter ? (
<>
<div className="flex items-center justify-between mb-4 pb-2 border-b">
@@ -89,8 +93,8 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-4 h-full">
<ScrollArea className="flex-1 min-h-0">
<div className="p-4 min-h-full">
{isEditing ? (
<Textarea
className="min-h-[500px] font-mono text-sm"
@@ -101,7 +105,9 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
{selectedChapter.content ? (
<div className="whitespace-pre-wrap">{selectedChapter.content}</div>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
{selectedChapter.content}
</ReactMarkdown>
) : (
<div className="text-muted-foreground italic py-8 text-center">
No content available. Click edit to add content.

View File

@@ -0,0 +1,82 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { Search, Filter, X } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
export function TextbookFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
const hasFilters = Boolean(search || subject !== "all" || grade !== "all")
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between bg-card p-4 rounded-lg border shadow-sm">
<div className="relative w-full md:w-96">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search textbooks..."
className="pl-9 bg-background"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background">
<Filter className="mr-2 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="Subject" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
<SelectItem value="Mathematics">Mathematics</SelectItem>
<SelectItem value="Physics">Physics</SelectItem>
<SelectItem value="Chemistry">Chemistry</SelectItem>
<SelectItem value="English">English</SelectItem>
<SelectItem value="History">History</SelectItem>
</SelectContent>
</Select>
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background">
<SelectValue placeholder="Grade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Grades</SelectItem>
<SelectItem value="Grade 9">Grade 9</SelectItem>
<SelectItem value="Grade 10">Grade 10</SelectItem>
<SelectItem value="Grade 11">Grade 11</SelectItem>
<SelectItem value="Grade 12">Grade 12</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setSubject(null)
setGrade(null)
}}
className="h-10 px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,156 @@
"use client"
import { useMemo, useState } from "react"
import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { useQueryState, parseAsString } from "nuqs"
import { ChevronRight, FileText, Folder } from "lucide-react"
import type { Chapter } from "../types"
import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button"
function buildChapterIndex(chapters: Chapter[]) {
const index = new Map<string, Chapter>()
const walk = (nodes: Chapter[]) => {
for (const node of nodes) {
index.set(node.id, node)
if (node.children && node.children.length > 0) walk(node.children)
}
}
walk(chapters)
return index
}
function ReaderChapterItem({
chapter,
level = 0,
selectedId,
onSelect,
}: {
chapter: Chapter
level?: number
selectedId: string | null
onSelect: (chapterId: string) => void
}) {
const hasChildren = Boolean(chapter.children && chapter.children.length > 0)
const [open, setOpen] = useState(level === 0)
const isSelected = selectedId === chapter.id
return (
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
<div
className={cn(
"flex items-center group py-1 rounded-md transition-colors",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
)}
>
{hasChildren ? (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
onClick={() => setOpen((v) => !v)}
>
<ChevronRight className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-90")} />
</Button>
) : (
<div className="w-6 shrink-0" />
)}
<button
type="button"
className={cn(
"flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 text-sm text-left cursor-pointer",
level === 0 ? "font-medium" : "text-muted-foreground",
isSelected && "text-accent-foreground font-medium"
)}
onClick={() => onSelect(chapter.id)}
>
{hasChildren ? (
<Folder className={cn("h-4 w-4", open ? "text-primary" : "text-muted-foreground/70")} />
) : (
<FileText className="h-4 w-4 text-muted-foreground/50" />
)}
<span className="truncate flex-1 min-w-0">{chapter.title}</span>
</button>
</div>
{hasChildren && open ? (
<div className="pt-1">
{chapter.children!.map((child) => (
<ReaderChapterItem
key={child.id}
chapter={child}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
)
}
export function TextbookReader({ chapters }: { chapters: Chapter[] }) {
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
const index = useMemo(() => buildChapterIndex(chapters), [chapters])
const selected = chapterId ? index.get(chapterId) ?? null : null
const selectedId = selected?.id ?? null
const handleSelect = (id: string) => setChapterId(id)
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<h3 className="font-semibold">Chapters</h3>
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
<div className="space-y-1">
{chapters.map((chapter) => (
<ReaderChapterItem
key={chapter.id}
chapter={chapter}
selectedId={selectedId}
onSelect={handleSelect}
/>
))}
</div>
</ScrollArea>
</div>
<div className="lg:col-span-8 flex flex-col min-h-0">
{selected ? (
<>
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
<div className="p-4 min-h-full">
{selected.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>{selected.content}</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center">No content available.</div>
)}
</div>
</ScrollArea>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
Select a chapter to start reading.
</div>
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
"use client"
import { useState } from "react"
import { Edit, Trash2 } from "lucide-react"
import { Edit } from "lucide-react"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {

View File

@@ -1,226 +1,396 @@
import { Textbook, Chapter, CreateTextbookInput, CreateChapterInput, UpdateChapterContentInput, KnowledgePoint, CreateKnowledgePointInput, UpdateTextbookInput } from "./types";
import "server-only"
// Mock Data (Moved from data/mock-data.ts and enhanced)
let MOCK_TEXTBOOKS: Textbook[] = [
// ... (previous textbooks remain same, keeping for brevity)
{
id: "tb_01",
title: "Advanced Mathematics Grade 10",
subject: "Mathematics",
grade: "Grade 10",
publisher: "Next Education Press",
createdAt: new Date(),
updatedAt: new Date(),
_count: { chapters: 12 },
},
// ... (other textbooks)
];
import { cache } from "react"
import { and, asc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
let MOCK_CHAPTERS: Chapter[] = [
// ... (previous chapters)
{
id: "ch_01",
textbookId: "tb_01",
title: "Chapter 1: Real Numbers",
order: 1,
parentId: null,
content: "# Chapter 1: Real Numbers\n\nIn this chapter, we will explore the properties of real numbers...",
createdAt: new Date(),
updatedAt: new Date(),
children: [
{
id: "ch_01_01",
textbookId: "tb_01",
title: "1.1 Introduction to Real Numbers",
order: 1,
parentId: "ch_01",
content: "## 1.1 Introduction\n\nReal numbers include rational and irrational numbers.",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
];
import { db } from "@/shared/db"
import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema"
import type {
Chapter,
CreateChapterInput,
CreateKnowledgePointInput,
CreateTextbookInput,
KnowledgePoint,
Textbook,
UpdateChapterContentInput,
UpdateTextbookInput,
} from "./types"
let MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [
{
id: "kp_01",
name: "Real Numbers",
description: "Definition and properties of real numbers",
level: 1,
order: 1,
chapterId: "ch_01",
},
{
id: "kp_02",
name: "Rational Numbers",
description: "Numbers that can be expressed as a fraction",
level: 2,
order: 1,
chapterId: "ch_01_01",
const normalizeOptional = (v: string | null | undefined) => {
const trimmed = v?.trim()
if (!trimmed) return null
return trimmed
}
const sortChapters = (a: Chapter, b: Chapter) => {
const ao = a.order ?? 0
const bo = b.order ?? 0
if (ao !== bo) return ao - bo
return a.title.localeCompare(b.title)
}
const buildChapterTree = (rows: Chapter[]): Chapter[] => {
const byId = new Map<string, Chapter & { children: Chapter[] }>()
for (const ch of rows) {
byId.set(ch.id, { ...ch, children: [] })
}
];
// ... (existing imports and mock data)
const roots: Array<Chapter & { children: Chapter[] }> = []
for (const ch of byId.values()) {
const pid = ch.parentId
if (pid && byId.has(pid)) {
byId.get(pid)!.children.push(ch)
} else {
roots.push(ch)
}
}
export async function getTextbooks(query?: string, subject?: string, grade?: string): Promise<Textbook[]> {
await new Promise((resolve) => setTimeout(resolve, 500));
const results = [...MOCK_TEXTBOOKS];
// ... (filtering logic)
return results;
const sortRecursive = (nodes: Array<Chapter & { children: Chapter[] }>) => {
nodes.sort(sortChapters)
for (const n of nodes) {
sortRecursive(n.children as Array<Chapter & { children: Chapter[] }>)
}
}
sortRecursive(roots)
return roots
}
export async function getTextbookById(id: string): Promise<Textbook | undefined> {
await new Promise((resolve) => setTimeout(resolve, 300));
return MOCK_TEXTBOOKS.find((t) => t.id === id);
}
export const getTextbooks = cache(async (query?: string, subject?: string, grade?: string): Promise<Textbook[]> => {
const conditions: SQL[] = []
export async function getChaptersByTextbookId(textbookId: string): Promise<Chapter[]> {
await new Promise((resolve) => setTimeout(resolve, 300));
return MOCK_CHAPTERS.filter((c) => c.textbookId === textbookId);
}
const q = query?.trim()
if (q) {
const needle = `%${q}%`
conditions.push(
or(
like(textbooks.title, needle),
like(textbooks.subject, needle),
like(textbooks.grade, needle),
like(textbooks.publisher, needle)
)!
)
}
const s = subject?.trim()
if (s && s !== "all") conditions.push(eq(textbooks.subject, s))
const g = grade?.trim()
if (g && g !== "all") conditions.push(eq(textbooks.grade, g))
const rows = await db
.select({
id: textbooks.id,
title: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
publisher: textbooks.publisher,
createdAt: textbooks.createdAt,
updatedAt: textbooks.updatedAt,
chaptersCount: sql<number>`COUNT(${chapters.id})`,
})
.from(textbooks)
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
.where(conditions.length ? and(...conditions) : undefined)
.groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt)
.orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade))
return rows.map((r) => ({
id: r.id,
title: r.title,
subject: r.subject,
grade: r.grade,
publisher: r.publisher,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
_count: { chapters: Number(r.chaptersCount ?? 0) },
}))
})
export const getTextbookById = cache(async (id: string): Promise<Textbook | undefined> => {
const [row] = await db
.select({
id: textbooks.id,
title: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
publisher: textbooks.publisher,
createdAt: textbooks.createdAt,
updatedAt: textbooks.updatedAt,
chaptersCount: sql<number>`COUNT(${chapters.id})`,
})
.from(textbooks)
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
.where(eq(textbooks.id, id))
.groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt)
.limit(1)
if (!row) return undefined
return {
id: row.id,
title: row.title,
subject: row.subject,
grade: row.grade,
publisher: row.publisher,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
_count: { chapters: Number(row.chaptersCount ?? 0) },
}
})
export const getChaptersByTextbookId = cache(async (textbookId: string): Promise<Chapter[]> => {
const rows = await db
.select({
id: chapters.id,
textbookId: chapters.textbookId,
title: chapters.title,
order: chapters.order,
parentId: chapters.parentId,
content: chapters.content,
createdAt: chapters.createdAt,
updatedAt: chapters.updatedAt,
})
.from(chapters)
.where(eq(chapters.textbookId, textbookId))
.orderBy(asc(chapters.order), asc(chapters.title))
return buildChapterTree(
rows.map((r) => ({
id: r.id,
textbookId: r.textbookId,
title: r.title,
order: r.order,
parentId: r.parentId,
content: r.content ?? null,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}))
)
})
export async function createTextbook(data: CreateTextbookInput): Promise<Textbook> {
await new Promise((resolve) => setTimeout(resolve, 800));
const newTextbook: Textbook = {
id: `tb_${Math.random().toString(36).substr(2, 9)}`,
...data,
createdAt: new Date(),
updatedAt: new Date(),
const id = createId()
const now = new Date()
const row = {
id,
title: data.title.trim(),
subject: data.subject.trim(),
grade: normalizeOptional(data.grade),
publisher: normalizeOptional(data.publisher),
createdAt: now,
updatedAt: now,
}
await db.insert(textbooks).values(row)
return {
...row,
_count: { chapters: 0 },
};
MOCK_TEXTBOOKS = [newTextbook, ...MOCK_TEXTBOOKS];
return newTextbook;
}
}
export async function updateTextbook(data: UpdateTextbookInput): Promise<Textbook> {
await new Promise((resolve) => setTimeout(resolve, 800));
const index = MOCK_TEXTBOOKS.findIndex((t) => t.id === data.id);
if (index === -1) throw new Error("Textbook not found");
const updatedTextbook = {
...MOCK_TEXTBOOKS[index],
...data,
updatedAt: new Date(),
};
MOCK_TEXTBOOKS[index] = updatedTextbook;
return updatedTextbook;
await db
.update(textbooks)
.set({
title: data.title.trim(),
subject: data.subject.trim(),
grade: normalizeOptional(data.grade),
publisher: normalizeOptional(data.publisher),
})
.where(eq(textbooks.id, data.id))
const updated = await getTextbookById(data.id)
if (!updated) throw new Error("Textbook not found")
return updated
}
export async function deleteTextbook(id: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 800));
MOCK_TEXTBOOKS = MOCK_TEXTBOOKS.filter((t) => t.id !== id);
await db.delete(textbooks).where(eq(textbooks.id, id))
}
// ... (rest of the file)
export async function createChapter(data: CreateChapterInput): Promise<Chapter> {
await new Promise((resolve) => setTimeout(resolve, 500));
const newChapter: Chapter = {
id: `ch_${Math.random().toString(36).substr(2, 9)}`,
textbookId: data.textbookId,
title: data.title,
order: data.order || 0,
parentId: data.parentId || null,
content: "",
createdAt: new Date(),
updatedAt: new Date(),
children: []
};
const id = createId()
const now = new Date()
// Logic to add to nested structure (simplified for mock: add to root or find parent)
// For deep nesting in mock, we'd need recursive search.
// Here we just push to root or try to find parent in top level for simplicity of demo.
if (data.parentId) {
const parent = MOCK_CHAPTERS.find(c => c.id === data.parentId);
if (parent) {
if (!parent.children) parent.children = [];
parent.children.push(newChapter);
} else {
// Try searching one level deep
for (const ch of MOCK_CHAPTERS) {
if (ch.children) {
const subParent = ch.children.find(c => c.id === data.parentId);
if (subParent) {
if (!subParent.children) subParent.children = [];
subParent.children.push(newChapter);
return newChapter;
}
}
}
}
} else {
MOCK_CHAPTERS.push(newChapter);
const row = {
id,
textbookId: data.textbookId,
title: data.title.trim(),
order: data.order ?? 0,
parentId: data.parentId ?? null,
content: "",
createdAt: now,
updatedAt: now,
}
await db.insert(chapters).values(row)
return {
id: row.id,
textbookId: row.textbookId,
title: row.title,
order: row.order,
parentId: row.parentId,
content: row.content,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
children: [],
}
return newChapter;
}
export async function updateChapterContent(data: UpdateChapterContentInput): Promise<Chapter> {
await new Promise((resolve) => setTimeout(resolve, 500));
// Recursive find and update
const updateContentRecursive = (chapters: Chapter[]): Chapter | null => {
for (const ch of chapters) {
if (ch.id === data.chapterId) {
ch.content = data.content;
ch.updatedAt = new Date();
return ch;
}
if (ch.children) {
const found = updateContentRecursive(ch.children);
if (found) return found;
}
}
return null;
};
await db.update(chapters).set({ content: data.content }).where(eq(chapters.id, data.chapterId))
const updated = updateContentRecursive(MOCK_CHAPTERS);
if (!updated) throw new Error("Chapter not found");
return updated;
const [row] = await db
.select({
id: chapters.id,
textbookId: chapters.textbookId,
title: chapters.title,
order: chapters.order,
parentId: chapters.parentId,
content: chapters.content,
createdAt: chapters.createdAt,
updatedAt: chapters.updatedAt,
})
.from(chapters)
.where(eq(chapters.id, data.chapterId))
.limit(1)
if (!row) throw new Error("Chapter not found")
return {
id: row.id,
textbookId: row.textbookId,
title: row.title,
order: row.order,
parentId: row.parentId,
content: row.content ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
children: [],
}
}
export async function deleteChapter(id: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
// Recursive delete
MOCK_CHAPTERS = MOCK_CHAPTERS.filter(c => c.id !== id);
MOCK_CHAPTERS.forEach(c => {
if (c.children) {
c.children = c.children.filter(child => child.id !== id);
}
});
const [target] = await db
.select({ id: chapters.id, textbookId: chapters.textbookId })
.from(chapters)
.where(eq(chapters.id, id))
.limit(1)
if (!target) return
const all = await db
.select({ id: chapters.id, parentId: chapters.parentId })
.from(chapters)
.where(eq(chapters.textbookId, target.textbookId))
const childrenByParent = new Map<string, string[]>()
for (const ch of all) {
if (!ch.parentId) continue
const arr = childrenByParent.get(ch.parentId) ?? []
arr.push(ch.id)
childrenByParent.set(ch.parentId, arr)
}
const idsToDelete: string[] = []
const stack = [id]
const seen = new Set<string>()
while (stack.length) {
const cur = stack.pop()!
if (seen.has(cur)) continue
seen.add(cur)
idsToDelete.push(cur)
const kids = childrenByParent.get(cur)
if (kids) stack.push(...kids)
}
await db.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete))
await db.delete(chapters).where(inArray(chapters.id, idsToDelete))
}
// Knowledge Points
export const getKnowledgePointsByChapterId = cache(async (chapterId: string): Promise<KnowledgePoint[]> => {
const rows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
description: knowledgePoints.description,
parentId: knowledgePoints.parentId,
chapterId: knowledgePoints.chapterId,
level: knowledgePoints.level,
order: knowledgePoints.order,
})
.from(knowledgePoints)
.where(eq(knowledgePoints.chapterId, chapterId))
.orderBy(asc(knowledgePoints.order), asc(knowledgePoints.name))
export async function getKnowledgePointsByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
await new Promise((resolve) => setTimeout(resolve, 300));
return MOCK_KNOWLEDGE_POINTS.filter(kp => kp.chapterId === chapterId);
}
return rows.map((r) => ({
id: r.id,
name: r.name,
description: r.description ?? null,
parentId: r.parentId ?? null,
chapterId: r.chapterId ?? undefined,
level: r.level ?? 0,
order: r.order ?? 0,
}))
})
export const getKnowledgePointsByTextbookId = cache(async (textbookId: string): Promise<KnowledgePoint[]> => {
const rows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
description: knowledgePoints.description,
parentId: knowledgePoints.parentId,
chapterId: knowledgePoints.chapterId,
level: knowledgePoints.level,
order: knowledgePoints.order,
})
.from(knowledgePoints)
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(eq(chapters.textbookId, textbookId))
.orderBy(asc(chapters.order), asc(knowledgePoints.order), asc(knowledgePoints.name))
return rows.map((r) => ({
id: r.id,
name: r.name,
description: r.description ?? null,
parentId: r.parentId ?? null,
chapterId: r.chapterId ?? undefined,
level: r.level ?? 0,
order: r.order ?? 0,
}))
})
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<KnowledgePoint> {
await new Promise((resolve) => setTimeout(resolve, 500));
const newKP: KnowledgePoint = {
id: `kp_${Math.random().toString(36).substr(2, 9)}`,
name: data.name,
description: data.description,
const id = createId()
const row = {
id,
name: data.name.trim(),
description: normalizeOptional(data.description ?? null),
chapterId: data.chapterId,
level: 1, // simplified
order: 0
};
MOCK_KNOWLEDGE_POINTS.push(newKP);
return newKP;
level: 1,
order: 0,
}
await db.insert(knowledgePoints).values(row)
return {
id: row.id,
name: row.name,
description: row.description,
parentId: null,
chapterId: row.chapterId,
level: row.level,
order: row.order,
}
}
export async function deleteKnowledgePoint(id: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
MOCK_KNOWLEDGE_POINTS = MOCK_KNOWLEDGE_POINTS.filter(kp => kp.id !== id);
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id))
}

View File

@@ -1,6 +1,3 @@
import { type InferSelectModel } from "drizzle-orm";
import { textbooks, chapters } from "@/shared/db/schema";
// Define types based on Drizzle Schema
// In a real app, we would infer these from the schema, but since we might not have the full schema setup running locally with DB,
// we will define interfaces that match the schema description in ARCHITECTURE.md and schema.ts