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
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:
parent
42645174e0
commit
ff44afc202
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 })} />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 ? 'Сохраняю…' : 'Установить пароль'}
|
||||||
|
|
|
||||||
|
|
@ -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 })} />
|
||||||
|
|
|
||||||
|
|
@ -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 })} />
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue