From bf360d6e53b3ad30c0ae643363afc018c83c3d53 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 1 May 2026 10:49:37 +0300 Subject: [PATCH] feat(web): per-field error highlighting from API validation issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/api.ts | 69 +++++++++++++++++++++++++---- apps/web/src/pages/Clients.tsx | 34 ++++++++++---- apps/web/src/pages/Organization.tsx | 30 ++++++++++++- 3 files changed, 115 insertions(+), 18 deletions(-) diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 4b6bc5f..24eb51a 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -7,18 +7,71 @@ export class ApiError extends Error { /** Удобочитаемое сообщение для UI: разворачивает zod issues если есть. */ prettyMessage(): string { - if (this.code === 'validation_error' && this.details && typeof this.details === 'object') { - const d = this.details as { issues?: { fieldErrors?: Record } }; - const fields = d.issues?.fieldErrors; - if (fields) { - const parts = Object.entries(fields) - .filter(([, msgs]) => msgs && msgs.length > 0) - .map(([k, msgs]) => `${k}: ${msgs!.join(', ')}`); - if (parts.length) return `Ошибка в полях: ${parts.join('; ')}`; + if (this.code === 'validation_error') { + const fe = this.fieldErrors(); + if (Object.keys(fe).length) { + const parts = Object.entries(fe).map(([k, msg]) => `${k}: ${msg}`); + return `Ошибка в полях: ${parts.join('; ')}`; } } return `${this.code} (HTTP ${this.status})`; } + + /** Map field name → human message; пусто если не валидационная или без issues. */ + fieldErrors(): Record { + if (this.code !== 'validation_error' || !this.details || typeof this.details !== 'object') { + return {}; + } + const d = this.details as { issues?: { fieldErrors?: Record } }; + const fields = d.issues?.fieldErrors; + if (!fields) return {}; + const out: Record = {}; + 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 = { + 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(method: string, path: string, body?: unknown): Promise { diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index a3970fd..9541d38 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -24,6 +24,7 @@ export function ClientsPage() { const [q, setQ] = useState(''); const [editing, setEditing] = useState | null>(null); const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); async function load() { setError(null); @@ -45,6 +46,7 @@ export function ClientsPage() { async function save() { if (!editing) return; setError(null); + setFieldErrors({}); try { const payload = { kind: editing.kind ?? 'ul', @@ -62,9 +64,16 @@ export function ClientsPage() { await api.post('/api/clients', payload); } setEditing(null); + setFieldErrors({}); await load(); } 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: K, v: Client[K] | string) => + const set = (k: K, v: Client[K] | string) => { 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 (
@@ -171,16 +188,17 @@ export function ClientsPage() { { value: 'fl' as const, label: 'Физ. лицо' }, ]} /> - set('name', e.target.value)} /> - set('inn', e.target.value)} /> - set('kpp', e.target.value)} /> - set('address', e.target.value)} /> - set('email', e.target.value)} /> - set('phone', e.target.value)} /> + set('name', e.target.value)} error={fieldErrors.name} /> + set('inn', e.target.value)} error={fieldErrors.inn} /> + set('kpp', e.target.value)} error={fieldErrors.kpp} /> + set('address', e.target.value)} error={fieldErrors.address} /> + set('email', e.target.value)} error={fieldErrors.email} /> + set('phone', e.target.value)} error={fieldErrors.phone} /> set('contactPerson', e.target.value)} + error={fieldErrors.contactPerson} /> diff --git a/apps/web/src/pages/Organization.tsx b/apps/web/src/pages/Organization.tsx index 74341fb..646275d 100644 --- a/apps/web/src/pages/Organization.tsx +++ b/apps/web/src/pages/Organization.tsx @@ -8,6 +8,7 @@ export function OrganizationPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [savedAt, setSavedAt] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); useEffect(() => { api @@ -29,6 +30,7 @@ export function OrganizationPage() { async function save() { setSaving(true); setError(null); + setFieldErrors({}); try { const saved = await api.put('/api/organization', { name: draft.name ?? '', @@ -46,14 +48,28 @@ export function OrganizationPage() { setDraft(saved); setSavedAt(new Date()); } 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 { setSaving(false); } } - const set = (k: K, v: Organization[K] | string) => + const set = (k: K, v: Organization[K] | string) => { 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 (
@@ -66,58 +82,68 @@ export function OrganizationPage() { value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} placeholder="ООО «Моя компания»" + error={fieldErrors.name} /> set('inn', e.target.value)} placeholder="10 или 12 цифр" + error={fieldErrors.inn} /> set('kpp', e.target.value)} placeholder="9 цифр" + error={fieldErrors.kpp} /> set('ogrn', e.target.value)} placeholder="13 или 15 цифр" + error={fieldErrors.ogrn} /> set('legalAddress', e.target.value)} + error={fieldErrors.legalAddress} /> set('bankName', e.target.value)} placeholder="Точка ПАО Банка ФК Открытие" + error={fieldErrors.bankName} /> set('bankBik', e.target.value)} placeholder="9 цифр" + error={fieldErrors.bankBik} /> set('bankAccount', e.target.value)} placeholder="20 цифр" + error={fieldErrors.bankAccount} /> set('signatoryName', e.target.value)} + error={fieldErrors.signatoryName} /> set('signatoryPosition', e.target.value)} placeholder="Генеральный директор" + error={fieldErrors.signatoryPosition} />