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:
admin
2026-05-01 10:49:37 +03:00
parent 747246197a
commit bf360d6e53
3 changed files with 115 additions and 18 deletions
+61 -8
View File
@@ -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> {
+26 -8
View File
@@ -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>
+28 -2
View File
@@ -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>