完整性更新
现在已经实现了大部分基础功能
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import Link from "next/link"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
|
||||
interface AuthLayoutProps {
|
||||
|
||||
@@ -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}
|
||||
|
||||
434
src/modules/classes/actions.ts
Normal file
434
src/modules/classes/actions.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
434
src/modules/classes/components/admin-classes-view.tsx
Normal file
434
src/modules/classes/components/admin-classes-view.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
40
src/modules/classes/components/insights-filters.tsx
Normal file
40
src/modules/classes/components/insights-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
533
src/modules/classes/components/my-classes-grid.tsx
Normal file
533
src/modules/classes/components/my-classes-grid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
src/modules/classes/components/schedule-filters.tsx
Normal file
195
src/modules/classes/components/schedule-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
465
src/modules/classes/components/schedule-view.tsx
Normal file
465
src/modules/classes/components/schedule-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
169
src/modules/classes/components/students-filters.tsx
Normal file
169
src/modules/classes/components/students-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
158
src/modules/classes/components/students-table.tsx
Normal file
158
src/modules/classes/components/students-table.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
1541
src/modules/classes/data-access.ts
Normal file
1541
src/modules/classes/data-access.ts
Normal file
File diff suppressed because it is too large
Load Diff
194
src/modules/classes/types.ts
Normal file
194
src/modules/classes/types.ts
Normal 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[]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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's work and your classes.</p>
|
||||
</div>
|
||||
<TeacherQuickActions />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
103
src/modules/dashboard/data-access.ts
Normal file
103
src/modules/dashboard/data-access.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
||||
70
src/modules/dashboard/types.ts
Normal file
70
src/modules/dashboard/types.ts
Normal 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[]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
184
src/modules/exams/components/exam-viewer.tsx
Normal file
184
src/modules/exams/components/exam-viewer.tsx
Normal 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>
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu } from "lucide-react"
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
291
src/modules/school/actions.ts
Normal file
291
src/modules/school/actions.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
315
src/modules/school/components/academic-year-view.tsx
Normal file
315
src/modules/school/components/academic-year-view.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
247
src/modules/school/components/departments-view.tsx
Normal file
247
src/modules/school/components/departments-view.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
779
src/modules/school/components/grades-view.tsx
Normal file
779
src/modules/school/components/grades-view.tsx
Normal 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">年级名称(A→Z)</SelectItem>
|
||||
<SelectItem value="name_desc">年级名称(Z→A)</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
253
src/modules/school/components/schools-view.tsx
Normal file
253
src/modules/school/components/schools-view.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
183
src/modules/school/data-access.ts
Normal file
183
src/modules/school/data-access.ts
Normal 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 []
|
||||
}
|
||||
})
|
||||
52
src/modules/school/schema.ts
Normal file
52
src/modules/school/schema.ts
Normal 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",
|
||||
})
|
||||
42
src/modules/school/types.ts
Normal file
42
src/modules/school/types.ts
Normal 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
|
||||
}
|
||||
158
src/modules/settings/components/admin-settings-view.tsx
Normal file
158
src/modules/settings/components/admin-settings-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
src/modules/settings/components/student-settings-view.tsx
Normal file
136
src/modules/settings/components/student-settings-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
148
src/modules/settings/components/teacher-settings-view.tsx
Normal file
148
src/modules/settings/components/teacher-settings-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
60
src/modules/settings/components/theme-preferences-card.tsx
Normal file
60
src/modules/settings/components/theme-preferences-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
src/modules/student/components/student-courses-view.tsx
Normal file
156
src/modules/student/components/student-courses-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/modules/student/components/student-schedule-filters.tsx
Normal file
33
src/modules/student/components/student-schedule-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
89
src/modules/student/components/student-schedule-view.tsx
Normal file
89
src/modules/student/components/student-schedule-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
82
src/modules/textbooks/components/textbook-filters.tsx
Normal file
82
src/modules/textbooks/components/textbook-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
156
src/modules/textbooks/components/textbook-reader.tsx
Normal file
156
src/modules/textbooks/components/textbook-reader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user