- 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
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
"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>
|
||
)
|
||
}
|