Files
CICD/src/modules/classes/components/my-classes-grid.tsx
SpecialX 57807def37
Some checks failed
CI / build-and-test (push) Failing after 3m50s
CI / deploy (push) Has been skipped
完整性更新
现在已经实现了大部分基础功能
2026-01-08 11:14:03 +08:00

534 lines
18 KiB
TypeScript

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