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

@@ -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)}