Files
NextEdu/src/modules/school/components/grade-form-dialog.tsx
SpecialX ebaf03107d feat(modules-admin): update scheduling, school, settings, standards, student, textbooks, users
- scheduling: update auto-schedule-panel, schedule-change-form, schedule-change-list,

  schedule-conflicts-view, scheduling-rules-form, data-access-class-schedule, data-access

- school: update academic-year-view, departments-view, grade-form-dialog, data-access

- settings: update admin-settings-view, ai-provider-settings-card, avatar-upload,

  brand-config-card, notification-preferences-form, password-change-form,

  profile-settings-form, security-recent-logins-section, security-two-factor-section

- standards: update data-access

- student: update student-courses-view

- textbooks: update chapter-sidebar-list, create-chapter-dialog, force-graph,

  graph-kp-node, graph-prerequisite-edge, knowledge-graph-node, textbook-card,

  textbook-form-dialog, textbook-reader, textbook-settings-dialog,

  data-access-graph, data-access, use-kp-create, use-kp-delete, use-kp-update, types

- users: update user-import-dialog
2026-07-07 16:22:07 +08:00

329 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useMemo, useState } from "react"
import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl"
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
import { createGradeAction, updateGradeAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
type FormState = {
schoolId: string
name: string
order: string
gradeHeadId: string
teachingHeadId: string
}
type FormErrors = Partial<Record<keyof FormState, string>>
type GradeFormDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
editItem: GradeListItem | null
schools: SchoolListItem[]
staff: StaffOption[]
grades: GradeListItem[]
onSuccess: () => void
}
const NONE_SELECT_VALUE = "__none__"
const normalizeName = (v: string): string => v.trim().replace(/\s+/g, " ")
const parseOrder = (raw: string): number | null => {
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
}
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 ?? "",
})
/**
* 年级创建/编辑表单对话框。
*
* 根据 `editItem` 是否存在自动切换模式。内部管理表单状态与客户端校验
* 必填、长度、order 格式、同校重名检测、isDirty 检测),
* mutation 通过 useActionMutation 统一处理 loading/toast。
* 成功后调用 `onOpenChange(false)` 关闭对话框并触发 `onSuccess` 通知父组件刷新。
*/
export function GradeFormDialog({
open,
onOpenChange,
editItem,
schools,
staff,
grades,
onSuccess,
}: GradeFormDialogProps) {
const t = useTranslations("school")
const isEdit = Boolean(editItem)
const defaultSchoolId = schools[0]?.id ?? ""
const [state, setState] = useState<FormState>(() => toFormState(editItem, 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(
(formState: FormState, excludeGradeId?: string): { ok: boolean; errors: FormErrors } => {
const errors: FormErrors = {}
const schoolId = formState.schoolId.trim()
if (!schoolId) errors.schoolId = t("grades.validation.selectSchool")
const name = normalizeName(formState.name)
if (!name) errors.name = t("grades.validation.enterName")
if (name.length > 100) errors.name = t("grades.validation.nameTooLong")
const order = parseOrder(formState.order)
if (order === null) errors.order = t("grades.validation.orderInvalid")
if (schoolId && name) {
const dup = grades.find((g) => {
if (excludeGradeId && g.id === 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, grades]
)
const validation = useMemo(
() => validateForm(state, editItem?.id),
[state, editItem?.id, validateForm]
)
const isDirty = useMemo(() => {
if (!editItem) return true
const next = {
schoolId: state.schoolId.trim(),
name: normalizeName(state.name),
order: parseOrder(state.order),
gradeHeadId: state.gradeHeadId || "",
teachingHeadId: state.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, state])
const createMutation = useActionMutation({
errorMessage: t("grades.failedCreate"),
onSuccess: () => {
onOpenChange(false)
onSuccess()
},
})
const updateMutation = useActionMutation({
errorMessage: t("grades.failedUpdate"),
onSuccess: () => {
onOpenChange(false)
onSuccess()
},
})
const isWorking = createMutation.isWorking || updateMutation.isWorking
const handleSubmit = (): void => {
const result = validateForm(state, editItem?.id)
if (!result.ok) {
notify.error(Object.values(result.errors)[0] || t("grades.validation.fixForm"))
return
}
if (isEdit && !isDirty) {
notify.message(t("grades.validation.noChanges"))
return
}
const fd = new FormData()
fd.set("schoolId", state.schoolId)
fd.set("name", normalizeName(state.name))
fd.set("order", state.order)
fd.set("gradeHeadId", state.gradeHeadId)
fd.set("teachingHeadId", state.teachingHeadId)
if (isEdit && editItem) {
void updateMutation.mutate(() => updateGradeAction(editItem.id, undefined, fd))
} else {
void createMutation.mutate(() => createGradeAction(undefined, fd))
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>
{isEdit ? t("grades.form.editTitle") : t("grades.form.createTitle")}
</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
void handleSubmit()
}}
>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.school")}</Label>
<div className="col-span-3">
<Select
value={state.schoolId}
onValueChange={(v) => setState((p) => ({ ...p, schoolId: v }))}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.form.school")} />
</SelectTrigger>
<SelectContent>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{validation.errors.schoolId ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{validation.errors.schoolId}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade-name" className="text-right">
{t("grades.form.name")}
</Label>
<Input
id="grade-name"
className="col-span-3"
value={state.name}
onChange={(e) => setState((p) => ({ ...p, name: e.target.value }))}
placeholder={t("grades.form.name")}
autoFocus={!isEdit}
/>
{validation.errors.name ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{validation.errors.name}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade-order" className="text-right">
{t("grades.form.order")}
</Label>
<Input
id="grade-order"
className="col-span-3"
type="number"
inputMode="numeric"
min={0}
step={1}
value={state.order}
onChange={(e) => setState((p) => ({ ...p, order: e.target.value }))}
/>
{validation.errors.order ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{validation.errors.order}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.gradeHead")}</Label>
<div className="col-span-3">
<Select
value={state.gradeHeadId}
onValueChange={(v) =>
setState((p) => ({ ...p, gradeHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.teachingHead")}</Label>
<div className="col-span-3">
<Select
value={state.teachingHeadId}
onValueChange={(v) =>
setState((p) => ({ ...p, teachingHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isWorking}>
{t("grades.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{isEdit ? t("grades.form.save") : t("grades.form.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}