"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { BarChart3, MoreHorizontal, Pencil, Plus, Trash2, Users, GraduationCap, UserCog } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" import { parseAsString, useQueryState } from "nuqs" import { useTranslations } from "next-intl" import type { GradeListItem, SchoolListItem, StaffOption } from "../types" import type { GradeOverviewStats } from "../data-access" import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { EmptyState } from "@/shared/components/ui/empty-state" import { Badge } from "@/shared/components/ui/badge" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/shared/components/ui/alert-dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import { formatDate } from "@/shared/lib/utils" type FormState = { schoolId: string name: string order: string gradeHeadId: string teachingHeadId: string } const toFormState = (item: GradeListItem | null, fallbackSchoolId: string): FormState => ({ schoolId: item?.school.id ?? fallbackSchoolId, name: item?.name ?? "", order: String(item?.order ?? 0), gradeHeadId: item?.gradeHead?.id ?? "", teachingHeadId: item?.teachingHead?.id ?? "", }) type FormErrors = Partial> const normalizeName = (v: string) => v.trim().replace(/\s+/g, " ") const NONE_SELECT_VALUE = "__none__" const parseOrder = (raw: string) => { const v = raw.trim() if (!v) return 0 const n = Number(v) if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return null return n } export function GradesClient({ grades, schools, staff, gradeStats, }: { grades: GradeListItem[] schools: SchoolListItem[] staff: StaffOption[] gradeStats: GradeOverviewStats[] }) { const t = useTranslations("school") const router = useRouter() const [isWorking, setIsWorking] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [editItem, setEditItem] = useState(null) const [deleteItem, setDeleteItem] = useState(null) const [q, setQ] = useQueryState("q", parseAsString.withDefault("")) const [school, setSchool] = useQueryState("school", parseAsString.withDefault("all")) const [head, setHead] = useQueryState("head", parseAsString.withDefault("all")) const [sort, setSort] = useQueryState("sort", parseAsString.withDefault("default")) const defaultSchoolId = useMemo(() => schools[0]?.id ?? "", [schools]) const [createState, setCreateState] = useState(() => toFormState(null, defaultSchoolId)) const [editState, setEditState] = useState(() => toFormState(null, defaultSchoolId)) // 年级概览统计映射,用于卡片视图 const statsMap = useMemo(() => { const m = new Map() for (const s of gradeStats) m.set(s.gradeId, s) return m }, [gradeStats]) useEffect(() => { if (!createOpen) return if (createState.schoolId.trim().length > 0) return if (!defaultSchoolId) return setCreateState((p) => ({ ...p, schoolId: defaultSchoolId })) }, [createOpen, createState.schoolId, defaultSchoolId]) useEffect(() => { if (!editItem) return if (editState.schoolId.trim().length > 0) return if (!defaultSchoolId) return setEditState((p) => ({ ...p, schoolId: defaultSchoolId })) }, [editItem, editState.schoolId, defaultSchoolId]) const staffOptions = useMemo(() => { return [...staff].sort((a, b) => { const byName = a.name.localeCompare(b.name) if (byName !== 0) return byName return a.email.localeCompare(b.email) }) }, [staff]) const validateForm = useCallback( (state: FormState, params: { grades: GradeListItem[]; excludeGradeId?: string }): { ok: boolean errors: FormErrors } => { const errors: FormErrors = {} const schoolId = state.schoolId.trim() if (!schoolId) errors.schoolId = t("grades.validation.selectSchool") const name = normalizeName(state.name) if (!name) errors.name = t("grades.validation.enterName") if (name.length > 100) errors.name = t("grades.validation.nameTooLong") const order = parseOrder(state.order) if (order === null) errors.order = t("grades.validation.orderInvalid") if (schoolId && name) { const dup = params.grades.find((g) => { if (params.excludeGradeId && g.id === params.excludeGradeId) return false return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase() }) if (dup) errors.name = t("grades.validation.duplicateName") } return { ok: Object.keys(errors).length === 0, errors } }, [t] ) const formatStaffDetail = (u: StaffOption | null) => { if (!u) return {t("grades.notSet")} return (
{u.name}
{u.email}
) } const filteredGrades = useMemo(() => { const needle = q.trim().toLowerCase() const bySchool = school === "all" ? "" : school return grades .filter((g) => { if (bySchool && g.school.id !== bySchool) return false if (head === "missing") { if (g.gradeHead || g.teachingHead) return false } if (head === "missing_grade_head") { if (g.gradeHead) return false } if (head === "missing_teaching_head") { if (g.teachingHead) return false } if (!needle) return true const hay = [ g.name, g.school.name, g.gradeHead?.name ?? "", g.gradeHead?.email ?? "", g.teachingHead?.name ?? "", g.teachingHead?.email ?? "", ] .join(" ") .toLowerCase() return hay.includes(needle) }) .sort((a, b) => { if (sort === "updated_desc") return b.updatedAt.localeCompare(a.updatedAt) if (sort === "updated_asc") return a.updatedAt.localeCompare(b.updatedAt) if (sort === "name_asc") return a.name.localeCompare(b.name) if (sort === "name_desc") return b.name.localeCompare(a.name) if (sort === "order_asc") return a.order - b.order if (sort === "order_desc") return b.order - a.order return 0 }) }, [grades, head, q, school, sort]) const hasFilters = q.length > 0 || school !== "all" || head !== "all" || sort !== "default" const openEdit = (item: GradeListItem) => { setEditItem(item) setEditState(toFormState(item, defaultSchoolId)) } const openCreate = () => { setCreateState(toFormState(null, defaultSchoolId)) setCreateOpen(true) } const createValidation = useMemo( () => validateForm(createState, { grades }), [createState, grades, validateForm] ) const editValidation = useMemo( () => validateForm(editState, { grades, excludeGradeId: editItem?.id }), [editItem?.id, editState, grades, validateForm] ) const isEditDirty = useMemo(() => { if (!editItem) return false const next = { schoolId: editState.schoolId.trim(), name: normalizeName(editState.name), order: parseOrder(editState.order), gradeHeadId: editState.gradeHeadId || "", teachingHeadId: editState.teachingHeadId || "", } const prev = { schoolId: editItem.school.id, name: normalizeName(editItem.name), order: editItem.order, gradeHeadId: editItem.gradeHead?.id ?? "", teachingHeadId: editItem.teachingHead?.id ?? "", } return ( next.schoolId !== prev.schoolId || next.name !== prev.name || (typeof next.order === "number" ? next.order : null) !== prev.order || next.gradeHeadId !== prev.gradeHeadId || next.teachingHeadId !== prev.teachingHeadId ) }, [editItem, editState]) const handleCreate = async () => { const validation = validateForm(createState, { grades }) if (!validation.ok) { toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm")) return } setIsWorking(true) try { const fd = new FormData() fd.set("schoolId", createState.schoolId) fd.set("name", normalizeName(createState.name)) fd.set("order", createState.order) fd.set("gradeHeadId", createState.gradeHeadId) fd.set("teachingHeadId", createState.teachingHeadId) const res = await createGradeAction(undefined, fd) if (res.success) { toast.success(res.message) setCreateOpen(false) router.refresh() } else { toast.error(res.message || t("grades.failedCreate")) } } catch { toast.error(t("grades.failedCreate")) } finally { setIsWorking(false) } } const handleUpdate = async () => { if (!editItem) return const validation = validateForm(editState, { grades, excludeGradeId: editItem.id }) if (!validation.ok) { toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm")) return } if (!isEditDirty) { toast.message(t("grades.validation.noChanges")) return } setIsWorking(true) try { const fd = new FormData() fd.set("schoolId", editState.schoolId) fd.set("name", normalizeName(editState.name)) fd.set("order", editState.order) fd.set("gradeHeadId", editState.gradeHeadId) fd.set("teachingHeadId", editState.teachingHeadId) const res = await updateGradeAction(editItem.id, undefined, fd) if (res.success) { toast.success(res.message) setEditItem(null) router.refresh() } else { toast.error(res.message || t("grades.failedUpdate")) } } catch { toast.error(t("grades.failedUpdate")) } finally { setIsWorking(false) } } const handleDelete = async () => { if (!deleteItem) return setIsWorking(true) try { const res = await deleteGradeAction(deleteItem.id) if (res.success) { toast.success(res.message) setDeleteItem(null) router.refresh() } else { toast.error(res.message || t("grades.failedDelete")) } } catch { toast.error(t("grades.failedDelete")) } finally { setIsWorking(false) } } return ( <> {/* 年级概览卡片视图:让管理员一目了然看到各年级规模 */} {filteredGrades.length > 0 && (
{filteredGrades.slice(0, 8).map((g) => { const stats = statsMap.get(g.id) return (
{g.name}
{g.school.name}
router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`) } > {t("grades.gradeOverview.viewInsights")} openEdit(g)}> {t("grades.actions.edit")} setDeleteItem(g)} > {t("grades.actions.delete")}
{/* 统计指标 */}
{stats?.classCount ?? 0}
{t("grades.gradeOverview.classCount")}
{stats?.studentCount ?? 0}
{t("grades.gradeOverview.studentCount")}
{stats?.teacherCount ?? 0}
{t("grades.gradeOverview.teacherCount")}
{/* 年级主任/教学主任 */}
{t("grades.gradeOverview.gradeHead")} {g.gradeHead?.name ?? t("grades.gradeOverview.notSet")}
{t("grades.gradeOverview.teachingHead")} {g.teachingHead?.name ?? t("grades.gradeOverview.notSet")}
{/* 快捷操作 */}
) })}
)}
setQ(e.target.value || null)} />
{hasFilters ? ( ) : null}
{t("grades.list.title")}
{filteredGrades.length} {filteredGrades.length !== grades.length ? (
/ {grades.length}
) : null}
{schools.length === 0 ? ( ) : filteredGrades.length === 0 ? ( ) : (
{t("grades.column.school")} {t("grades.column.grade")} {t("grades.column.order")} {t("grades.column.gradeHead")} {t("grades.column.teachingHead")} {t("grades.column.updated")} {filteredGrades.map((g) => ( {g.school.name} {g.name} {g.order} {formatStaffDetail(g.gradeHead)} {formatStaffDetail(g.teachingHead)} {formatDate(g.updatedAt)} router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`) } > {t("grades.actions.insights")} openEdit(g)}> {t("grades.actions.edit")} setDeleteItem(g)} > {t("grades.actions.delete")} ))}
)}
{t("grades.form.createTitle")}
{ e.preventDefault() void handleCreate() }} >
{createValidation.errors.schoolId ? (
{createValidation.errors.schoolId}
) : null}
setCreateState((p) => ({ ...p, name: e.target.value }))} placeholder={t("grades.form.name")} autoFocus /> {createValidation.errors.name ? (
{createValidation.errors.name}
) : null}
setCreateState((p) => ({ ...p, order: e.target.value }))} /> {createValidation.errors.order ? (
{createValidation.errors.order}
) : null}
{ if (!open) setEditItem(null) }} > {t("grades.form.editTitle")} {editItem ? (
{ e.preventDefault() void handleUpdate() }} >
{editValidation.errors.schoolId ? (
{editValidation.errors.schoolId}
) : null}
setEditState((p) => ({ ...p, name: e.target.value }))} /> {editValidation.errors.name ? (
{editValidation.errors.name}
) : null}
setEditState((p) => ({ ...p, order: e.target.value }))} /> {editValidation.errors.order ? (
{editValidation.errors.order}
) : null}
) : null}
{ if (!open) setDeleteItem(null) }} > {t("grades.delete.title")} {t("grades.delete.description", { name: deleteItem?.name || "" })} {t("grades.delete.cancel")} {t("grades.delete.confirm")} ) }