feat(web): toast-система — error на 4xx/5xx + success на мутации (через meta)
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Item 3 Sprint 7 — заменил молчаливый rej в src/lib/api.ts на toast.error
для всех 4xx/5xx (кроме 401, где идёт auto-refresh). Тосты успеха —
для мутаций через meta.successMessage, чтобы избежать спама на queries.

Компоненты:
- src/lib/toast.ts — мин singleton API (toast.success/error/info), без deps.
  Дедуп подряд идущих одинаковых сообщений. Autoclose через setTimeout.
- src/components/Toaster.tsx — фиксированный top-right контейнер. На мобиле
  растягивается до экрана с margin. Кнопка X для ручного закрытия.
- src/lib/api.ts — interceptor 4xx/5xx: humanizeError() читает
  ProblemDetails (errors.X[0] → detail → message → title); title по статусу
  («Нет доступа» / «Не найдено» / «Конфликт» / «Проверьте поля» / «Слишком
  много запросов» / «Ошибка сервера»). Опт-аут через config.__silent=true.

Глобальный mutation onSuccess (App.tsx) подтягивает meta.successMessage и
показывает toast. meta.successMessage=false → опт-аут.

Применено (через meta):
- useCatalogMutations: create=«Создано», update=«Сохранено», remove=«Удалено»
  (автоматически для всех list-pages: Counterparties, Stores, Countries,
  PriceTypes, ProductGroups, RetailPoints, EmployeeRoles, ...)
- Doc-edit pages (Demand/Enter/Inventory/Loss/SupplierReturn/Transfer/Supply/
  RetailSale): save=«Сохранено», post=«Проведено», unpost=«Снято с
  проведения», remove=«Удалено».
- ProductEditPage: save=«Сохранено», remove=«Удалено».
- OrganizationSettingsPage save: «Настройки сохранены».

tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-30 10:54:14 +05:00
parent c201625b2b
commit 27ce8dddfc
15 changed files with 232 additions and 1 deletions

View file

@ -57,6 +57,8 @@ import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettin
import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage' import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage'
import { ResetPasswordPage } from '@/pages/ResetPasswordPage' import { ResetPasswordPage } from '@/pages/ResetPasswordPage'
import { RoleGuard } from '@/components/RoleGuard' import { RoleGuard } from '@/components/RoleGuard'
import { Toaster } from '@/components/Toaster'
import { toast } from '@/lib/toast'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -64,12 +66,25 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 1, 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() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Toaster />
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />

View file

@ -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<ToastEntry[]>([])
useEffect(() => {
return subscribeToasts(setItems)
}, [])
if (items.length === 0) return null
return (
<div className="fixed top-4 right-4 z-[80] flex flex-col gap-2 max-w-[380px] w-[calc(100vw-2rem)] sm:w-[380px] pointer-events-none">
{items.map(t => (
<ToastCard key={t.id} entry={t} />
))}
</div>
)
}
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 (
<div
role="alert"
aria-live="polite"
className={`pointer-events-auto flex gap-2 items-start px-3 py-2.5 rounded-md border shadow-sm ${palette}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${iconColor}`} />
<div className="flex-1 min-w-0 text-sm">
{entry.title && <div className="font-semibold leading-tight">{entry.title}</div>}
<div className="leading-snug break-words">{entry.message}</div>
</div>
<button
onClick={() => dismiss(entry.id)}
className="shrink-0 -mr-0.5 text-slate-400 hover:text-slate-600"
aria-label="Закрыть"
>
<X className="w-4 h-4" />
</button>
</div>
)
}

View file

