feat(web): toast-система — error на 4xx/5xx + success на мутации (через meta)
Some checks are pending
Some checks are pending
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:
parent
c201625b2b
commit
27ce8dddfc
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
63
src/food-market.web/src/components/Toaster.tsx
Normal file
63
src/food-market.web/src/components/Toaster.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
70
src/food-market.web/src/lib/toast.ts
Normal file
70
src/food-market.web/src/lib/toast.ts
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue