feat(ux): onBlur валидация полей во всех формах
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Public / Build + push Public (push) Waiting to run
Docker Public / Deploy Public on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Ошибки полей теперь показываются сразу после потери фокуса — без
нажатия «Сохранить». При начале ввода ошибка убирается (onChange).
Submit-валидация остаётся без изменений.

Охват: SignupForm (public), LoginPage, ForgotPasswordPage,
ResetPasswordPage, EmployeesPage, CounterpartiesPage, StoresPage,
OrganizationSettingsPage, SuperAdminOrgCreatePage, PriceTypesPage,
EmployeeRolesPage, RetailPointsPage.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-17 23:38:56 +05:00
parent 42645174e0
commit ff44afc202
12 changed files with 147 additions and 71 deletions

View file

@ -82,19 +82,28 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div> <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)} )}
<Field label="Email" error={fieldErrors.email}> <Field label="Email" error={fieldErrors.email}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} <input type="email" value={email}
onChange={(e) => { setEmail(e.target.value); setFieldErrors(p => ({...p, email: undefined})) }}
onBlur={() => { const e = validateEmail(email); setFieldErrors(p => ({...p, email: e ?? undefined})) }}
autoComplete="email" placeholder="name@example.kz" className={inputCls(!!fieldErrors.email)} /> autoComplete="email" placeholder="name@example.kz" className={inputCls(!!fieldErrors.email)} />
</Field> </Field>
<Field label="Пароль" hint="Минимум 8 символов, цифра и заглавная." error={fieldErrors.password}> <Field label="Пароль" hint="Минимум 8 символов, цифра и заглавная." error={fieldErrors.password}>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} <input type="password" value={password}
onChange={(e) => { setPassword(e.target.value); setFieldErrors(p => ({...p, password: undefined})) }}
onBlur={() => { const e = validatePassword(password); setFieldErrors(p => ({...p, password: e ?? undefined})) }}
autoComplete="new-password" className={inputCls(!!fieldErrors.password)} /> autoComplete="new-password" className={inputCls(!!fieldErrors.password)} />
</Field> </Field>
<Field label="Название магазина" error={fieldErrors.orgName}> <Field label="Название магазина" error={fieldErrors.orgName}>
<input type="text" value={orgName} onChange={(e) => setOrgName(e.target.value)} <input type="text" value={orgName}
onChange={(e) => { setOrgName(e.target.value); setFieldErrors(p => ({...p, orgName: undefined})) }}
onBlur={() => { setFieldErrors(p => ({...p, orgName: !orgName.trim() ? 'Название магазина обязательно для заполнения' : undefined})) }}
placeholder="Наименование организации" className={inputCls(!!fieldErrors.orgName)} /> placeholder="Наименование организации" className={inputCls(!!fieldErrors.orgName)} />
</Field> </Field>
<Field label="Телефон" error={fieldErrors.phone}> <Field label="Телефон" error={fieldErrors.phone}>
<PhoneInput value={phone} onChange={setPhone} required className={inputCls(!!fieldErrors.phone)} /> <PhoneInput value={phone}
onChange={(v) => { setPhone(v); setFieldErrors(p => ({...p, phone: undefined})) }}
onBlur={() => { const e = validatePhone(phone); setFieldErrors(p => ({...p, phone: e ?? undefined})) }}
required className={inputCls(!!fieldErrors.phone)} />
</Field> </Field>
<div> <div>
<div className="text-sm font-medium mb-2">Тариф</div> <div className="text-sm font-medium mb-2">Тариф</div>

View file

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { validateEmail, validatePhone } from '@/lib/validation'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
@ -51,6 +52,7 @@ export function CounterpartiesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Counterparty>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Counterparty>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL) const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const [fieldErrors, setFieldErrors] = useState<Partial<Record<'name' | 'email' | 'phone', string>>>({})
const countries = useQuery({ const countries = useQuery({
queryKey: ['countries-lookup'], queryKey: ['countries-lookup'],
@ -64,7 +66,7 @@ export function CounterpartiesPage() {
const payload = { ...rest, countryId: countryId || null } const payload = { ...rest, countryId: countryId || null }
if (id) await update.mutateAsync({ id, input: payload }) if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload) else await create.mutateAsync(payload)
setForm(null) setForm(null); setFieldErrors({})
} }
return ( return (
@ -75,7 +77,7 @@ export function CounterpartiesPage() {
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по имени, БИН, ИИН, телефону…" /> <SearchBar value={search} onChange={setSearch} placeholder="Поиск по имени, БИН, ИИН, телефону…" />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button> <Button onClick={() => { setForm(blankForm); setFieldErrors({}) }}><Plus className="w-4 h-4" /> Добавить</Button>
</> </>
} }
footer={data && data.total > 0 && ( footer={data && data.total > 0 && (
@ -89,13 +91,13 @@ export function CounterpartiesPage() {
sortKey={sortKey} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ onRowClick={(r) => { setFieldErrors({}); setForm({
id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type, id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type,
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '', bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '', countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '', bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', contactPerson: r.contactPerson ?? '', notes: r.notes ?? '',
})} }) }}
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Тип', width: '120px', sortKey: 'type', cell: (r) => typeLabel[r.type] }, { header: 'Тип', width: '120px', sortKey: 'type', cell: (r) => typeLabel[r.type] },
@ -108,7 +110,7 @@ export function CounterpartiesPage() {
<Modal <Modal
open={!!form} open={!!form}
onClose={() => setForm(null)} onClose={() => { setForm(null); setFieldErrors({}) }}
title={form?.id ? 'Редактировать контрагента' : 'Новый контрагент'} title={form?.id ? 'Редактировать контрагента' : 'Новый контрагент'}
width="max-w-3xl" width="max-w-3xl"
footer={ footer={
@ -117,7 +119,7 @@ export function CounterpartiesPage() {
<Button variant="danger" size="sm" onClick={async () => { <Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить контрагента?')) { if (confirm('Удалить контрагента?')) {
await remove.mutateAsync(form.id!) await remove.mutateAsync(form.id!)
setForm(null) setForm(null); setFieldErrors({})
} }
}}> }}>
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
@ -130,8 +132,10 @@ export function CounterpartiesPage() {
> >
{form && ( {form && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Название" className="col-span-2"> <Field label="Название" className="col-span-2" error={fieldErrors.name}>
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput value={form.name}
onChange={(e) => { setForm({ ...form, name: e.target.value }); if (fieldErrors.name) setFieldErrors(p => ({...p, name: undefined})) }}
onBlur={() => { if (!form.name.trim()) setFieldErrors(p => ({...p, name: 'Название обязательно'})) }} />
</Field> </Field>
<Field label="Юридическое название" className="col-span-2"> <Field label="Юридическое название" className="col-span-2">
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} /> <TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
@ -157,11 +161,15 @@ export function CounterpartiesPage() {
<Field label="Адрес" className="col-span-2"> <Field label="Адрес" className="col-span-2">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} /> <TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field> </Field>
<Field label="Телефон"> <Field label="Телефон" error={fieldErrors.phone}>
<PhoneInput value={form.phone} onChange={(v) => setForm({ ...form, phone: v })} /> <PhoneInput value={form.phone}
onChange={(v) => { setForm({ ...form, phone: v }); if (fieldErrors.phone) setFieldErrors(p => ({...p, phone: undefined})) }}
onBlur={() => { if (form.phone) { const e = validatePhone(form.phone); if (e) setFieldErrors(p => ({...p, phone: e})) } }} />
</Field> </Field>
<Field label="Email"> <Field label="Email" error={fieldErrors.email}>
<TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /> <TextInput type="email" value={form.email}
onChange={(e) => { setForm({ ...form, email: e.target.value }); if (fieldErrors.email) setFieldErrors(p => ({...p, email: undefined})) }}
onBlur={() => { if (form.email) { const e = validateEmail(form.email); if (e) setFieldErrors(p => ({...p, email: e})) } }} />
</Field> </Field>
<Field label="Банк" className="col-span-2"> <Field label="Банк" className="col-span-2">
<TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} /> <TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} />

View file

@ -121,6 +121,7 @@ export function EmployeeRolesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<EmployeeRoleDto>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<EmployeeRoleDto>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL) const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null)
// Шаг выбора шаблона: показывается ПЕРЕД редактированием матрицы при добавлении новой роли. // Шаг выбора шаблона: показывается ПЕРЕД редактированием матрицы при добавлении новой роли.
const [pickTemplate, setPickTemplate] = useState(false) const [pickTemplate, setPickTemplate] = useState(false)
const [templateId, setTemplateId] = useState<string>('blank') const [templateId, setTemplateId] = useState<string>('blank')
@ -160,6 +161,7 @@ export function EmployeeRolesPage() {
onRowClick={(r) => { onRowClick={(r) => {
// Системные роли — показываем форму с правами в read-only. // Системные роли — показываем форму с правами в read-only.
// Все чекбоксы disabled, кнопка «Сохранить» скрыта (см. footer). // Все чекбоксы disabled, кнопка «Сохранить» скрыта (см. footer).
setNameErr(null)
setForm({ setForm({
id: r.id, name: r.name, description: r.description ?? '', id: r.id, name: r.name, description: r.description ?? '',
isSystem: r.isSystem, permissions: { ...blankPerms(), ...r.permissions }, isSystem: r.isSystem, permissions: { ...blankPerms(), ...r.permissions },
@ -199,6 +201,7 @@ export function EmployeeRolesPage() {
if (src) perms = { ...blankPerms(), ...src.permissions } if (src) perms = { ...blankPerms(), ...src.permissions }
} }
setPickTemplate(false) setPickTemplate(false)
setNameErr(null)
setForm({ ...blankForm(), permissions: perms }) setForm({ ...blankForm(), permissions: perms })
}}>Продолжить</Button> }}>Продолжить</Button>
</> </>
@ -232,7 +235,7 @@ export function EmployeeRolesPage() {
<Modal <Modal
open={!!form} open={!!form}
onClose={() => setForm(null)} onClose={() => { setForm(null); setNameErr(null) }}
title={form?.id title={form?.id
? (form.isSystem ? `Системная роль «${form.name}» (только просмотр)` : `Редактировать роль «${form.name}»`) ? (form.isSystem ? `Системная роль «${form.name}» (только просмотр)` : `Редактировать роль «${form.name}»`)
: 'Новая роль'} : 'Новая роль'}
@ -243,7 +246,7 @@ export function EmployeeRolesPage() {
<Button variant="danger" size="sm" onClick={async () => { <Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить роль?')) { if (confirm('Удалить роль?')) {
await remove.mutateAsync(form.id!) await remove.mutateAsync(form.id!)
setForm(null) setForm(null); setNameErr(null)
} }
}}> }}>
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
@ -260,9 +263,10 @@ export function EmployeeRolesPage() {
> >
{form && ( {form && (
<div className="space-y-4"> <div className="space-y-4">
<Field label="Название *"> <Field label="Название *" error={nameErr ?? undefined}>
<TextInput value={form.name} disabled={form.isSystem} <TextInput value={form.name} disabled={form.isSystem}
onChange={(e) => setForm({ ...form, name: e.target.value })} /> onChange={(e) => { setForm({ ...form, name: e.target.value }); if (nameErr) setNameErr(null) }}
onBlur={() => { if (!form.isSystem && !form.name.trim()) setNameErr('Название обязательно') }} />
</Field> </Field>
<Field label="Описание"> <Field label="Описание">
<TextArea rows={2} value={form.description} disabled={form.isSystem} <TextArea rows={2} value={form.description} disabled={form.isSystem}

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { validateEmail, validatePhone } from '@/lib/validation'
import { Plus, Trash2, Copy } from 'lucide-react' import { Plus, Trash2, Copy } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
@ -91,6 +92,7 @@ export function EmployeesPage() {
// при попытке удалить главного администратора или себя, либо как обёртка // при попытке удалить главного администратора или себя, либо как обёртка
// над любой ошибкой 4xx/5xx с сервера. // над любой ошибкой 4xx/5xx с сервера.
const [blockedDelete, setBlockedDelete] = useState<{ title: string; body: string } | null>(null) const [blockedDelete, setBlockedDelete] = useState<{ title: string; body: string } | null>(null)
const [fieldErrors, setFieldErrors] = useState<Partial<Record<'lastName' | 'firstName' | 'email' | 'phone', string>>>({})
const roles = useQuery({ const roles = useQuery({
queryKey: ['employee-roles-lookup'], queryKey: ['employee-roles-lookup'],
@ -131,10 +133,10 @@ export function EmployeesPage() {
try { try {
if (form.id) { if (form.id) {
await update.mutateAsync({ id: form.id, input: payload }) await update.mutateAsync({ id: form.id, input: payload })
setForm(null); setActiveEmployee(null) setForm(null); setActiveEmployee(null); setFieldErrors({})
} else { } else {
const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload) const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload)
setForm(null); setActiveEmployee(null) setForm(null); setActiveEmployee(null); setFieldErrors({})
// Если сервер вернул password — показываем модалку one-shot. // Если сервер вернул password — показываем модалку one-shot.
if (res.data.generatedPassword && res.data.employee.email) { if (res.data.generatedPassword && res.data.employee.email) {
setCreatedAccount({ email: res.data.employee.email, password: res.data.generatedPassword }) setCreatedAccount({ email: res.data.employee.email, password: res.data.generatedPassword })
@ -180,7 +182,7 @@ export function EmployeesPage() {
<option value="deleted">Только удалённые</option> <option value="deleted">Только удалённые</option>
<option value="all">Все, включая удалённых</option> <option value="all">Все, включая удалённых</option>
</select> </select>
<Button onClick={() => setForm(blankForm())}> <Button onClick={() => { setForm(blankForm()); setFieldErrors({}) }}>
<Plus className="w-4 h-4" /> Добавить сотрудника <Plus className="w-4 h-4" /> Добавить сотрудника
</Button> </Button>
</> </>
@ -197,6 +199,7 @@ export function EmployeesPage() {
sortOrder={list.sortOrder} sortOrder={list.sortOrder}
onSortChange={list.setSort} onSortChange={list.setSort}
onRowClick={(r) => { onRowClick={(r) => {
setFieldErrors({})
setActiveEmployee(r) setActiveEmployee(r)
setForm({ setForm({
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '', id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
@ -242,7 +245,7 @@ export function EmployeesPage() {
<Modal <Modal
open={!!form} open={!!form}
onClose={() => { setForm(null); setActiveEmployee(null) }} onClose={() => { setForm(null); setActiveEmployee(null); setFieldErrors({}) }}
title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'} title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'}
width="max-w-xl" width="max-w-xl"
footer={ footer={
@ -287,7 +290,7 @@ export function EmployeesPage() {
try { try {
await remove.mutateAsync(form.id!) await remove.mutateAsync(form.id!)
list.refetch?.() list.refetch?.()
setForm(null); setActiveEmployee(null) setForm(null); setActiveEmployee(null); setFieldErrors({})
} catch (e) { } catch (e) {
const err = e as { response?: { data?: { error?: string } }, message?: string } const err = e as { response?: { data?: { error?: string } }, message?: string }
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось выполнить операцию' const msg = err.response?.data?.error ?? err.message ?? 'Не удалось выполнить операцию'
@ -309,11 +312,15 @@ export function EmployeesPage() {
{form && ( {form && (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Фамилия *"> <Field label="Фамилия *" error={fieldErrors.lastName}>
<TextInput value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} /> <TextInput value={form.lastName}
onChange={(e) => { setForm({ ...form, lastName: e.target.value }); if (fieldErrors.lastName) setFieldErrors(p => ({...p, lastName: undefined})) }}
onBlur={() => { if (!form.lastName.trim()) setFieldErrors(p => ({...p, lastName: 'Фамилия обязательна'})) }} />
</Field> </Field>
<Field label="Имя *"> <Field label="Имя *" error={fieldErrors.firstName}>
<TextInput value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} /> <TextInput value={form.firstName}
onChange={(e) => { setForm({ ...form, firstName: e.target.value }); if (fieldErrors.firstName) setFieldErrors(p => ({...p, firstName: undefined})) }}
onBlur={() => { if (!form.firstName.trim()) setFieldErrors(p => ({...p, firstName: 'Имя обязательно'})) }} />
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@ -325,11 +332,15 @@ export function EmployeesPage() {
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Email"> <Field label="Email" error={fieldErrors.email}>
<TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /> <TextInput type="email" value={form.email}
onChange={(e) => { setForm({ ...form, email: e.target.value }); if (fieldErrors.email) setFieldErrors(p => ({...p, email: undefined})) }}
onBlur={() => { if (form.email) { const e = validateEmail(form.email); if (e) setFieldErrors(p => ({...p, email: e})) } }} />
</Field> </Field>
<Field label="Телефон"> <Field label="Телефон" error={fieldErrors.phone}>
<PhoneInput value={form.phone} onChange={(v) => setForm({ ...form, phone: v })} /> <PhoneInput value={form.phone}
onChange={(v) => { setForm({ ...form, phone: v }); if (fieldErrors.phone) setFieldErrors(p => ({...p, phone: undefined})) }}
onBlur={() => { if (form.phone) { const e = validatePhone(form.phone); if (e) setFieldErrors(p => ({...p, phone: e})) } }} />
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">

View file

@ -51,7 +51,9 @@ export function ForgotPasswordPage() {
</p> </p>
</div> </div>
<Field label="Email" error={error ?? undefined}> <Field label="Email" error={error ?? undefined}>
<TextInput type="email" value={email} onChange={(e) => setEmail(e.target.value)} <TextInput type="email" value={email}
onChange={(e) => { setEmail(e.target.value); if (error) setError(null) }}
onBlur={() => setError(validateEmail(email))}
autoComplete="email" placeholder="name@example.kz" required /> autoComplete="email" placeholder="name@example.kz" required />
</Field> </Field>
<Button onClick={() => {}} disabled={busy} className="w-full justify-center"> <Button onClick={() => {}} disabled={busy} className="w-full justify-center">

View file

@ -65,6 +65,7 @@ export function LoginPage() {
placeholder="name@example.kz" placeholder="name@example.kz"
value={email} value={email}
onChange={(e) => { setEmail(e.target.value); if (emailErr) setEmailErr(null) }} onChange={(e) => { setEmail(e.target.value); if (emailErr) setEmailErr(null) }}
onBlur={() => setEmailErr(validateEmail(email))}
className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 ${emailErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`} className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 ${emailErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}
/> />
{emailErr && <span className="text-xs text-red-600 block mt-1">{emailErr}</span>} {emailErr && <span className="text-xs text-red-600 block mt-1">{emailErr}</span>}
@ -77,6 +78,7 @@ export function LoginPage() {
autoComplete="current-password" autoComplete="current-password"
value={password} value={password}
onChange={(e) => { setPassword(e.target.value); if (passwordErr) setPasswordErr(null) }} onChange={(e) => { setPassword(e.target.value); if (passwordErr) setPasswordErr(null) }}
onBlur={() => setPasswordErr(password ? null : messages.required)}
className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 ${passwordErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`} className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 ${passwordErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}
/> />
{passwordErr && <span className="text-xs text-red-600 block mt-1">{passwordErr}</span>} {passwordErr && <span className="text-xs text-red-600 block mt-1">{passwordErr}</span>}

View file

@ -14,10 +14,11 @@ export function OrganizationSettingsPage() {
const countries = useCountries() const countries = useCountries()
const [form, setForm] = useState<OrgSettings | null>(null) const [form, setForm] = useState<OrgSettings | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null)
// Синхронизируем форму с актуальным снимком настроек: первый раз — после // Синхронизируем форму с актуальным снимком настроек: первый раз — после
// загрузки; и каждый раз когда сервер вернул свежую версию (например после // загрузки; и каждый раз когда сервер вернул свежую версию (например после
// refetchOnMount или после save в другой вкладке). // refetchOnMount или после save в другой вкладке).
useEffect(() => { if (settings.data) setForm(settings.data) }, [settings.data]) useEffect(() => { if (settings.data) { setForm(settings.data); setNameErr(null) } }, [settings.data])
// При смене страны подтягиваем её валюту и ставку НДС (оба read-only, из справочника стран). // При смене страны подтягиваем её валюту и ставку НДС (оба read-only, из справочника стран).
const onCountryChange = (countryCode: string) => { const onCountryChange = (countryCode: string) => {
@ -65,8 +66,10 @@ export function OrganizationSettingsPage() {
<PageHeader title="Настройки организации" description="Страна, валюта, ставка НДС по умолчанию." /> <PageHeader title="Настройки организации" description="Страна, валюта, ставка НДС по умолчанию." />
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4"> <section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<Field label="Название организации"> <Field label="Название организации" error={nameErr ?? undefined}>
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput value={form.name}
onChange={(e) => { setForm({ ...form, name: e.target.value }); if (nameErr) setNameErr(null) }}
onBlur={() => { if (!form.name.trim()) setNameErr('Название обязательно') }} />
</Field> </Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">

View file

@ -27,6 +27,7 @@ export function PriceTypesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<PriceType>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<PriceType>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL) const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null)
const qc = useQueryClient() const qc = useQueryClient()
const save = async () => { const save = async () => {
@ -40,7 +41,7 @@ export function PriceTypesPage() {
// явно тригерим перефетч чтобы карточка товара сразу увидела новый // явно тригерим перефетч чтобы карточка товара сразу увидела новый
// IsRequired/IsRetail без перезагрузки страницы. // IsRequired/IsRetail без перезагрузки страницы.
await qc.invalidateQueries({ queryKey: ['lookup:price-types'] }) await qc.invalidateQueries({ queryKey: ['lookup:price-types'] })
setForm(null) setForm(null); setNameErr(null)
} }
return ( return (
@ -65,11 +66,11 @@ export function PriceTypesPage() {
sortKey={sortKey} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ onRowClick={(r) => { setNameErr(null); setForm({
id: r.id, name: r.name, id: r.id, name: r.name,
isRequired: r.isRequired, isSystem: r.isSystem, isRequired: r.isRequired, isSystem: r.isSystem,
isRetail: r.isRetail, sortOrder: r.sortOrder, isRetail: r.isRetail, sortOrder: r.sortOrder,
})} }) }}
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => ( { header: 'Название', sortKey: 'name', cell: (r) => (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
@ -85,7 +86,7 @@ export function PriceTypesPage() {
<Modal <Modal
open={!!form} open={!!form}
onClose={() => setForm(null)} onClose={() => { setForm(null); setNameErr(null) }}
title={form?.id ? 'Редактировать тип цены' : 'Новый тип цены'} title={form?.id ? 'Редактировать тип цены' : 'Новый тип цены'}
footer={ footer={
<> <>
@ -94,7 +95,7 @@ export function PriceTypesPage() {
if (confirm('Удалить тип цены?')) { if (confirm('Удалить тип цены?')) {
await remove.mutateAsync(form.id!) await remove.mutateAsync(form.id!)
await qc.invalidateQueries({ queryKey: ['lookup:price-types'] }) await qc.invalidateQueries({ queryKey: ['lookup:price-types'] })
setForm(null) setForm(null); setNameErr(null)
} }
}}> }}>
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
@ -112,8 +113,10 @@ export function PriceTypesPage() {
Системная запись. Имя можно переименовать; флаг «Обязательно» зафиксирован, удаление запрещено. Системная запись. Имя можно переименовать; флаг «Обязательно» зафиксирован, удаление запрещено.
</p> </p>
)} )}
<Field label="Название"> <Field label="Название" error={nameErr ?? undefined}>
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput value={form.name}
onChange={(e) => { setForm({ ...form, name: e.target.value }); if (nameErr) setNameErr(null) }}
onBlur={() => { if (!form.name.trim()) setNameErr('Название обязательно') }} />
</Field> </Field>
<Field label="Порядок сортировки"> <Field label="Порядок сортировки">
<TextInput type="number" <TextInput type="number"

View file

@ -19,6 +19,7 @@ export function ResetPasswordPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
const [pwErr, setPwErr] = useState<string | null>(null)
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@ -55,13 +56,17 @@ export function ResetPasswordPage() {
Аккаунт {email ? <strong>{email}</strong> : 'не указан'}. Аккаунт {email ? <strong>{email}</strong> : 'не указан'}.
</p> </p>
</div> </div>
<Field label="Новый пароль"> <Field label="Новый пароль" error={pwErr ?? undefined}>
<TextInput type="password" value={password} <TextInput type="password" value={password}
onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required /> onChange={(e) => { setPassword(e.target.value); if (pwErr) setPwErr(null) }}
onBlur={() => setPwErr(validatePassword(password))}
autoComplete="new-password" required />
</Field> </Field>
<Field label="Повторите пароль" error={error ?? undefined}> <Field label="Повторите пароль" error={error ?? undefined}>
<TextInput type="password" value={confirm} <TextInput type="password" value={confirm}
onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required /> onChange={(e) => { setConfirm(e.target.value); if (error) setError(null) }}
onBlur={() => { if (!validatePassword(password) && password !== confirm) setError('Пароли не совпадают.') }}
autoComplete="new-password" required />
</Field> </Field>
<Button onClick={() => {}} disabled={busy} className="w-full justify-center"> <Button onClick={() => {}} disabled={busy} className="w-full justify-center">
{busy ? 'Сохраняю…' : 'Установить пароль'} {busy ? 'Сохраняю…' : 'Установить пароль'}

View file

@ -35,6 +35,7 @@ export function RetailPointsPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<RetailPoint>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<RetailPoint>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL) const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null)
const stores = useQuery({ const stores = useQuery({
queryKey: ['stores-lookup'], queryKey: ['stores-lookup'],
@ -47,7 +48,7 @@ export function RetailPointsPage() {
const { id, ...payload } = form const { id, ...payload } = form
if (id) await update.mutateAsync({ id, input: payload }) if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload) else await create.mutateAsync(payload)
setForm(null) setForm(null); setNameErr(null)
} }
const firstStore = stores.data?.[0]?.id ?? '' const firstStore = stores.data?.[0]?.id ?? ''
@ -60,7 +61,7 @@ export function RetailPointsPage() {
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} /> <SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm(firstStore))} disabled={!firstStore}> <Button onClick={() => { setForm(blankForm(firstStore)); setNameErr(null) }} disabled={!firstStore}>
<Plus className="w-4 h-4" /> Добавить <Plus className="w-4 h-4" /> Добавить
</Button> </Button>
</> </>
@ -76,12 +77,12 @@ export function RetailPointsPage() {
sortKey={sortKey} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ onRowClick={(r) => { setNameErr(null); setForm({
id: r.id, name: r.name, code: r.code ?? '', storeId: r.storeId, id: r.id, name: r.name, code: r.code ?? '', storeId: r.storeId,
address: r.address ?? '', phone: r.phone ?? '', address: r.address ?? '', phone: r.phone ?? '',
fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '', fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '',
isActive: r.isActive, isActive: r.isActive,
})} }) }}
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Код', width: '120px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> }, { header: 'Код', width: '120px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
@ -95,7 +96,7 @@ export function RetailPointsPage() {
<Modal <Modal
open={!!form} open={!!form}
onClose={() => setForm(null)} onClose={() => { setForm(null); setNameErr(null) }}
title={form?.id ? 'Редактировать кассу' : 'Новая касса'} title={form?.id ? 'Редактировать кассу' : 'Новая касса'}
footer={ footer={
<> <>
@ -103,7 +104,7 @@ export function RetailPointsPage() {
<Button variant="danger" size="sm" onClick={async () => { <Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить кассу?')) { if (confirm('Удалить кассу?')) {
await remove.mutateAsync(form.id!) await remove.mutateAsync(form.id!)
setForm(null) setForm(null); setNameErr(null)
} }
}}> }}>
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
@ -117,8 +118,10 @@ export function RetailPointsPage() {
{form && ( {form && (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Название"> <Field label="Название" error={nameErr ?? undefined}>
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput value={form.name}
onChange={(e) => { setForm({ ...form, name: e.target.value }); if (nameErr) setNameErr(null) }}
onBlur={() => { if (!form.name.trim()) setNameErr('Название обязательно') }} />
</Field> </Field>
<Field label="Код"> <Field label="Код">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /> <TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />

View file

@ -32,13 +32,14 @@ export function StoresPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Store>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Store>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL) const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null)
const save = async () => { const save = async () => {
if (!form) return if (!form) return
const { id, ...payload } = form const { id, ...payload } = form
if (id) await update.mutateAsync({ id, input: payload }) if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload) else await create.mutateAsync(payload)
setForm(null) setForm(null); setNameErr(null)
} }
return ( return (
@ -49,7 +50,7 @@ export function StoresPage() {
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} /> <SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button> <Button onClick={() => { setForm(blankForm); setNameErr(null) }}><Plus className="w-4 h-4" /> Добавить</Button>
</> </>
} }
footer={data && data.total > 0 && ( footer={data && data.total > 0 && (
@ -63,11 +64,11 @@ export function StoresPage() {
sortKey={sortKey} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ onRowClick={(r) => { setNameErr(null); setForm({
id: r.id, name: r.name, code: r.code ?? '', id: r.id, name: r.name, code: r.code ?? '',
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '', address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
isMain: r.isMain, isActive: r.isActive, isMain: r.isMain, isActive: r.isActive,
})} }) }}
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Код', width: '120px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> }, { header: 'Код', width: '120px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
@ -80,7 +81,7 @@ export function StoresPage() {
<Modal <Modal
open={!!form} open={!!form}
onClose={() => setForm(null)} onClose={() => { setForm(null); setNameErr(null) }}
title={form?.id ? 'Редактировать склад' : 'Новый склад'} title={form?.id ? 'Редактировать склад' : 'Новый склад'}
footer={ footer={
<> <>
@ -88,7 +89,7 @@ export function StoresPage() {
<Button variant="danger" size="sm" onClick={async () => { <Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить склад?')) { if (confirm('Удалить склад?')) {
await remove.mutateAsync(form.id!) await remove.mutateAsync(form.id!)
setForm(null) setForm(null); setNameErr(null)
} }
}}> }}>
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
@ -102,8 +103,10 @@ export function StoresPage() {
{form && ( {form && (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Название"> <Field label="Название" error={nameErr ?? undefined}>
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput value={form.name}
onChange={(e) => { setForm({ ...form, name: e.target.value }); if (nameErr) setNameErr(null) }}
onBlur={() => { if (!form.name.trim()) setNameErr('Название обязательно') }} />
</Field> </Field>
<Field label="Код"> <Field label="Код">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /> <TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />

View file

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { validateEmail, validatePhone } from '@/lib/validation'
import { Save, Copy } from 'lucide-react' import { Save, Copy } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -27,6 +28,8 @@ export function SuperAdminOrgCreatePage() {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState<{ email: string; password: string } | null>(null) const [done, setDone] = useState<{ email: string; password: string } | null>(null)
type SAFieldErrors = Partial<Record<'name' | 'email' | 'phone' | 'adminLast' | 'adminFirst' | 'adminEmail', string>>
const [fieldErrors, setFieldErrors] = useState<SAFieldErrors>({})
const onCountryChange = (cc: string) => { const onCountryChange = (cc: string) => {
setCountryCode(cc) setCountryCode(cc)
@ -62,8 +65,10 @@ export function SuperAdminOrgCreatePage() {
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>} {error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3"> <section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="text-sm font-semibold">Реквизиты организации</h3> <h3 className="text-sm font-semibold">Реквизиты организации</h3>
<Field label="Название *"> <Field label="Название *" error={fieldErrors.name}>
<TextInput value={name} onChange={(e) => setName(e.target.value)} /> <TextInput value={name}
onChange={(e) => { setName(e.target.value); if (fieldErrors.name) setFieldErrors(p => ({...p, name: undefined})) }}
onBlur={() => { if (!name.trim()) setFieldErrors(p => ({...p, name: 'Название обязательно'})) }} />
</Field> </Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Страна *"> <Field label="Страна *">
@ -80,20 +85,38 @@ export function SuperAdminOrgCreatePage() {
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="БИН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} placeholder="12 цифр" maxLength={12} inputMode="numeric" /></Field> <Field label="БИН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} placeholder="12 цифр" maxLength={12} inputMode="numeric" /></Field>
<Field label="Телефон"><PhoneInput value={phone} onChange={setPhone} /></Field> <Field label="Телефон" error={fieldErrors.phone}>
<PhoneInput value={phone}
onChange={(v) => { setPhone(v); if (fieldErrors.phone) setFieldErrors(p => ({...p, phone: undefined})) }}
onBlur={() => { if (phone) { const e = validatePhone(phone); if (e) setFieldErrors(p => ({...p, phone: e})) } }} />
</Field>
</div> </div>
<Field label="Адрес"><TextInput value={address} onChange={(e) => setAddress(e.target.value)} /></Field> <Field label="Адрес"><TextInput value={address} onChange={(e) => setAddress(e.target.value)} /></Field>
<Field label="Email организации"><TextInput type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field> <Field label="Email организации" error={fieldErrors.email}>
<TextInput type="email" value={email}
onChange={(e) => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(p => ({...p, email: undefined})) }}
onBlur={() => { if (email) { const e = validateEmail(email); if (e) setFieldErrors(p => ({...p, email: e})) } }} />
</Field>
</section> </section>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3"> <section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="text-sm font-semibold">Главный администратор</h3> <h3 className="text-sm font-semibold">Главный администратор</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Фамилия *"><TextInput value={adminLast} onChange={(e) => setAdminLast(e.target.value)} /></Field> <Field label="Фамилия *" error={fieldErrors.adminLast}>
<Field label="Имя *"><TextInput value={adminFirst} onChange={(e) => setAdminFirst(e.target.value)} /></Field> <TextInput value={adminLast}
onChange={(e) => { setAdminLast(e.target.value); if (fieldErrors.adminLast) setFieldErrors(p => ({...p, adminLast: undefined})) }}
onBlur={() => { if (!adminLast.trim()) setFieldErrors(p => ({...p, adminLast: 'Фамилия обязательна'})) }} />
</Field>
<Field label="Имя *" error={fieldErrors.adminFirst}>
<TextInput value={adminFirst}
onChange={(e) => { setAdminFirst(e.target.value); if (fieldErrors.adminFirst) setFieldErrors(p => ({...p, adminFirst: undefined})) }}
onBlur={() => { if (!adminFirst.trim()) setFieldErrors(p => ({...p, adminFirst: 'Имя обязательно'})) }} />
</Field>
</div> </div>
<Field label="Email (логин) *"> <Field label="Email (логин) *" error={fieldErrors.adminEmail}>
<TextInput type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} /> <TextInput type="email" value={adminEmail}
onChange={(e) => { setAdminEmail(e.target.value); if (fieldErrors.adminEmail) setFieldErrors(p => ({...p, adminEmail: undefined})) }}
onBlur={() => { const e = validateEmail(adminEmail); if (e) setFieldErrors(p => ({...p, adminEmail: e})) }} />
</Field> </Field>
<Field label="Должность"> <Field label="Должность">
<TextInput value={adminPosition} onChange={(e) => setAdminPosition(e.target.value)} /> <TextInput value={adminPosition} onChange={(e) => setAdminPosition(e.target.value)} />