@ -1,5 +1,6 @@
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { getAccessToken, refreshTokens, clearTokens } from './auth' import { getAccessToken, refreshTokens, clearTokens } from './auth'
import { toast } from './toast'
export const api = axios.create({ export const api = axios.create({
baseURL: '', baseURL: '',
@ -73,7 +74,7 @@ let refreshing: Promise<string | null> | null = null
api.interceptors.response.use( api.interceptors.response.use(
(res) => res, (res) => res,
async (error: AxiosError) => { 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) { if (error.response?.status === 401 && !original.__retried) {
original.__retried = true original.__retried = true
refreshing ??= refreshTokens().finally(() => { refreshing = null }) refreshing ??= refreshTokens().finally(() => { refreshing = null })
@ -87,7 +88,48 @@ api.interceptors.response.use(
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/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) 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<string, unknown> | undefined
if (data && typeof data === 'object') {
// ASP.NET validation errors: { errors: { Field: ['msg', ...] } }
const errs = data.errors as Record<string, string[]> | 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()
}

View file

@ -0,0 +1,70 @@
/**
* Минимальная toast-система собственная, без зависимостей (react-hot-toast
* не хотим тянуть ради одного пункта). Subscribe/publish, autoclose через
* 5 секунд, position top-right.
*
* Использование:
* import { toast } from '@/lib/toast'
* toast.success('Сохранено')
* toast.error('Не удалось сохранить — проверьте поля')
*
* Рендер: <Toaster /> в 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<Listener>()
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,
}

View file

@ -39,17 +39,22 @@ export function useCatalogList<T>(url: string, extraParams: Record<string, strin
export function useCatalogMutations(url: string, listUrl: string) { export function useCatalogMutations(url: string, listUrl: string) {
const qc = useQueryClient() const qc = useQueryClient()
// Тосты успеха: подхватываются глобальным defaultOptions.mutations.onSuccess
// (см. App.tsx) через meta.successMessage. Errors показывает axios interceptor.
const create = useMutation({ const create = useMutation({
mutationFn: async (input: unknown) => (await api.post(url, input)).data, mutationFn: async (input: unknown) => (await api.post(url, input)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }), onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }),
meta: { successMessage: 'Создано' },
}) })
const update = useMutation({ const update = useMutation({
mutationFn: async ({ id, input }: { id: string; input: unknown }) => (await api.put(`${url}/${id}`, input)).data, mutationFn: async ({ id, input }: { id: string; input: unknown }) => (await api.put(`${url}/${id}`, input)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }), onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }),
meta: { successMessage: 'Сохранено' },
}) })
const remove = useMutation({ const remove = useMutation({
mutationFn: async (id: string) => (await api.delete(`${url}/${id}`)).data, mutationFn: async (id: string) => (await api.delete(`${url}/${id}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }), onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }),
meta: { successMessage: 'Удалено' },
}) })
return { create, update, remove } return { create, update, remove }
} }

View file

@ -142,6 +142,7 @@ export function DemandEditPage() {
navigate(created ? `/sales/demands/${created.id}` : `/sales/demands/${id}`) navigate(created ? `/sales/demands/${created.id}` : `/sales/demands/${id}`)
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Сохранено' },
}) })
const post = useMutation({ const post = useMutation({
@ -156,6 +157,7 @@ export function DemandEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Проведено' },
}) })
const unpost = useMutation({ const unpost = useMutation({
@ -166,12 +168,14 @@ export function DemandEditPage() {
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Снято с проведения' },
}) })
const remove = useMutation({ const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/sales/demands/${id}`) }, mutationFn: async () => { await api.delete(`/api/sales/demands/${id}`) },
onSuccess: () => navigate('/sales/demands'), onSuccess: () => navigate('/sales/demands'),
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Удалено' },
}) })
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }

View file

@ -124,6 +124,7 @@ export function EnterEditPage() {
navigate(created ? `/inventory/enters/${created.id}` : `/inventory/enters/${id}`) navigate(created ? `/inventory/enters/${created.id}` : `/inventory/enters/${id}`)
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Сохранено' },
}) })
const post = useMutation({ const post = useMutation({
@ -135,6 +136,7 @@ export function EnterEditPage() {
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Проведено' },
}) })
const unpost = useMutation({ const unpost = useMutation({
@ -149,12 +151,14 @@ export function EnterEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Снято с проведения' },
}) })
const remove = useMutation({ const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/inventory/enters/${id}`) }, mutationFn: async () => { await api.delete(`/api/inventory/enters/${id}`) },
onSuccess: () => navigate('/inventory/enters'), onSuccess: () => navigate('/inventory/enters'),
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Удалено' },
}) })
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }

View file

@ -104,6 +104,7 @@ export function InventoryEditPage() {
navigate(`/inventory/inventories/${created.id}`) navigate(`/inventory/inventories/${created.id}`)
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Сохранено' },
}) })
const save = useMutation({ const save = useMutation({
@ -122,6 +123,7 @@ export function InventoryEditPage() {
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Сохранено' },
}) })
const post = useMutation({ const post = useMutation({
@ -136,6 +138,7 @@ export function InventoryEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Проведено' },
}) })
const unpost = useMutation({ const unpost = useMutation({
@ -149,12 +152,14 @@ export function InventoryEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Снято с проведения' },
}) })
const remove = useMutation({ const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/inventory/inventories/${id}`) }, mutationFn: async () => { await api.delete(`/api/inventory/inventories/${id}`) },
onSuccess: () => navigate('/inventory/inventories'), onSuccess: () => navigate('/inventory/inventories'),
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Удалено' },
}) })
const onSubmit = (e: FormEvent) => { const onSubmit = (e: FormEvent) => {

View file

@ -126,6 +126,7 @@ export function LossEditPage() {
navigate(created ? `/inventory/losses/${created.id}` : `/inventory/losses/${id}`) navigate(created ? `/inventory/losses/${created.id}` : `/inventory/losses/${id}`)
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Сохранено' },
}) })
const post = useMutation({ const post = useMutation({
@ -140,6 +141,7 @@ export function LossEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Проведено' },
}) })
const unpost = useMutation({ const unpost = useMutation({
@ -151,12 +153,14 @@ export function LossEditPage() {
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Снято с проведения' },
}) })
const remove = useMutation({ const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/inventory/losses/${id}`) }, mutationFn: async () => { await api.delete(`/api/inventory/losses/${id}`) },
onSuccess: () => navigate('/inventory/losses'), onSuccess: () => navigate('/inventory/losses'),
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Удалено' },
}) })
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }

View file

@ -63,6 +63,7 @@ export function OrganizationSettingsPage() {
if (d) setForm(d) if (d) setForm(d)
qc.invalidateQueries({ queryKey: ['/api/organization/settings'] }) qc.invalidateQueries({ queryKey: ['/api/organization/settings'] })
}, },
meta: { successMessage: 'Настройки сохранены' },
}) })
if (!form) return <div className="p-6 text-sm text-slate-500">Загрузка</div> if (!form) return <div className="p-6 text-sm text-slate-500">Загрузка</div>

View file

@ -178,6 +178,7 @@ export function ProductEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Сохранено' },
}) })
const remove = useMutation({ const remove = useMutation({
@ -186,6 +187,7 @@ export function ProductEditPage() {
qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }) qc.invalidateQueries({ queryKey: ['/api/catalog/products'] })
navigate('/catalog/products') navigate('/catalog/products')
}, },
meta: { successMessage: 'Удалено' },
}) })
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }

View file

@ -146,6 +146,7 @@ export function RetailSaleEditPage() {
navigate(created ? `/sales/retail/${created.id}` : `/sales/retail/${id}`) navigate(created ? `/sales/retail/${created.id}` : `/sales/retail/${id}`)
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Сохранено' },
}) })
const post = useMutation({ const post = useMutation({
@ -157,6 +158,7 @@ export function RetailSaleEditPage() {
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Проведено' },
}) })
const unpost = useMutation({ const unpost = useMutation({
@ -168,12 +170,14 @@ export function RetailSaleEditPage() {
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Снято с проведения' },
}) })
const remove = useMutation({ const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/sales/retail/${id}`) }, mutationFn: async () => { await api.delete(`/api/sales/retail/${id}`) },
onSuccess: () => navigate('/sales/retail'), onSuccess: () => navigate('/sales/retail'),
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Удалено' },
}) })
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }

View file

@ -130,6 +130,7 @@ export function SupplierReturnEditPage() {
navigate(created ? `/purchases/supplier-returns/${created.id}` : `/purchases/supplier-returns/${id}`) navigate(created ? `/purchases/supplier-returns/${created.id}` : `/purchases/supplier-returns/${id}`)
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Сохранено' },
}) })
const post = useMutation({ const post = useMutation({
@ -144,6 +145,7 @@ export function SupplierReturnEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Проведено' },
}) })
const unpost = useMutation({ const unpost = useMutation({
@ -154,12 +156,14 @@ export function SupplierReturnEditPage() {
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Снято с проведения' },
}) })
const remove = useMutation({ const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/purchases/supplier-returns/${id}`) }, mutationFn: async () => { await api.delete(`/api/purchases/supplier-returns/${id}`) },
onSuccess: () => navigate('/purchases/supplier-returns'), onSuccess: () => navigate('/purchases/supplier-returns'),
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Удалено' },
}) })
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }

View file

@ -169,6 +169,7 @@ export function SupplyEditPage() {
navigate(created ? `/purchases/supplies/${created.id}` : `/purchases/supplies/${id}`) navigate(created ? `/purchases/supplies/${created.id}` : `/purchases/supplies/${id}`)
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Сохранено' },
}) })
const post = useMutation({ const post = useMutation({
@ -180,6 +181,7 @@ export function SupplyEditPage() {
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Проведено' },
}) })
const unpost = useMutation({ const unpost = useMutation({
@ -194,12 +196,14 @@ export function SupplyEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Снято с проведения' },
}) })
const remove = useMutation({ const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/purchases/supplies/${id}`) }, mutationFn: async () => { await api.delete(`/api/purchases/supplies/${id}`) },
onSuccess: () => navigate('/purchases/supplies'), onSuccess: () => navigate('/purchases/supplies'),
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Удалено' },
}) })
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }

View file

@ -108,6 +108,7 @@ export function TransferEditPage() {
navigate(created ? `/inventory/transfers/${created.id}` : `/inventory/transfers/${id}`) navigate(created ? `/inventory/transfers/${created.id}` : `/inventory/transfers/${id}`)
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Сохранено' },
}) })
const post = useMutation({ const post = useMutation({
@ -122,6 +123,7 @@ export function TransferEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Проведено' },
}) })
const unpost = useMutation({ const unpost = useMutation({
@ -136,12 +138,14 @@ export function TransferEditPage() {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg) setError(msg)
}, },
meta: { successMessage: 'Снято с проведения' },
}) })
const remove = useMutation({ const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/inventory/transfers/${id}`) }, mutationFn: async () => { await api.delete(`/api/inventory/transfers/${id}`) },
onSuccess: () => navigate('/inventory/transfers'), onSuccess: () => navigate('/inventory/transfers'),
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Удалено' },
}) })
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }