refactor(modules): update existing module implementations across attendance, audit, auth, classes, course-plans, exams, files, homework, layout, proctoring, questions, scheduling, textbooks, users
- Update attendance components and data-access for record management - Update audit log views, filters, and data-access - Update auth login and register forms - Update classes actions, components, and data-access (admin, schedule, stats) - Update course-plans actions, form, list, progress, and schema - Update exams actions, AI pipeline, preview components, and hooks - Update files components (icon, list, preview, upload) and data-access - Update homework assignment form, review view, auto-save hook, and stats-service - Update layout sidebar, header, and navigation config - Update proctoring actions, anti-cheat monitor, and data-access - Update questions actions, components (dialog, actions, columns, filters), and data-access - Update scheduling actions, auto-scheduler, components, and schema - Update textbooks constants and text-selection hook - Update users class-registration, import-dialog, data-access, and user-service
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import {
|
||||
createClassScheduleItem,
|
||||
updateClassScheduleItem,
|
||||
@@ -50,11 +51,10 @@ export async function createClassScheduleItemAction(
|
||||
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" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,11 +93,10 @@ export async function updateClassScheduleItemAction(
|
||||
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" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,10 +114,9 @@ export async function deleteClassScheduleItemAction(scheduleId: string): Promise
|
||||
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" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import {
|
||||
createTeacherClass,
|
||||
deleteTeacherClass,
|
||||
@@ -68,11 +69,10 @@ export async function createTeacherClassAction(
|
||||
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" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +115,10 @@ export async function updateTeacherClassAction(
|
||||
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" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,10 +138,9 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
|
||||
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" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
@@ -70,25 +70,31 @@ export function AdminClassesClient({
|
||||
const selectedEditSchool = schools.find((s) => s.id === editSchoolId)
|
||||
const selectedEditGrade = grades.find((g) => g.id === editGradeId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
setCreateTeacherId(defaultTeacherId)
|
||||
setCreateSchoolId(defaultSchoolId)
|
||||
setCreateGradeId("")
|
||||
}, [createOpen, defaultTeacherId, defaultSchoolId])
|
||||
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||
if (createOpen !== prevCreateOpen) {
|
||||
setPrevCreateOpen(createOpen)
|
||||
if (createOpen) {
|
||||
setCreateTeacherId(defaultTeacherId)
|
||||
setCreateSchoolId(defaultSchoolId)
|
||||
setCreateGradeId("")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editItem) return
|
||||
setEditTeacherId(editItem.teacher.id)
|
||||
setEditSchoolId(editItem.schoolId ?? "")
|
||||
setEditGradeId(editItem.gradeId ?? "")
|
||||
setEditSubjectTeachers(
|
||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||
subject: s,
|
||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||
}))
|
||||
)
|
||||
}, [editItem])
|
||||
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||
if (editItem !== prevEditItem) {
|
||||
setPrevEditItem(editItem)
|
||||
if (editItem) {
|
||||
setEditTeacherId(editItem.teacher.id)
|
||||
setEditSchoolId(editItem.schoolId ?? "")
|
||||
setEditGradeId(editItem.gradeId ?? "")
|
||||
setEditSubjectTeachers(
|
||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||
subject: s,
|
||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
|
||||
@@ -98,6 +98,8 @@ export function ClassInvitationManager({
|
||||
} else {
|
||||
toast.error(result.message ?? t("revokeFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("revokeFailed"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -258,6 +260,8 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP
|
||||
} else {
|
||||
toast.error(result.message ?? t("generateFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("generateFailed"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -23,16 +23,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
@@ -431,25 +422,16 @@ export function GradeClassesClient({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
<ConfirmDeleteDialog
|
||||
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>
|
||||
title="Delete class"
|
||||
description={`This will permanently delete ${deleteItem?.name || "this class"}.`}
|
||||
onConfirm={handleDelete}
|
||||
isWorking={isWorking}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Plus } from "lucide-react"
|
||||
@@ -38,12 +38,15 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
|
||||
const [createClassId, setCreateClassId] = useState(defaultClassId)
|
||||
const [weekday, setWeekday] = useState<string>("1")
|
||||
const [prevOpen, setPrevOpen] = useState(open)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setCreateClassId(defaultClassId)
|
||||
setWeekday("1")
|
||||
}, [open, defaultClassId])
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
setCreateClassId(defaultClassId)
|
||||
setWeekday("1")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@@ -74,16 +74,22 @@ export function ScheduleView({
|
||||
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])
|
||||
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||
if (editItem !== prevEditItem) {
|
||||
setPrevEditItem(editItem)
|
||||
if (editItem) {
|
||||
setEditClassId(editItem.classId)
|
||||
setEditWeekday(String(editItem.weekday))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
setCreateClassId(defaultClassId)
|
||||
}, [createOpen, defaultClassId])
|
||||
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||
if (createOpen !== prevCreateOpen) {
|
||||
setPrevCreateOpen(createOpen)
|
||||
if (createOpen) {
|
||||
setCreateClassId(defaultClassId)
|
||||
}
|
||||
}
|
||||
|
||||
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
|
||||
for (const d of WEEKDAYS) byDay.set(d.key, [])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
|
||||
@@ -49,10 +49,13 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
|
||||
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
}, [open, effectiveClassId, classes])
|
||||
const [prevOpen, setPrevOpen] = useState(open)
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnroll = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { toast } from "sonner"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
||||
import { cn, getInitials } from "@/shared/lib/utils"
|
||||
import { cn, formatDate, getInitials } from "@/shared/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,16 +16,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||
import type { ClassStudent } from "../types"
|
||||
import { setStudentEnrollmentStatusAction } from "../actions"
|
||||
|
||||
@@ -79,11 +70,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
{s.className}
|
||||
</span>
|
||||
<span className="text-[10px]">
|
||||
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit"
|
||||
})}
|
||||
{formatDate(s.joinedAt, "en-GB")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,41 +148,29 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
<ConfirmDeleteDialog
|
||||
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>
|
||||
title="Remove student from class?"
|
||||
confirmText="Remove"
|
||||
description={
|
||||
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
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (!removeTarget) return
|
||||
setRemoveTarget(null)
|
||||
setStatus(removeTarget, "inactive")
|
||||
}}
|
||||
isWorking={workingKey !== null}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -402,6 +402,28 @@ export const getClassesByGradeId = async (gradeId: string): Promise<Array<{ id:
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个年级下的所有班级 ID(供 grades 模块 grade_managed scope 过滤使用)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassIdsByGradeIds = async (gradeIds: string[]): Promise<string[]> => {
|
||||
const uniqueIds = Array.from(new Set(gradeIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
if (uniqueIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(inArray(classes.gradeId, uniqueIds))
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建一个 Drizzle 子查询 SQL,用于过滤 classId IN (SELECT id FROM classes WHERE grade_id IN (...))。
|
||||
* 供 grades 模块 grade_managed scope 同步构建 SQL 过滤条件使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassIdsByGradeIdsSubquery = (gradeIds: string[]) => {
|
||||
return db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, gradeIds))
|
||||
}
|
||||
|
||||
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||
if (!teacherId) return []
|
||||
|
||||
Reference in New Issue
Block a user