feat(web): per-field error highlighting from API validation issues
ApiError.fieldErrors() returns map field→ru-message; forms set fieldErrors state on save failure, pass error prop to Field components (red border + inline message), clear individual error when user edits that field. Wired up: Clients, Organization. Services и Documents — позже. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+61
-8
@@ -7,18 +7,71 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
/** Удобочитаемое сообщение для UI: разворачивает zod issues если есть. */
|
/** Удобочитаемое сообщение для UI: разворачивает zod issues если есть. */
|
||||||
prettyMessage(): string {
|
prettyMessage(): string {
|
||||||
if (this.code === 'validation_error' && this.details && typeof this.details === 'object') {
|
if (this.code === 'validation_error') {
|
||||||
const d = this.details as { issues?: { fieldErrors?: Record<string, string[]> } };
|
const fe = this.fieldErrors();
|
||||||
const fields = d.issues?.fieldErrors;
|
if (Object.keys(fe).length) {
|
||||||
if (fields) {
|
const parts = Object.entries(fe).map(([k, msg]) => `${k}: ${msg}`);
|
||||||
const parts = Object.entries(fields)
|
return `Ошибка в полях: ${parts.join('; ')}`;
|
||||||
.filter(([, msgs]) => msgs && msgs.length > 0)
|
|
||||||
.map(([k, msgs]) => `${k}: ${msgs!.join(', ')}`);
|
|
||||||
if (parts.length) return `Ошибка в полях: ${parts.join('; ')}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `${this.code} (HTTP ${this.status})`;
|
return `${this.code} (HTTP ${this.status})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map field name → human message; пусто если не валидационная или без issues. */
|
||||||
|
fieldErrors(): Record<string, string> {
|
||||||
|
if (this.code !== 'validation_error' || !this.details || typeof this.details !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const d = this.details as { issues?: { fieldErrors?: Record<string, string[]> } };
|
||||||
|
const fields = d.issues?.fieldErrors;
|
||||||
|
if (!fields) return {};
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [k, msgs] of Object.entries(fields)) {
|
||||||
|
if (msgs && msgs.length > 0) out[k] = humanizeIssue(k, msgs[0]!);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_NAMES_RU: Record<string, string> = {
|
||||||
|
name: 'Название',
|
||||||
|
inn: 'ИНН',
|
||||||
|
kpp: 'КПП',
|
||||||
|
ogrn: 'ОГРН/ОГРНИП',
|
||||||
|
email: 'Email',
|
||||||
|
phone: 'Телефон',
|
||||||
|
address: 'Адрес',
|
||||||
|
legalAddress: 'Юр. адрес',
|
||||||
|
bankName: 'Банк',
|
||||||
|
bankBik: 'БИК',
|
||||||
|
bankAccount: 'Расчётный счёт',
|
||||||
|
signatoryName: 'Подписант',
|
||||||
|
signatoryPosition: 'Должность подписанта',
|
||||||
|
contactPerson: 'Контактное лицо',
|
||||||
|
notes: 'Примечания',
|
||||||
|
unit: 'Единица',
|
||||||
|
defaultPriceCents: 'Цена',
|
||||||
|
defaultVat: 'НДС',
|
||||||
|
number: 'Номер',
|
||||||
|
issuedAt: 'Дата',
|
||||||
|
clientId: 'Клиент',
|
||||||
|
};
|
||||||
|
|
||||||
|
function humanizeIssue(field: string, raw: string): string {
|
||||||
|
const ru = FIELD_NAMES_RU[field] ?? field;
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
if (lower.includes('regex') || lower.includes('invalid_string') || lower.includes('did not match')) {
|
||||||
|
if (field === 'inn') return 'нужно 10 или 12 цифр';
|
||||||
|
if (field === 'kpp' || field === 'bankBik') return 'нужно 9 цифр';
|
||||||
|
if (field === 'ogrn') return 'нужно 13 или 15 цифр';
|
||||||
|
if (field === 'bankAccount') return 'нужно 20 цифр';
|
||||||
|
return 'неверный формат';
|
||||||
|
}
|
||||||
|
if (lower.includes('email')) return 'неверный email';
|
||||||
|
if (lower.includes('too_small') || lower.includes('at least')) return 'слишком короткое значение';
|
||||||
|
if (lower.includes('too_big') || lower.includes('at most')) return 'слишком длинное значение';
|
||||||
|
if (lower.includes('required') || lower.includes('expected')) return 'обязательное поле';
|
||||||
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function ClientsPage() {
|
|||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [editing, setEditing] = useState<Partial<Client> | null>(null);
|
const [editing, setEditing] = useState<Partial<Client> | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -45,6 +46,7 @@ export function ClientsPage() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
kind: editing.kind ?? 'ul',
|
kind: editing.kind ?? 'ul',
|
||||||
@@ -62,9 +64,16 @@ export function ClientsPage() {
|
|||||||
await api.post<Client>('/api/clients', payload);
|
await api.post<Client>('/api/clients', payload);
|
||||||
}
|
}
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
setFieldErrors({});
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
|
if (e instanceof ApiError) {
|
||||||
|
const fe = e.fieldErrors();
|
||||||
|
setFieldErrors(fe);
|
||||||
|
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
|
||||||
|
} else {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +91,16 @@ export function ClientsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const set = <K extends keyof Client>(k: K, v: Client[K] | string) =>
|
const set = <K extends keyof Client>(k: K, v: Client[K] | string) => {
|
||||||
setEditing((d) => (d ? { ...d, [k]: v as Client[K] } : d));
|
setEditing((d) => (d ? { ...d, [k]: v as Client[K] } : d));
|
||||||
|
if (fieldErrors[k as string]) {
|
||||||
|
setFieldErrors((fe) => {
|
||||||
|
const next = { ...fe };
|
||||||
|
delete next[k as string];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="content">
|
<main className="content">
|
||||||
@@ -171,16 +188,17 @@ export function ClientsPage() {
|
|||||||
{ value: 'fl' as const, label: 'Физ. лицо' },
|
{ value: 'fl' as const, label: 'Физ. лицо' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Field label="Название" value={editing?.name ?? ''} onChange={(e) => set('name', e.target.value)} />
|
<Field label="Название" value={editing?.name ?? ''} onChange={(e) => set('name', e.target.value)} error={fieldErrors.name} />
|
||||||
<Field label="ИНН" value={editing?.inn ?? ''} onChange={(e) => set('inn', e.target.value)} />
|
<Field label="ИНН" value={editing?.inn ?? ''} onChange={(e) => set('inn', e.target.value)} error={fieldErrors.inn} />
|
||||||
<Field label="КПП" value={editing?.kpp ?? ''} onChange={(e) => set('kpp', e.target.value)} />
|
<Field label="КПП" value={editing?.kpp ?? ''} onChange={(e) => set('kpp', e.target.value)} error={fieldErrors.kpp} />
|
||||||
<Field label="Адрес" value={editing?.address ?? ''} onChange={(e) => set('address', e.target.value)} />
|
<Field label="Адрес" value={editing?.address ?? ''} onChange={(e) => set('address', e.target.value)} error={fieldErrors.address} />
|
||||||
<Field label="Email" type="email" value={editing?.email ?? ''} onChange={(e) => set('email', e.target.value)} />
|
<Field label="Email" type="email" value={editing?.email ?? ''} onChange={(e) => set('email', e.target.value)} error={fieldErrors.email} />
|
||||||
<Field label="Телефон" value={editing?.phone ?? ''} onChange={(e) => set('phone', e.target.value)} />
|
<Field label="Телефон" value={editing?.phone ?? ''} onChange={(e) => set('phone', e.target.value)} error={fieldErrors.phone} />
|
||||||
<Field
|
<Field
|
||||||
label="Контактное лицо"
|
label="Контактное лицо"
|
||||||
value={editing?.contactPerson ?? ''}
|
value={editing?.contactPerson ?? ''}
|
||||||
onChange={(e) => set('contactPerson', e.target.value)}
|
onChange={(e) => set('contactPerson', e.target.value)}
|
||||||
|
error={fieldErrors.contactPerson}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function OrganizationPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
@@ -29,6 +30,7 @@ export function OrganizationPage() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
try {
|
try {
|
||||||
const saved = await api.put<Organization>('/api/organization', {
|
const saved = await api.put<Organization>('/api/organization', {
|
||||||
name: draft.name ?? '',
|
name: draft.name ?? '',
|
||||||
@@ -46,14 +48,28 @@ export function OrganizationPage() {
|
|||||||
setDraft(saved);
|
setDraft(saved);
|
||||||
setSavedAt(new Date());
|
setSavedAt(new Date());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
|
if (e instanceof ApiError) {
|
||||||
|
const fe = e.fieldErrors();
|
||||||
|
setFieldErrors(fe);
|
||||||
|
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
|
||||||
|
} else {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const set = <K extends keyof Organization>(k: K, v: Organization[K] | string) =>
|
const set = <K extends keyof Organization>(k: K, v: Organization[K] | string) => {
|
||||||
setDraft((d) => ({ ...d, [k]: v as Organization[K] }));
|
setDraft((d) => ({ ...d, [k]: v as Organization[K] }));
|
||||||
|
if (fieldErrors[k as string]) {
|
||||||
|
setFieldErrors((fe) => {
|
||||||
|
const next = { ...fe };
|
||||||
|
delete next[k as string];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="content">
|
<main className="content">
|
||||||
@@ -66,58 +82,68 @@ export function OrganizationPage() {
|
|||||||
value={draft.name ?? ''}
|
value={draft.name ?? ''}
|
||||||
onChange={(e) => set('name', e.target.value)}
|
onChange={(e) => set('name', e.target.value)}
|
||||||
placeholder="ООО «Моя компания»"
|
placeholder="ООО «Моя компания»"
|
||||||
|
error={fieldErrors.name}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="ИНН"
|
label="ИНН"
|
||||||
value={draft.inn ?? ''}
|
value={draft.inn ?? ''}
|
||||||
onChange={(e) => set('inn', e.target.value)}
|
onChange={(e) => set('inn', e.target.value)}
|
||||||
placeholder="10 или 12 цифр"
|
placeholder="10 или 12 цифр"
|
||||||
|
error={fieldErrors.inn}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="КПП"
|
label="КПП"
|
||||||
value={draft.kpp ?? ''}
|
value={draft.kpp ?? ''}
|
||||||
onChange={(e) => set('kpp', e.target.value)}
|
onChange={(e) => set('kpp', e.target.value)}
|
||||||
placeholder="9 цифр"
|
placeholder="9 цифр"
|
||||||
|
error={fieldErrors.kpp}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="ОГРН/ОГРНИП"
|
label="ОГРН/ОГРНИП"
|
||||||
value={draft.ogrn ?? ''}
|
value={draft.ogrn ?? ''}
|
||||||
onChange={(e) => set('ogrn', e.target.value)}
|
onChange={(e) => set('ogrn', e.target.value)}
|
||||||
placeholder="13 или 15 цифр"
|
placeholder="13 или 15 цифр"
|
||||||
|
error={fieldErrors.ogrn}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Юр. адрес"
|
label="Юр. адрес"
|
||||||
value={draft.legalAddress ?? ''}
|
value={draft.legalAddress ?? ''}
|
||||||
onChange={(e) => set('legalAddress', e.target.value)}
|
onChange={(e) => set('legalAddress', e.target.value)}
|
||||||
|
error={fieldErrors.legalAddress}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Банк"
|
label="Банк"
|
||||||
value={draft.bankName ?? ''}
|
value={draft.bankName ?? ''}
|
||||||
onChange={(e) => set('bankName', e.target.value)}
|
onChange={(e) => set('bankName', e.target.value)}
|
||||||
placeholder="Точка ПАО Банка ФК Открытие"
|
placeholder="Точка ПАО Банка ФК Открытие"
|
||||||
|
error={fieldErrors.bankName}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="БИК"
|
label="БИК"
|
||||||
value={draft.bankBik ?? ''}
|
value={draft.bankBik ?? ''}
|
||||||
onChange={(e) => set('bankBik', e.target.value)}
|
onChange={(e) => set('bankBik', e.target.value)}
|
||||||
placeholder="9 цифр"
|
placeholder="9 цифр"
|
||||||
|
error={fieldErrors.bankBik}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Расчётный счёт"
|
label="Расчётный счёт"
|
||||||
value={draft.bankAccount ?? ''}
|
value={draft.bankAccount ?? ''}
|
||||||
onChange={(e) => set('bankAccount', e.target.value)}
|
onChange={(e) => set('bankAccount', e.target.value)}
|
||||||
placeholder="20 цифр"
|
placeholder="20 цифр"
|
||||||
|
error={fieldErrors.bankAccount}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Подписант ФИО"
|
label="Подписант ФИО"
|
||||||
value={draft.signatoryName ?? ''}
|
value={draft.signatoryName ?? ''}
|
||||||
onChange={(e) => set('signatoryName', e.target.value)}
|
onChange={(e) => set('signatoryName', e.target.value)}
|
||||||
|
error={fieldErrors.signatoryName}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Должность подписанта"
|
label="Должность подписанта"
|
||||||
value={draft.signatoryPosition ?? ''}
|
value={draft.signatoryPosition ?? ''}
|
||||||
onChange={(e) => set('signatoryPosition', e.target.value)}
|
onChange={(e) => set('signatoryPosition', e.target.value)}
|
||||||
placeholder="Генеральный директор"
|
placeholder="Генеральный директор"
|
||||||
|
error={fieldErrors.signatoryPosition}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user