feat(classes): optimize teacher dashboard ui and implement grade management

This commit is contained in:
SpecialX
2026-01-14 13:59:11 +08:00
parent ade8d4346c
commit 9bfc621d3f
104 changed files with 12793 additions and 2309 deletions

View File

@@ -1,11 +1,11 @@
"use server";
import { revalidatePath } from "next/cache"
import { and, eq, sql } from "drizzle-orm"
import { and, eq, sql, or, inArray } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { grades } from "@/shared/db/schema"
import { grades, classes } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import {
createAdminClass,
@@ -138,6 +138,201 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
}
}
export async function createGradeClassAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
const session = await auth()
const userId = session?.user?.id
if (!userId) return { success: false, message: "Unauthorized" }
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 gradeId !== "string" || gradeId.trim().length === 0) {
return { success: false, message: "Grade selection is required" }
}
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
return { success: false, message: "Teacher is required" }
}
// Verify access
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to create classes for this grade" }
}
try {
const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
name,
grade: typeof grade === "string" ? grade : "", // Should be passed from UI based on selected grade
gradeId,
teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
})
revalidatePath("/management/grade/classes")
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 updateGradeClassAction(
classId: string,
prevState: ActionState | undefined,
formData: FormData
): Promise<ActionState> {
const session = await auth()
const userId = session?.user?.id
if (!userId) return { success: false, message: "Unauthorized" }
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" }
}
// Verify access: Check if the class belongs to a managed grade
const [cls] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!cls || !cls.gradeId) {
return { success: false, message: "Class not found or not linked to a grade" }
}
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to update this class" }
}
// If changing gradeId, verify target grade too
if (typeof gradeId === "string" && gradeId !== cls.gradeId) {
const [targetGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!targetGrade) {
return { success: false, message: "You do not have permission to move class to this grade" }
}
}
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("/management/grade/classes")
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 deleteGradeClassAction(classId: string): Promise<ActionState> {
const session = await auth()
const userId = session?.user?.id
if (!userId) return { success: false, message: "Unauthorized" }
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
// Verify access
const [cls] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!cls || !cls.gradeId) {
return { success: false, message: "Class not found or not linked to a grade" }
}
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to delete this class" }
}
try {
await deleteAdminClass(classId)
revalidatePath("/management/grade/classes")
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,
@@ -171,14 +366,19 @@ export async function joinClassByInvitationCodeAction(
}
const session = await auth()
if (!session?.user?.id || String(session.user.role ?? "") !== "student") {
const role = String(session?.user?.role ?? "")
if (!session?.user?.id || (role !== "student" && role !== "teacher")) {
return { success: false, message: "Unauthorized" }
}
try {
const classId = await enrollStudentByInvitationCode(session.user.id, code)
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")
if (role === "student") {
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")
} else {
revalidatePath("/teacher/classes/my")
}
revalidatePath("/profile")
return { success: true, message: "Joined class successfully", data: { classId } }
} catch (error) {

View File

@@ -0,0 +1,455 @@
"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 { createGradeClassAction, deleteGradeClassAction, updateGradeClassAction } 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 GradeClassesClient({
classes,
teachers,
managedGrades,
}: {
classes: AdminClassListItem[]
teachers: TeacherOption[]
managedGrades: { id: string; name: string; schoolId: string; schoolName: string | null }[]
}) {
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 [createGradeId, setCreateGradeId] = useState(managedGrades[0]?.id ?? "")
const [editTeacherId, setEditTeacherId] = useState("")
const [editGradeId, setEditGradeId] = useState("")
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
useEffect(() => {
if (!createOpen) return
setCreateTeacherId(defaultTeacherId)
setCreateGradeId(managedGrades[0]?.id ?? "")
}, [createOpen, defaultTeacherId, managedGrades])
useEffect(() => {
if (!editItem) return
setEditTeacherId(editItem.teacher.id)
setEditGradeId(editItem.gradeId ?? managedGrades[0]?.id ?? "")
setEditSubjectTeachers(
DEFAULT_CLASS_SUBJECTS.map((s) => ({
subject: s,
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
}))
)
}, [editItem, managedGrades])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createGradeClassAction(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 updateGradeClassAction(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 deleteGradeClassAction(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("") : "-"
}
const selectedCreateGrade = managedGrades.find(g => g.id === createGradeId)
const selectedEditGrade = managedGrades.find(g => g.id === editGradeId)
return (
<>
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking || managedGrades.length === 0}>
<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={managedGrades.length === 0 ? "You are not managing any grades yet." : "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 className="text-right">Grade</Label>
<div className="col-span-3">
<Select value={createGradeId} onValueChange={setCreateGradeId} disabled={managedGrades.length === 0}>
<SelectTrigger>
<SelectValue placeholder={managedGrades.length === 0 ? "No managed grades" : "Select a grade"} />
</SelectTrigger>
<SelectContent>
{managedGrades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.schoolName})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="gradeId" value={createGradeId} />
<input type="hidden" name="grade" value={selectedCreateGrade?.name ?? ""} />
<input type="hidden" name="schoolId" value={selectedCreateGrade?.schoolId ?? ""} />
<input type="hidden" name="schoolName" value={selectedCreateGrade?.schoolName ?? ""} />
</div>
</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 3" autoFocus />
</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"></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 || !createGradeId}>
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 className="text-right">Grade</Label>
<div className="col-span-3">
<Select value={editGradeId} onValueChange={setEditGradeId} disabled={managedGrades.length === 0}>
<SelectTrigger>
<SelectValue placeholder="Select a grade" />
</SelectTrigger>
<SelectContent>
{managedGrades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.schoolName})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="gradeId" value={editGradeId} />
<input type="hidden" name="grade" value={selectedEditGrade?.name ?? ""} />
<input type="hidden" name="schoolId" value={selectedEditGrade?.schoolId ?? ""} />
<input type="hidden" name="schoolName" value={selectedEditGrade?.schoolName ?? ""} />
</div>
</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-homeroom" className="text-right">
Homeroom
</Label>
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-room" className="text-right">
Room
</Label>
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={editTeacherId} />
</div>
</div>
<div className="space-y-3 rounded-md border p-4">
<div className="text-sm font-medium"></div>
<div className="grid gap-3">
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
return (
<div key={subject} className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{subject}</Label>
<div className="col-span-3">
<Select
value={selected ?? ""}
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
disabled={teachers.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
})}
</div>
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || !editTeacherId}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete class</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this class"}.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -3,11 +3,24 @@
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 {
Calendar,
Copy,
MoreHorizontal,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
Users,
GraduationCap,
MapPin,
ChartBar,
} from "lucide-react"
import { toast } from "sonner"
import { parseAsString, useQueryState } from "nuqs"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -41,6 +54,7 @@ import {
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
import type { TeacherClass } from "../types"
import {
createTeacherClassAction,
@@ -48,12 +62,25 @@ import {
ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction,
updateTeacherClassAction,
joinClassByInvitationCodeAction,
} from "../actions"
const GRADIENTS = [
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
]
function getClassGradient(id: string) {
return "bg-card border-border shadow-sm hover:shadow-md"
}
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
@@ -75,41 +102,44 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
const handleCreate = async (formData: FormData) => {
const handleJoin = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createTeacherClassAction(null, formData)
const res = await joinClassByInvitationCodeAction(null, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
toast.success(res.message || "Joined class successfully")
setJoinOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create class")
toast.error(res.message || "Failed to join class")
}
} catch {
toast.error("Failed to create class")
toast.error("Failed to join class")
} finally {
setIsWorking(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-6">
{/* Filter Bar */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm">
<div className="relative flex-1 md:max-w-[320px]">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Search classes..."
value={q}
onChange={(e) => setQ(e.target.value || null)}
className="pl-9 bg-background"
/>
</div>
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Grade" />
<SelectTrigger className="w-[160px] bg-background">
<SelectValue placeholder="All Grades" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All grades</SelectItem>
<SelectItem value="all">All Grades</SelectItem>
{gradeOptions.map((g) => (
<SelectItem key={g} value={g}>
{g}
@@ -120,83 +150,56 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
{(q || grade !== "all") && (
<Button
variant="ghost"
className="h-9"
size="icon"
onClick={() => {
setQ(null)
setGrade(null)
}}
title="Clear filters"
>
Reset
<RefreshCw className="size-4" />
</Button>
)}
</div>
<Dialog
open={createOpen}
open={joinOpen}
onOpenChange={(open) => {
if (!canCreateClass) return
if (isWorking) return
setCreateOpen(open)
setJoinOpen(open)
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={isWorking || !canCreateClass}>
<Button className="gap-2 shadow-sm" disabled={isWorking}>
<Plus className="size-4" />
New class
Join Class
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create class</DialogTitle>
<DialogDescription>Add a new class to start managing students.</DialogDescription>
<DialogTitle>Join Class</DialogTitle>
<DialogDescription>Enter the invitation code to join a class.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<form action={handleJoin}>
<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 htmlFor="join-code" className="text-right">
Code
</Label>
<Input
id="create-school-name"
name="schoolName"
id="join-code"
name="code"
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}
placeholder="e.g. 123456"
required
maxLength={6}
pattern="\d{6}"
/>
</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"}
{isWorking ? "Joining..." : "Join Class"}
</Button>
</DialogFooter>
</form>
@@ -204,34 +207,33 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
</Dialog>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Grid */}
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{classes.length === 0 ? (
<EmptyState
title="No classes yet"
description="Create your first class to start managing students and schedules."
description="Join a class to start managing students and schedules."
icon={Users}
action={canCreateClass ? { label: "Create class", onClick: () => setCreateOpen(true) } : undefined}
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
/>
) : 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"
icon={Search}
action={{
label: "Clear filters",
onClick: () => {
setQ(null)
setGrade(null)
},
}}
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
/>
) : (
filteredClasses.map((c) => (
<ClassCard
key={c.id}
c={c}
onWorkingChange={setIsWorking}
isWorking={isWorking}
/>
<ClassCard key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
))
)}
</div>
@@ -334,92 +336,131 @@ function ClassCard({
}
return (
<Card className="shadow-none">
<CardHeader className="space-y-2">
<Card className={cn("group flex flex-col transition-all hover:shadow-md", getClassGradient(c.id))}>
<CardHeader className="relative pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-base truncate">
<div className="space-y-1">
<CardTitle className="line-clamp-1 text-lg font-bold leading-none tracking-tight">
<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 className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary" className="h-5 px-1.5 font-medium">
{c.grade}
</Badge>
{c.homeroom && (
<Badge variant="outline" className="h-5 border-dashed bg-transparent px-1.5 font-normal">
{c.homeroom}
</Badge>
)}
</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>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 -mr-2" disabled={isWorking}>
<MoreHorizontal className="size-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEdit(true)}>
<Pencil className="mr-2 size-4" />
Edit Class
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDelete(true)}
>
<Trash2 className="mr-2 size-4" />
Delete Class
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</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>
<CardContent className="flex-1 pb-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Students</span>
<div className="flex items-center gap-1.5 font-medium">
<Users className="size-3.5 text-muted-foreground" />
{c.studentCount}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Room</span>
<div className="flex items-center gap-1.5 font-medium">
<MapPin className="size-3.5 text-muted-foreground" />
{c.room || "—"}
</div>
</div>
</div>
<div className="mt-4 flex items-center justify-between rounded-md border bg-background/50 p-2">
<div className="flex flex-col">
<span className="text-[10px] uppercase text-muted-foreground">Invite Code</span>
<span className="font-mono text-sm font-medium tracking-wider">{c.invitationCode || "—"}</span>
</div>
<div className="flex items-center gap-1">
{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>
</>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopyCode} disabled={isWorking}>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy Code</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleRegenerateCode}
disabled={isWorking}
>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Regenerate</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button variant="outline" size="sm" onClick={handleEnsureCode} disabled={isWorking}>
<Button variant="outline" size="sm" className="h-7 text-xs" 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>
<CardFooter className="grid grid-cols-3 gap-2 border-t p-2">
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
<Users className="mr-1.5 size-3.5" />
Students
</Link>
</Button>
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
<Calendar className="mr-1.5 size-3.5" />
Schedule
</Link>
</Button>
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(c.id)}`}>
<ChartBar className="mr-1.5 size-3.5" />
Insights
</Link>
</Button>
</CardFooter>
{/* Dialogs */}
<Dialog
open={showEdit}
onOpenChange={(open) => {
@@ -495,7 +536,7 @@ function ClassCard({
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save"}
{isWorking ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
@@ -524,7 +565,7 @@ function ClassCard({
onClick={handleDelete}
disabled={isWorking}
>
{isWorking ? "Deleting..." : "Delete"}
{isWorking ? "Deleting..." : "Delete Class"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -31,6 +31,7 @@ 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 [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
const router = useRouter()
const [open, setOpen] = useState(false)
@@ -76,7 +77,7 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
</div>
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[200px]">
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
@@ -89,12 +90,24 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
</SelectContent>
</Select>
{(search || classId !== "all") && (
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
{(search || classId !== "all" || status !== "all") && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setClassId(null)
setStatus(null)
}}
className="h-8 px-2 lg:px-3"
>

View File

@@ -2,12 +2,14 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { MoreHorizontal, UserCheck, UserX, ChevronLeft, ChevronRight } 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 { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,10 +38,17 @@ import {
import type { ClassStudent } from "../types"
import { setStudentEnrollmentStatusAction } from "../actions"
const ITEMS_PER_PAGE = 10
export function StudentsTable({ students }: { students: ClassStudent[] }) {
const router = useRouter()
const [workingKey, setWorkingKey] = useState<string | null>(null)
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
const [page, setPage] = useState(1)
const totalPages = Math.ceil(students.length / ITEMS_PER_PAGE)
const startIndex = (page - 1) * ITEMS_PER_PAGE
const paginatedStudents = students.slice(startIndex, startIndex + ITEMS_PER_PAGE)
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
const key = `${student.classId}:${student.id}:${status}`
@@ -59,64 +68,144 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
}
}
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
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}
<Card className="shadow-none">
<CardHeader className="border-b px-6 py-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">All Students</CardTitle>
<Badge variant="secondary" className="rounded-sm px-1.5 font-normal">
{students.length} total
</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="pl-6 text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Joined</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
<TableHead className="pr-6 text-right text-xs font-medium uppercase text-muted-foreground">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedStudents.map((s) => (
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-16", s.status !== "active" && "opacity-70")}>
<TableCell className="pl-6">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9 border">
<AvatarImage src={s.image || undefined} alt={s.name} />
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5">
<span className="font-medium leading-none">{s.name}</span>
<span className="text-xs text-muted-foreground">{s.email}</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
{s.className}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(s.joinedAt)}
</TableCell>
<TableCell>
<Badge
variant={s.status === "active" ? "secondary" : "outline"}
className={cn(
"font-medium",
s.status === "active"
? "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 hover:bg-emerald-500/20"
: "text-muted-foreground"
)}
>
<UserX className="mr-2 size-4" />
Remove from class
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{s.status === "active" ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="pr-6 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>
</CardContent>
{totalPages > 1 && (
<CardFooter className="flex items-center justify-between border-t px-6 py-4">
<div className="text-xs text-muted-foreground">
Showing <strong>{startIndex + 1}</strong>-
<strong>{Math.min(startIndex + ITEMS_PER_PAGE, students.length)}</strong> of{" "}
<strong>{students.length}</strong> students
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">
{page} / {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardFooter>
)}
</Card>
<AlertDialog
open={Boolean(removeTarget)}

View File

@@ -122,6 +122,20 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
const rows = await (async () => {
try {
const ownedIds = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, teacherId))
const enrolledIds = await db
.select({ id: classEnrollments.classId })
.from(classEnrollments)
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
if (allIds.length === 0) return []
return await db
.select({
id: classes.id,
@@ -135,26 +149,11 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
})
.from(classes)
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(eq(classes.teacherId, teacherId))
.where(inArray(classes.id, allIds))
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} catch {
return await db
.select({
id: classes.id,
schoolName: sql<string | null>`NULL`.as("schoolName"),
name: classes.name,
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: sql<string | null>`NULL`.as("invitationCode"),
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
})
.from(classes)
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(eq(classes.teacherId, teacherId))
.groupBy(classes.id, classes.name, classes.grade, classes.homeroom, classes.room)
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
return []
}
})()
@@ -331,6 +330,143 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
return list
})
export const getGradeManagedClasses = cache(async (userId: string): Promise<AdminClassListItem[]> => {
const managedGradeIds = await db
.select({ id: grades.id })
.from(grades)
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
if (managedGradeIds.length === 0) return []
const gradeIds = managedGradeIds.map((g) => g.id)
const [rows, subjectRows] = await Promise.all([
(async () => {
try {
return await db
.select({
id: classes.id,
schoolName: classes.schoolName,
schoolId: classes.schoolId,
name: classes.name,
grade: classes.grade,
gradeId: classes.gradeId,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
createdAt: classes.createdAt,
updatedAt: classes.updatedAt,
})
.from(classes)
.innerJoin(users, eq(users.id, classes.teacherId))
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(inArray(classes.gradeId, gradeIds))
.groupBy(
classes.id,
classes.schoolName,
classes.schoolId,
classes.name,
classes.grade,
classes.gradeId,
classes.homeroom,
classes.room,
classes.invitationCode,
users.id,
users.name,
users.email,
classes.createdAt,
classes.updatedAt
)
.orderBy(
asc(classes.schoolName),
asc(classes.grade),
asc(classes.name),
asc(classes.homeroom),
asc(classes.room)
)
} catch {
return []
}
})(),
db
.select({
classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.where(inArray(classes.gradeId, gradeIds))
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
for (const r of subjectRows) {
const subject = r.subject as ClassSubject
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
const teacher =
typeof r.teacherId === "string" && r.teacherId.length > 0
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
: null
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
bySubject.set(subject, teacher)
subjectsByClassId.set(r.classId, bySubject)
}
const list = rows.map((r) => {
const bySubject = subjectsByClassId.get(r.id)
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
subject,
teacher: bySubject?.get(subject) ?? null,
}))
return {
id: r.id,
schoolName: r.schoolName,
schoolId: r.schoolId,
name: r.name,
grade: r.grade,
gradeId: r.gradeId,
homeroom: r.homeroom,
room: r.room,
invitationCode: r.invitationCode ?? null,
teacher: {
id: r.teacherId,
name: r.teacherName ?? "Unnamed",
email: r.teacherEmail,
},
subjectTeachers,
studentCount: Number(r.studentCount ?? 0),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}
})
list.sort(compareClassLike)
return list
})
export const getManagedGrades = cache(async (userId: string) => {
return await db
.select({
id: grades.id,
name: grades.name,
schoolId: grades.schoolId,
schoolName: schools.name,
})
.from(grades)
.innerJoin(schools, eq(schools.id, grades.schoolId))
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
.orderBy(asc(schools.name), asc(grades.name))
})
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
const id = studentId.trim()
if (!id) return []
@@ -345,9 +481,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} catch {
@@ -359,9 +498,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
}
@@ -374,6 +516,8 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
grade: r.grade,
homeroom: r.homeroom,
room: r.room,
teacherName: r.teacherName,
teacherEmail: r.teacherEmail,
}))
list.sort(compareClassLike)
@@ -414,12 +558,13 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
})
export const getClassStudents = cache(
async (params?: { classId?: string; q?: string; teacherId?: string }): Promise<ClassStudent[]> => {
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const q = params?.q?.trim().toLowerCase()
const status = params?.status?.trim().toLowerCase()
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
@@ -427,6 +572,10 @@ export const getClassStudents = cache(
conditions.push(eq(classes.id, classId))
}
if (status === "active" || status === "inactive") {
conditions.push(eq(classEnrollments.status, status))
}
if (q && q.length > 0) {
const needle = `%${q}%`
conditions.push(
@@ -439,9 +588,12 @@ export const getClassStudents = cache(
id: users.id,
name: users.name,
email: users.email,
image: users.image,
gender: users.gender,
classId: classes.id,
className: classes.name,
status: classEnrollments.status,
joinedAt: classEnrollments.createdAt,
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
@@ -453,9 +605,12 @@ export const getClassStudents = cache(
id: r.id,
name: r.name ?? "Unnamed",
email: r.email,
image: r.image,
gender: r.gender,
classId: r.classId,
className: r.className,
status: r.status,
joinedAt: r.joinedAt,
}))
}
)

View File

@@ -65,9 +65,12 @@ export type ClassStudent = {
id: string
name: string
email: string
image?: string | null
gender?: string | null
classId: string
className: string
status: "active" | "inactive"
joinedAt: Date
}
export type ClassScheduleItem = {
@@ -80,26 +83,6 @@ export type ClassScheduleItem = {
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
@@ -118,13 +101,26 @@ export type UpdateClassScheduleItemInput = {
location?: string | null
}
export type ClassBasicInfo = {
export type StudentEnrolledClass = {
id: string
schoolName?: string | null
name: string
grade: string
homeroom?: string | null
room?: string | null
invitationCode?: string | null
teacherName?: string | null
teacherEmail?: 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 ScoreStats = {
@@ -151,24 +147,23 @@ export type ClassHomeworkAssignmentStats = {
}
export type ClassHomeworkInsights = {
class: ClassBasicInfo
studentCounts: {
total: number
active: number
inactive: number
class: {
id: string
name: string
grade: string
homeroom?: string | null
room?: string | null
invitationCode?: string | null
}
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
}
class: { id: string; name: string; grade: string; homeroom?: string | null; room?: string | null }
studentCounts: { total: number; active: number; inactive: number }
latestAvg: number | null
prevAvg: number | null
deltaAvg: number | null
@@ -176,17 +171,9 @@ export type GradeHomeworkClassSummary = {
}
export type GradeHomeworkInsights = {
grade: {
id: string
name: string
school: { id: string; name: string }
}
grade: { id: string; name: string; school: { id: string; name: string } }
classCount: number
studentCounts: {
total: number
active: number
inactive: number
}
studentCounts: { total: number; active: number; inactive: number }
assignments: ClassHomeworkAssignmentStats[]
latest: ClassHomeworkAssignmentStats | null
overallScores: ScoreStats