diff --git a/src/food-market.web/src/lib/api.ts b/src/food-market.web/src/lib/api.ts index f18a8f8..79b3ada 100644 --- a/src/food-market.web/src/lib/api.ts +++ b/src/food-market.web/src/lib/api.ts @@ -96,7 +96,7 @@ api.interceptors.response.use( // если страница не отображала mutation.error явно. const status = error.response?.status if (status && status >= 400 && status !== 401 && !original.__silent) { - toast.error(humanizeError(error), { title: errorTitle(status) }) + toast.error(humanizeAxios(error), { title: errorTitle(status) }) } return Promise.reject(error) }, @@ -115,8 +115,18 @@ function errorTitle(status: number): string | undefined { /** Извлекает человеко-читаемый текст из ответа API. Бэкенд использует * ProblemDetails (RFC 7807) с `title`/`detail`/`errors`; иногда отдаёт * простой `message`/`error`/`error_description`. Падать в крайнем случае - * — на статус-текст («Internal Server Error»). */ -function humanizeError(err: AxiosError): string { + * — на статус-текст («Internal Server Error»). Экспортируется, чтобы + * страницы с form-level error display'ем (ProductEditPage и др.) могли + * показывать ту же подсказку, что и toast. */ +export function humanizeError(err: AxiosError | Error): string { + if (!('isAxiosError' in err) && !((err as AxiosError).response)) { + // обычный Error (не axios) — возвращаем message + return err.message + } + return humanizeAxios(err as AxiosError) +} + +function humanizeAxios(err: AxiosError): string { const data = err.response?.data as Record | undefined if (data && typeof data === 'object') { // ASP.NET validation errors: { errors: { Field: ['msg', ...] } } diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index f3b5d2c..5c85891 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, type FormEvent, type ReactNode } from 'react' import { useNavigate, useParams, Link } from 'react-router-dom' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react' -import { api } from '@/lib/api' +import { api, humanizeError } from '@/lib/api' import { Button } from '@/components/Button' import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field' import { @@ -177,10 +177,7 @@ export function ProductEditPage() { qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }) navigate(created ? `/catalog/products/${created.id}` : '/catalog/products') }, - onError: (e: Error) => { - const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message - setError(msg) - }, + onError: (e: Error) => setError(humanizeError(e)), meta: { successMessage: 'Сохранено' }, }) @@ -213,7 +210,9 @@ export function ProductEditPage() { .filter((pt) => pt.isRequired) .filter((pt) => { const row = form.prices.find((p) => p.priceTypeId === pt.id) - return !row || row.amount <= 0 + // !row.currencyId — гонка currencies.data: если попало пустое значение, + // canSave должен остаться disabled пока currencies не подгрузятся. + return !row || row.amount <= 0 || !row.currencyId }) const canSave = form.name.trim().length > 0 @@ -447,6 +446,7 @@ export function ProductEditPage() { { if (n == null) { setForm({ ...form, prices: form.prices.filter(x => x.priceTypeId !== pt.id) }) @@ -455,7 +455,12 @@ export function ProductEditPage() { if (idx >= 0) { updatePrice(idx, { amount: n }) } else { - const cur = currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' + // Race-guard: пока currencies.data не загрузились — не + // создаём строку с пустым currencyId (сервер вернёт + // невнятный JSON-validation 400). Поле disabled до + // загрузки, эта ветка — двойная страховка. + const cur = currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id + if (!cur) return setForm({ ...form, prices: [...form.prices, { priceTypeId: pt.id, amount: n, currencyId: cur }] }) } }}