diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index baaeb64..d162fc2 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -57,6 +57,8 @@ import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettin import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage' import { ResetPasswordPage } from '@/pages/ResetPasswordPage' import { RoleGuard } from '@/components/RoleGuard' +import { Toaster } from '@/components/Toaster' +import { toast } from '@/lib/toast' const queryClient = new QueryClient({ defaultOptions: { @@ -64,12 +66,25 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, retry: 1, }, + mutations: { + // axios interceptor показывает toast.error на 4xx/5xx — здесь только + // success-сторона. Текст управляется через mutation.meta.successMessage + // (опт-аут: `successMessage: false`, или просто не задавать ничего). + onSuccess: (_data, _vars, _ctx, mutation) => { + const meta = mutation?.meta as { successMessage?: string | false } | undefined + if (meta?.successMessage === false) return + if (typeof meta?.successMessage === 'string' && meta.successMessage) { + toast.success(meta.successMessage) + } + }, + }, }, }) export default function App() { return ( + } /> diff --git a/src/food-market.web/src/components/Toaster.tsx b/src/food-market.web/src/components/Toaster.tsx new file mode 100644 index 0000000..0b53b69 --- /dev/null +++ b/src/food-market.web/src/components/Toaster.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react' +import { CheckCircle2, XCircle, Info, X } from 'lucide-react' +import { subscribeToasts, dismiss, type ToastEntry } from '@/lib/toast' + +/** + * Контейнер тоастов: фиксированный top-right, max-width 380px, мобильно + * растягивается на ширину экрана с padding. Подписывается на + * `subscribeToasts()` и рендерит активные. Каждый тоаст знает свой + * duration (5с по умолчанию) — setTimeout живёт в toast.ts, тут только + * UI и кнопка закрыть вручную. + */ +export function Toaster() { + const [items, setItems] = useState([]) + + useEffect(() => { + return subscribeToasts(setItems) + }, []) + + if (items.length === 0) return null + + return ( +
+ {items.map(t => ( + + ))} +
+ ) +} + +function ToastCard({ entry }: { entry: ToastEntry }) { + const palette = { + success: 'bg-emerald-50 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800 text-emerald-900 dark:text-emerald-100', + error: 'bg-red-50 dark:bg-red-900/40 border-red-200 dark:border-red-800 text-red-900 dark:text-red-100', + info: 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100', + }[entry.kind] + const iconColor = { + success: 'text-emerald-600', + error: 'text-red-600', + info: 'text-slate-600', + }[entry.kind] + const Icon = entry.kind === 'success' ? CheckCircle2 : entry.kind === 'error' ? XCircle : Info + + return ( +
+ +
+ {entry.title &&
{entry.title}
} +
{entry.message}
+
+ +
+ ) +} diff --git a/src/food-market.web/src/lib/api.ts b/src/food-market.web/src/lib/api.ts index c8692e7..f18a8f8 100644 --- a/src/food-market.web/src/lib/api.ts +++ b/src/food-market.web/src/lib/api.ts @@ -1,5 +1,6 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' import { getAccessToken, refreshTokens, clearTokens } from './auth' +import { toast } from './toast' export const api = axios.create({ baseURL: '', @@ -73,7 +74,7 @@ let refreshing: Promise | null = null api.interceptors.response.use( (res) => res, async (error: AxiosError) => { - const original = error.config as InternalAxiosRequestConfig & { __retried?: boolean } + const original = error.config as InternalAxiosRequestConfig & { __retried?: boolean; __silent?: boolean } if (error.response?.status === 401 && !original.__retried) { original.__retried = true refreshing ??= refreshTokens().finally(() => { refreshing = null }) @@ -87,7 +88,48 @@ api.interceptors.response.use( if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { window.location.href = '/login' } + // На редиректе ничего не показываем — иначе мигнёт «не авторизован». + return Promise.reject(error) + } + // Toast: 4xx/5xx (кроме 401 и __silent=true) → читаем message из ответа. + // Старое поведение было «молчаливый rej», ошибки не доходили до юзера + // если страница не отображала mutation.error явно. + const status = error.response?.status + if (status && status >= 400 && status !== 401 && !original.__silent) { + toast.error(humanizeError(error), { title: errorTitle(status) }) } return Promise.reject(error) }, ) + +function errorTitle(status: number): string | undefined { + if (status === 403) return 'Нет доступа' + if (status === 404) return 'Не найдено' + if (status === 409) return 'Конфликт' + if (status === 422) return 'Проверьте поля' + if (status === 429) return 'Слишком много запросов' + if (status >= 500) return 'Ошибка сервера' + return undefined +} + +/** Извлекает человеко-читаемый текст из ответа API. Бэкенд использует + * ProblemDetails (RFC 7807) с `title`/`detail`/`errors`; иногда отдаёт + * простой `message`/`error`/`error_description`. Падать в крайнем случае + * — на статус-текст («Internal Server Error»). */ +function humanizeError(err: AxiosError): string { + const data = err.response?.data as Record | undefined + if (data && typeof data === 'object') { + // ASP.NET validation errors: { errors: { Field: ['msg', ...] } } + const errs = data.errors as Record | undefined + if (errs && typeof errs === 'object') { + const first = Object.values(errs).flat()[0] + if (typeof first === 'string' && first.trim()) return first + } + const detail = data.detail ?? data.message ?? data.error_description ?? data.error + if (typeof detail === 'string' && detail.trim()) return detail + const title = data.title + if (typeof title === 'string' && title.trim()) return title + } + if (err.message && !err.message.toLowerCase().includes('request failed')) return err.message + return `Ошибка ${err.response?.status ?? ''} ${err.response?.statusText ?? ''}`.trim() +} diff --git a/src/food-market.web/src/lib/toast.ts b/src/food-market.web/src/lib/toast.ts new file mode 100644 index 0000000..2f6b1d1 --- /dev/null +++ b/src/food-market.web/src/lib/toast.ts @@ -0,0 +1,70 @@ +/** + * Минимальная toast-система — собственная, без зависимостей (react-hot-toast + * не хотим тянуть ради одного пункта). Subscribe/publish, autoclose через + * 5 секунд, position top-right. + * + * Использование: + * import { toast } from '@/lib/toast' + * toast.success('Сохранено') + * toast.error('Не удалось сохранить — проверьте поля') + * + * Рендер: в App.tsx один раз. + */ + +export type ToastKind = 'success' | 'error' | 'info' +export interface ToastEntry { + id: number + kind: ToastKind + message: string + /** опционально — заголовок жирным сверху */ + title?: string + /** опционально — миллисекунды до автозакрытия; 0 = не закрывать */ + duration: number +} + +type Listener = (toasts: ToastEntry[]) => void + +let counter = 1 +let entries: ToastEntry[] = [] +const listeners = new Set() + +function emit() { + for (const l of listeners) l(entries.slice()) +} + +export function subscribeToasts(l: Listener): () => void { + listeners.add(l) + l(entries.slice()) + return () => { listeners.delete(l) } +} + +function add(kind: ToastKind, message: string, opts?: { title?: string; duration?: number }) { + const id = counter++ + const duration = opts?.duration ?? 5000 + // Дедуп: подряд идущие одинаковые сообщения не плодим. + const last = entries[entries.length - 1] + if (last && last.kind === kind && last.message === message && last.title === opts?.title) { + return last.id + } + entries = [...entries, { id, kind, message, title: opts?.title, duration }] + emit() + if (duration > 0) { + setTimeout(() => dismiss(id), duration) + } + return id +} + +export function dismiss(id: number) { + entries = entries.filter(e => e.id !== id) + emit() +} + +export const toast = { + success: (message: string, opts?: { title?: string; duration?: number }) => + add('success', message, opts), + error: (message: string, opts?: { title?: string; duration?: number }) => + add('error', message, opts), + info: (message: string, opts?: { title?: string; duration?: number }) => + add('info', message, opts), + dismiss, +} diff --git a/src/food-market.web/src/lib/useCatalog.ts b/src/food-market.web/src/lib/useCatalog.ts index d903fc6..3cdf6e5 100644 --- a/src/food-market.web/src/lib/useCatalog.ts +++ b/src/food-market.web/src/lib/useCatalog.ts @@ -39,17 +39,22 @@ export function useCatalogList(url: string, extraParams: Record (await api.post(url, input)).data, onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }), + meta: { successMessage: 'Создано' }, }) const update = useMutation({ mutationFn: async ({ id, input }: { id: string; input: unknown }) => (await api.put(`${url}/${id}`, input)).data, onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }), + meta: { successMessage: 'Сохранено' }, }) const remove = useMutation({ mutationFn: async (id: string) => (await api.delete(`${url}/${id}`)).data, onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }), + meta: { successMessage: 'Удалено' }, }) return { create, update, remove } } diff --git a/src/food-market.web/src/pages/DemandEditPage.tsx b/src/food-market.web/src/pages/DemandEditPage.tsx index 3bd5181..ed79117 100644 --- a/src/food-market.web/src/pages/DemandEditPage.tsx +++ b/src/food-market.web/src/pages/DemandEditPage.tsx @@ -142,6 +142,7 @@ export function DemandEditPage() { navigate(created ? `/sales/demands/${created.id}` : `/sales/demands/${id}`) }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Сохранено' }, }) const post = useMutation({ @@ -156,6 +157,7 @@ export function DemandEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Проведено' }, }) const unpost = useMutation({ @@ -166,12 +168,14 @@ export function DemandEditPage() { existing.refetch() }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Снято с проведения' }, }) const remove = useMutation({ mutationFn: async () => { await api.delete(`/api/sales/demands/${id}`) }, onSuccess: () => navigate('/sales/demands'), onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Удалено' }, }) const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } diff --git a/src/food-market.web/src/pages/EnterEditPage.tsx b/src/food-market.web/src/pages/EnterEditPage.tsx index fb8fb7e..b912fd4 100644 --- a/src/food-market.web/src/pages/EnterEditPage.tsx +++ b/src/food-market.web/src/pages/EnterEditPage.tsx @@ -124,6 +124,7 @@ export function EnterEditPage() { navigate(created ? `/inventory/enters/${created.id}` : `/inventory/enters/${id}`) }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Сохранено' }, }) const post = useMutation({ @@ -135,6 +136,7 @@ export function EnterEditPage() { existing.refetch() }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Проведено' }, }) const unpost = useMutation({ @@ -149,12 +151,14 @@ export function EnterEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Снято с проведения' }, }) const remove = useMutation({ mutationFn: async () => { await api.delete(`/api/inventory/enters/${id}`) }, onSuccess: () => navigate('/inventory/enters'), onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Удалено' }, }) const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } diff --git a/src/food-market.web/src/pages/InventoryEditPage.tsx b/src/food-market.web/src/pages/InventoryEditPage.tsx index 0f63c50..1aa9e84 100644 --- a/src/food-market.web/src/pages/InventoryEditPage.tsx +++ b/src/food-market.web/src/pages/InventoryEditPage.tsx @@ -104,6 +104,7 @@ export function InventoryEditPage() { navigate(`/inventory/inventories/${created.id}`) }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Сохранено' }, }) const save = useMutation({ @@ -122,6 +123,7 @@ export function InventoryEditPage() { existing.refetch() }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Сохранено' }, }) const post = useMutation({ @@ -136,6 +138,7 @@ export function InventoryEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Проведено' }, }) const unpost = useMutation({ @@ -149,12 +152,14 @@ export function InventoryEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Снято с проведения' }, }) const remove = useMutation({ mutationFn: async () => { await api.delete(`/api/inventory/inventories/${id}`) }, onSuccess: () => navigate('/inventory/inventories'), onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Удалено' }, }) const onSubmit = (e: FormEvent) => { diff --git a/src/food-market.web/src/pages/LossEditPage.tsx b/src/food-market.web/src/pages/LossEditPage.tsx index 19f93d4..885b8cf 100644 --- a/src/food-market.web/src/pages/LossEditPage.tsx +++ b/src/food-market.web/src/pages/LossEditPage.tsx @@ -126,6 +126,7 @@ export function LossEditPage() { navigate(created ? `/inventory/losses/${created.id}` : `/inventory/losses/${id}`) }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Сохранено' }, }) const post = useMutation({ @@ -140,6 +141,7 @@ export function LossEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Проведено' }, }) const unpost = useMutation({ @@ -151,12 +153,14 @@ export function LossEditPage() { existing.refetch() }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Снято с проведения' }, }) const remove = useMutation({ mutationFn: async () => { await api.delete(`/api/inventory/losses/${id}`) }, onSuccess: () => navigate('/inventory/losses'), onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Удалено' }, }) const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx index ef67d91..451371e 100644 --- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx @@ -63,6 +63,7 @@ export function OrganizationSettingsPage() { if (d) setForm(d) qc.invalidateQueries({ queryKey: ['/api/organization/settings'] }) }, + meta: { successMessage: 'Настройки сохранены' }, }) if (!form) return
Загрузка…
diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 1ac03ef..6db8c17 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -178,6 +178,7 @@ export function ProductEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Сохранено' }, }) const remove = useMutation({ @@ -186,6 +187,7 @@ export function ProductEditPage() { qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }) navigate('/catalog/products') }, + meta: { successMessage: 'Удалено' }, }) const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } diff --git a/src/food-market.web/src/pages/RetailSaleEditPage.tsx b/src/food-market.web/src/pages/RetailSaleEditPage.tsx index 18fcf6e..8d3aaa6 100644 --- a/src/food-market.web/src/pages/RetailSaleEditPage.tsx +++ b/src/food-market.web/src/pages/RetailSaleEditPage.tsx @@ -146,6 +146,7 @@ export function RetailSaleEditPage() { navigate(created ? `/sales/retail/${created.id}` : `/sales/retail/${id}`) }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Сохранено' }, }) const post = useMutation({ @@ -157,6 +158,7 @@ export function RetailSaleEditPage() { existing.refetch() }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Проведено' }, }) const unpost = useMutation({ @@ -168,12 +170,14 @@ export function RetailSaleEditPage() { existing.refetch() }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Снято с проведения' }, }) const remove = useMutation({ mutationFn: async () => { await api.delete(`/api/sales/retail/${id}`) }, onSuccess: () => navigate('/sales/retail'), onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Удалено' }, }) const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } diff --git a/src/food-market.web/src/pages/SupplierReturnEditPage.tsx b/src/food-market.web/src/pages/SupplierReturnEditPage.tsx index c4110c8..75c9c88 100644 --- a/src/food-market.web/src/pages/SupplierReturnEditPage.tsx +++ b/src/food-market.web/src/pages/SupplierReturnEditPage.tsx @@ -130,6 +130,7 @@ export function SupplierReturnEditPage() { navigate(created ? `/purchases/supplier-returns/${created.id}` : `/purchases/supplier-returns/${id}`) }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Сохранено' }, }) const post = useMutation({ @@ -144,6 +145,7 @@ export function SupplierReturnEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Проведено' }, }) const unpost = useMutation({ @@ -154,12 +156,14 @@ export function SupplierReturnEditPage() { existing.refetch() }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Снято с проведения' }, }) const remove = useMutation({ mutationFn: async () => { await api.delete(`/api/purchases/supplier-returns/${id}`) }, onSuccess: () => navigate('/purchases/supplier-returns'), onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Удалено' }, }) const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index 810ca40..6b2db8b 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -169,6 +169,7 @@ export function SupplyEditPage() { navigate(created ? `/purchases/supplies/${created.id}` : `/purchases/supplies/${id}`) }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Сохранено' }, }) const post = useMutation({ @@ -180,6 +181,7 @@ export function SupplyEditPage() { existing.refetch() }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Проведено' }, }) const unpost = useMutation({ @@ -194,12 +196,14 @@ export function SupplyEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Снято с проведения' }, }) const remove = useMutation({ mutationFn: async () => { await api.delete(`/api/purchases/supplies/${id}`) }, onSuccess: () => navigate('/purchases/supplies'), onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Удалено' }, }) const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } diff --git a/src/food-market.web/src/pages/TransferEditPage.tsx b/src/food-market.web/src/pages/TransferEditPage.tsx index b1f7d32..977405f 100644 --- a/src/food-market.web/src/pages/TransferEditPage.tsx +++ b/src/food-market.web/src/pages/TransferEditPage.tsx @@ -108,6 +108,7 @@ export function TransferEditPage() { navigate(created ? `/inventory/transfers/${created.id}` : `/inventory/transfers/${id}`) }, onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Сохранено' }, }) const post = useMutation({ @@ -122,6 +123,7 @@ export function TransferEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Проведено' }, }) const unpost = useMutation({ @@ -136,12 +138,14 @@ export function TransferEditPage() { const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message setError(msg) }, + meta: { successMessage: 'Снято с проведения' }, }) const remove = useMutation({ mutationFn: async () => { await api.delete(`/api/inventory/transfers/${id}`) }, onSuccess: () => navigate('/inventory/transfers'), onError: (e: Error) => setError(e.message), + meta: { successMessage: 'Удалено' }, }) const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }