feat(web): ConfirmDialog компонент + useConfirm hook вместо window.confirm()
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 2 Sprint 7 — заменил все нативные confirm() в фронте на собственный
<ConfirmDialog> с понятной типографикой, Esc=cancel, focus-on-Cancel
(чтобы случайный Enter не подтверждал удаление), tone='danger' | 'warning'.

Компоненты:
- src/components/ConfirmDialog.tsx — UI поверх Modal-overlay, AlertTriangle
  иконка, primary/danger кнопки. Текст description конкретный («Удалить
  товар «Молоко 3.2%»? Действие необратимо»). aria-labelledby выставлен.
- src/lib/useConfirm.ts — хук-обёртка: const { confirm, dialogProps } =
  useConfirm(); if (await confirm({...})) action(). Возвращает Promise<bool>.

Button: переведён на forwardRef, чтобы dialog мог поставить фокус на Cancel.

Применено (17 страниц + 1 компонент):
- ProductEditPage (delete product)
- DemandEditPage / EnterEditPage / InventoryEditPage / LossEditPage /
  SupplierReturnEditPage / TransferEditPage / SupplyEditPage / RetailSaleEditPage:
  delete draft + post + unpost (всего 3 диалога на форму)
- EmployeesPage: уволить (warning) / удалить навсегда (danger), сохранена
  динамика по статусу
- CounterpartiesPage / StoresPage / ProductGroupsPage / RetailPointsPage /
  CountriesPage / PriceTypesPage / EmployeeRolesPage / SuperAdminUnitsOfMeasurePage:
  delete с именем сущности в description
- ProductImageGallery: delete image

tsc --noEmit: clean. Текстов: в описаниях есть имена сущностей (товар,
номер документа), чтобы было ясно что именно удаляется.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-30 10:38:31 +05:00
parent 26959d56d1
commit 17a6da2f8b
22 changed files with 453 additions and 47 deletions

View file

@ -1,4 +1,4 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { useReadOnly } from '@/lib/useReadOnly'
@ -27,7 +27,10 @@ const sizes: Record<Size, string> = {
md: 'px-3.5 py-1.5 text-sm',
}
export function Button({ variant = 'primary', size = 'md', mutating, className, children, disabled, title, ...rest }: ButtonProps) {
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = 'primary', size = 'md', mutating, className, children, disabled, title, ...rest },
ref,
) {
const ro = useReadOnly()
// Variant primary/danger по умолчанию считаем мутирующими (Добавить/
// Сохранить/Удалить/Создать почти всегда primary либо danger). Secondary/
@ -38,6 +41,7 @@ export function Button({ variant = 'primary', size = 'md', mutating, className,
const blocked = isMutating && ro.readOnly
return (
<button
ref={ref}
{...rest}
disabled={disabled || blocked}
title={blocked ? ro.reason : title}
@ -51,4 +55,4 @@ export function Button({ variant = 'primary', size = 'md', mutating, className,
{children}
</button>
)
}
})

View file

@ -0,0 +1,89 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { AlertTriangle } from 'lucide-react'
import { Button } from './Button'
/**
* Универсальный confirm-диалог для destructive actions. Заменяет нативный
* `window.confirm()` даёт собственный UI (поверх Modal), Esc=отмена, фокус на
* Cancel (чтобы случайный Enter не подтверждал удаление).
*
* Использование: держим стейтом `{ open, payload }`, в `onConfirm` запускаем
* мутацию, в `onCancel` `setOpen(false)`. Текст description конкретный, с
* именем сущности («Удалить товар «Молоко 3.2%»? Действие необратимо»).
*
* Варианты:
* - tone='danger' (default): красная кнопка справа, иконка треугольник
* - tone='warning': жёлтая кнопка («Снять проведение»), всё ещё деструктивно
* но не уничтожающе. Иконка та же.
*/
interface ConfirmDialogProps {
open: boolean
title: string
description?: ReactNode
confirmLabel?: string
cancelLabel?: string
tone?: 'danger' | 'warning'
busy?: boolean
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
open, title, description, confirmLabel = 'Удалить', cancelLabel = 'Отмена',
tone = 'danger', busy = false, onConfirm, onCancel,
}: ConfirmDialogProps) {
const cancelBtnRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onCancel() }
else if (e.key === 'Enter') { e.preventDefault(); if (!busy) onConfirm() }
}
document.addEventListener('keydown', onKey)
// Фокус на Cancel по умолчанию — безопасно для случайного Enter.
cancelBtnRef.current?.focus()
return () => document.removeEventListener('keydown', onKey)
}, [open, busy, onCancel, onConfirm])
if (!open) return null
const iconColor = tone === 'danger' ? 'text-red-600' : 'text-amber-600'
const iconBg = tone === 'danger' ? 'bg-red-50 dark:bg-red-900/20' : 'bg-amber-50 dark:bg-amber-900/20'
const confirmVariant = tone === 'danger' ? 'danger' : 'primary'
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm"
onClick={onCancel}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
>
<div
className="w-full max-w-md bg-white dark:bg-slate-900 rounded-xl shadow-xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex gap-3 px-5 pt-5">
<div className={`shrink-0 w-10 h-10 rounded-full ${iconBg} flex items-center justify-center ${iconColor}`}>
<AlertTriangle className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h2 id="confirm-dialog-title" className="font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{description && (
<div className="mt-1 text-sm text-slate-600 dark:text-slate-400">{description}</div>
)}
</div>
</div>
<div className="px-5 py-4 flex flex-wrap justify-end gap-2">
<Button ref={cancelBtnRef} variant="secondary" mutating={false} onClick={onCancel} disabled={busy}>
{cancelLabel}
</Button>
<Button variant={confirmVariant} onClick={onConfirm} disabled={busy}>
{busy ? '…' : confirmLabel}
</Button>
</div>
</div>
</div>
)
}

View file

@ -3,6 +3,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Star, Upload, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
interface ImageDto { id: string; url: string; isMain: boolean; sortOrder: number }
@ -12,6 +14,7 @@ export function ProductImageGallery({ productId }: Props) {
const qc = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null)
const { confirm, dialogProps } = useConfirm()
const url = `/api/catalog/products/${productId}/images`
@ -105,7 +108,14 @@ export function ProductImageGallery({ productId }: Props) {
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); if (confirm('Удалить это изображение?')) remove.mutate(img.id) }}
onClick={async (e) => {
e.stopPropagation()
if (await confirm({
title: 'Удалить изображение?',
description: 'Файл будет удалён со склада, действие необратимо.',
confirmLabel: 'Удалить',
})) remove.mutate(img.id)
}}
className="p-1 rounded bg-white/90 hover:bg-white text-red-600"
title="Удалить"
>
@ -159,6 +169,7 @@ export function ProductImageGallery({ productId }: Props) {
</div>
</div>
)}
<ConfirmDialog {...dialogProps} />
</div>
)
}

View file

@ -0,0 +1,59 @@
import { useCallback, useState } from 'react'
import type { ReactNode } from 'react'
/**
* Хук-обёртка над <ConfirmDialog>: переписывает `if (confirm('...'))` на
* `await confirm({ title, description, ... })`. Возвращает true если
* пользователь подтвердил, false если отмёл (Esc, бэкграунд клик, Cancel).
*
* Пример:
* const { confirm, dialogProps } = useConfirm()
* ...
* if (await confirm({ title: 'Удалить товар?', description: name, confirmLabel: 'Удалить' })) {
* remove.mutate()
* }
* ...
* <ConfirmDialog {...dialogProps} />
*/
export interface ConfirmRequest {
title: string
description?: ReactNode
confirmLabel?: string
cancelLabel?: string
tone?: 'danger' | 'warning'
}
export function useConfirm() {
const [state, setState] = useState<{
req: ConfirmRequest
resolve: (v: boolean) => void
} | null>(null)
const confirm = useCallback((req: ConfirmRequest): Promise<boolean> => {
return new Promise<boolean>((resolve) => setState({ req, resolve }))
}, [])
const onCancel = useCallback(() => {
state?.resolve(false)
setState(null)
}, [state])
const onConfirm = useCallback(() => {
state?.resolve(true)
setState(null)
}, [state])
return {
confirm,
dialogProps: {
open: !!state,
title: state?.req.title ?? '',
description: state?.req.description,
confirmLabel: state?.req.confirmLabel,
cancelLabel: state?.req.cancelLabel,
tone: state?.req.tone,
onCancel,
onConfirm,
},
}
}

View file

@ -11,6 +11,8 @@ import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select } from '@/components/Field'
import { PhoneInput } from '@/components/PhoneInput'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
@ -53,6 +55,7 @@ export function CounterpartiesPage() {
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const [fieldErrors, setFieldErrors] = useState<Partial<Record<'name' | 'email' | 'phone', string>>>({})
const { confirm, dialogProps } = useConfirm()
const countries = useQuery({
queryKey: ['countries-lookup'],
@ -117,7 +120,11 @@ export function CounterpartiesPage() {
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить контрагента?')) {
if (await confirm({
title: 'Удалить контрагента?',
description: <>Удалить <strong>«{form.name || 'без названия'}»</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) {
await remove.mutateAsync(form.id!)
setForm(null); setFieldErrors({})
}
@ -191,6 +198,7 @@ export function CounterpartiesPage() {
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -7,6 +7,8 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, NumberInput } from '@/components/Field'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useCurrencies } from '@/lib/useLookups'
import type { Country } from '@/lib/types'
@ -28,6 +30,7 @@ export function CountriesPage() {
const { create, update, remove } = useCatalogMutations(URL, URL)
const currencies = useCurrencies()
const [form, setForm] = useState<Form | null>(null)
const { confirm, dialogProps } = useConfirm()
const save = async () => {
if (!form) return
@ -80,7 +83,11 @@ export function CountriesPage() {
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить страну?')) {
if (await confirm({
title: 'Удалить страну?',
description: <>Удалить <strong>«{form.name || 'без названия'}»</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) {
await remove.mutateAsync(form.id!)
setForm(null)
}
@ -121,6 +128,7 @@ export function CountriesPage() {
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -7,6 +7,8 @@ import { Button } from '@/components/Button'
import { Field, TextArea, Select, AsyncSelect, MoneyInput, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import {
@ -57,6 +59,7 @@ export function DemandEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
@ -222,7 +225,13 @@ export function DemandEditPage() {
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Button type="button" variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить черновик отгрузки?',
description: <>Удалить черновик <strong>{existing.data?.number || '—'}</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) remove.mutate()
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
@ -296,11 +305,21 @@ export function DemandEditPage() {
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0}
onChange={(v) => {
onChange={async (v) => {
if (v) {
if (confirm('Провести? Товар спишется со склада.')) post.mutate()
if (await confirm({
title: 'Провести отгрузку?',
description: 'Товар спишется со склада в адрес покупателя.',
confirmLabel: 'Провести',
tone: 'warning',
})) post.mutate()
} else {
if (confirm('Снять проведение? Товар вернётся на склад.')) unpost.mutate()
if (await confirm({
title: 'Снять проведение?',
description: 'Товар вернётся на склад.',
confirmLabel: 'Снять',
tone: 'warning',
})) unpost.mutate()
}
}}
/>
@ -398,6 +417,7 @@ export function DemandEditPage() {
onClose={() => setPickerOpen(false)}
onPick={(p) => { addLineFromProduct(p); setPickerOpen(false) }}
/>
<ConfirmDialog {...dialogProps} />
</form>
)
}

View file

@ -7,6 +7,8 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Checkbox } from '@/components/Field'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
const URL = '/api/organization/employee-roles'
@ -125,6 +127,7 @@ export function EmployeeRolesPage() {
// Шаг выбора шаблона: показывается ПЕРЕД редактированием матрицы при добавлении новой роли.
const [pickTemplate, setPickTemplate] = useState(false)
const [templateId, setTemplateId] = useState<string>('blank')
const { confirm, dialogProps } = useConfirm()
const save = async () => {
if (!form) return
@ -244,7 +247,11 @@ export function EmployeeRolesPage() {
<>
{form?.id && !form.isSystem && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить роль?')) {
if (await confirm({
title: 'Удалить роль?',
description: <>Удалить <strong>«{form.name || 'без названия'}»</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) {
await remove.mutateAsync(form.id!)
setForm(null); setNameErr(null)
}
@ -304,6 +311,7 @@ export function EmployeeRolesPage() {
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -11,6 +11,8 @@ import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Checkbox, MoneyInput } from '@/components/Field'
import { PhoneInput } from '@/components/PhoneInput'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { PagedResult, RetailPoint } from '@/lib/types'
import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage'
@ -93,6 +95,7 @@ export function EmployeesPage() {
// над любой ошибкой 4xx/5xx с сервера.
const [blockedDelete, setBlockedDelete] = useState<{ title: string; body: string } | null>(null)
const [fieldErrors, setFieldErrors] = useState<Partial<Record<'lastName' | 'firstName' | 'email' | 'phone', string>>>({})
const { confirm, dialogProps } = useConfirm()
const roles = useQuery({
queryKey: ['employee-roles-lookup'],
@ -283,10 +286,20 @@ export function EmployeesPage() {
return
}
const fullName = `${activeEmployee.lastName ?? ''} ${activeEmployee.firstName ?? ''}`.trim()
const confirmText = activeEmployee.status === 'active'
? `Уволить сотрудника «${fullName}»?\n\nЕго учётная запись потеряет доступ, документы и история останутся в системе. Восстановить можно в любой момент.`
: `Удалить запись о сотруднике «${fullName}»?\n\nСотрудник уже уволен. После удаления он скрывается из обычных списков, но во всех связанных документах остаётся подпись «${fullName} (удалён)».`
if (!confirm(confirmText)) return
const confirmReq = activeEmployee.status === 'active'
? {
title: 'Уволить сотрудника?',
description: <>Уволить <strong>«{fullName}»</strong>? Его учётная запись потеряет доступ, документы и история останутся в системе. Восстановить можно в любой момент.</>,
confirmLabel: 'Уволить',
tone: 'warning' as const,
}
: {
title: 'Удалить навсегда?',
description: <>Удалить запись о сотруднике <strong>«{fullName}»</strong>? Сотрудник уже уволен. После удаления он скрывается из обычных списков, но во всех связанных документах остаётся подпись «{fullName} (удалён)». Действие необратимо.</>,
confirmLabel: 'Удалить',
tone: 'danger' as const,
}
if (!await confirm(confirmReq)) return
try {
await remove.mutateAsync(form.id!)
list.refetch?.()
@ -476,6 +489,7 @@ export function EmployeesPage() {
{blockedDelete?.body}
</p>
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -7,6 +7,8 @@ import { Button } from '@/components/Button'
import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { EnterStatus, type EnterDto, type Product } from '@/lib/types'
@ -49,6 +51,7 @@ export function EnterEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
@ -202,7 +205,13 @@ export function EnterEditPage() {
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Button type="button" variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить черновик оприходования?',
description: <>Удалить черновик <strong>{existing.data?.number || '—'}</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) remove.mutate()
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
@ -255,11 +264,21 @@ export function EnterEditPage() {
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0}
onChange={(v) => {
onChange={async (v) => {
if (v) {
if (confirm('Провести? Товар будет оприходован на склад.')) post.mutate()
if (await confirm({
title: 'Провести оприходование?',
description: 'Товар будет оприходован на склад.',
confirmLabel: 'Провести',
tone: 'warning',
})) post.mutate()
} else {
if (confirm('Снять проведение? Остатки откатятся (заблокировано, если товар уже списан).')) unpost.mutate()
if (await confirm({
title: 'Снять проведение?',
description: 'Остатки откатятся (заблокировано, если товар уже списан).',
confirmLabel: 'Снять',
tone: 'warning',
})) unpost.mutate()
}
}}
/>
@ -342,6 +361,7 @@ export function EnterEditPage() {
onClose={() => setPickerOpen(false)}
onPick={(p) => { addLineFromProduct(p); setPickerOpen(false) }}
/>
<ConfirmDialog {...dialogProps} />
</form>
)
}

View file

@ -6,6 +6,8 @@ import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextArea, Select, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useStores } from '@/lib/useLookups'
import { InventoryStatus, type InventoryDto } from '@/lib/types'
@ -41,6 +43,7 @@ export function InventoryEditPage() {
const qc = useQueryClient()
const stores = useStores()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form>(emptyForm)
const [error, setError] = useState<string | null>(null)
const csvInputRef = useRef<HTMLInputElement>(null)
@ -223,7 +226,13 @@ export function InventoryEditPage() {
<Button type="button" variant="secondary" size="sm" onClick={() => csvInputRef.current?.click()}>
<Upload className="w-4 h-4" /> Импорт CSV
</Button>
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Button type="button" variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить черновик инвентаризации?',
description: <>Удалить черновик <strong>{existing.data?.number || '—'}</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) remove.mutate()
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
</>
@ -277,11 +286,21 @@ export function InventoryEditPage() {
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || !canPost && !isPosted}
onChange={(v) => {
onChange={async (v) => {
if (v) {
if (confirm('Провести? Учтённые остатки будут скорректированы на разницу.')) post.mutate()
if (await confirm({
title: 'Провести инвентаризацию?',
description: 'Учтённые остатки будут скорректированы на разницу.',
confirmLabel: 'Провести',
tone: 'warning',
})) post.mutate()
} else {
if (confirm('Снять проведение? Корректировки отменятся.')) unpost.mutate()
if (await confirm({
title: 'Снять проведение?',
description: 'Корректировки отменятся.',
confirmLabel: 'Снять',
tone: 'warning',
})) unpost.mutate()
}
}}
/>
@ -348,6 +367,7 @@ export function InventoryEditPage() {
)}
</div>
</div>
<ConfirmDialog {...dialogProps} />
</form>
)
}

View file

@ -7,6 +7,8 @@ import { Button } from '@/components/Button'
import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { LossStatus, LossReason, lossReasonLabel, type LossDto, type Product } from '@/lib/types'
@ -48,6 +50,7 @@ export function LossEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
@ -205,7 +208,13 @@ export function LossEditPage() {
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Button type="button" variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить черновик списания?',
description: <>Удалить черновик <strong>{existing.data?.number || '—'}</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) remove.mutate()
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
@ -266,11 +275,21 @@ export function LossEditPage() {
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0}
onChange={(v) => {
onChange={async (v) => {
if (v) {
if (confirm('Провести? Товар будет списан со склада.')) post.mutate()
if (await confirm({
title: 'Провести списание?',
description: 'Товар будет списан со склада.',
confirmLabel: 'Провести',
tone: 'warning',
})) post.mutate()
} else {
if (confirm('Снять проведение? Списанный товар вернётся на остаток.')) unpost.mutate()
if (await confirm({
title: 'Снять проведение?',
description: 'Списанный товар вернётся на остаток.',
confirmLabel: 'Снять',
tone: 'warning',
})) unpost.mutate()
}
}}
/>
@ -357,6 +376,7 @@ export function LossEditPage() {
onClose={() => setPickerOpen(false)}
onPick={(p) => { addLineFromProduct(p); setPickerOpen(false) }}
/>
<ConfirmDialog {...dialogProps} />
</form>
)
}

View file

@ -7,6 +7,8 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useQueryClient } from '@tanstack/react-query'
import type { PriceType } from '@/lib/types'
@ -29,6 +31,7 @@ export function PriceTypesPage() {
const [form, setForm] = useState<Form | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null)
const qc = useQueryClient()
const { confirm, dialogProps } = useConfirm()
const save = async () => {
if (!form) return
@ -92,7 +95,11 @@ export function PriceTypesPage() {
<>
{form?.id && !form.isSystem && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить тип цены?')) {
if (await confirm({
title: 'Удалить тип цены?',
description: <>Удалить <strong>«{form.name || 'без названия'}»</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) {
await remove.mutateAsync(form.id!)
await qc.invalidateQueries({ queryKey: ['lookup:price-types'] })
setForm(null); setNameErr(null)
@ -134,6 +141,7 @@ export function PriceTypesPage() {
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -11,6 +11,8 @@ import {
import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, Packaging, type Product } from '@/lib/types'
import { ProductImageGallery } from '@/components/ProductImageGallery'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode'
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
@ -60,6 +62,7 @@ export function ProductEditPage() {
const groups = useProductGroups()
const countries = useCountries()
const currencies = useCurrencies()
const { confirm, dialogProps } = useConfirm()
const priceTypes = usePriceTypes()
const org = useOrgSettings()
@ -242,7 +245,13 @@ export function ProductEditPage() {
type="button"
variant="danger"
size="sm"
onClick={() => { if (confirm('Удалить товар?')) remove.mutate() }}
onClick={async () => {
if (await confirm({
title: 'Удалить товар?',
description: <>Удалить <strong>«{form.name || 'без названия'}»</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) remove.mutate()
}}
>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
@ -534,6 +543,7 @@ export function ProductEditPage() {
)}
</div>
</div>
<ConfirmDialog {...dialogProps} />
</form>
)
}

View file

@ -7,6 +7,8 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, PercentInput } from '@/components/Field'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { ProductGroup } from '@/lib/types'
import { useQueryClient } from '@tanstack/react-query'
@ -22,6 +24,7 @@ export function ProductGroupsPage() {
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const qc = useQueryClient()
const { confirm, dialogProps } = useConfirm()
// inline-сохранение наценки прямо из таблицы — без открытия модалки.
const saveMarkup = async (g: ProductGroup, markupPercent: number | null) => {
await api.put(`/api/catalog/product-groups/${g.id}`, {
@ -102,7 +105,11 @@ export function ProductGroupsPage() {
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить группу? (должна быть пустой)')) {
if (await confirm({
title: 'Удалить группу?',
description: <>Удалить <strong>«{form.name || 'без названия'}»</strong>? Группа должна быть пустой.</>,
confirmLabel: 'Удалить',
})) {
try { await remove.mutateAsync(form.id!); setForm(null) }
catch (e) { alert((e as Error).message) }
}
@ -151,6 +158,7 @@ export function ProductGroupsPage() {
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -9,6 +9,8 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { PagedResult, RetailPoint, Store } from '@/lib/types'
@ -36,6 +38,7 @@ export function RetailPointsPage() {
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null)
const { confirm, dialogProps } = useConfirm()
const stores = useQuery({
queryKey: ['stores-lookup'],
@ -102,7 +105,11 @@ export function RetailPointsPage() {
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить кассу?')) {
if (await confirm({
title: 'Удалить кассу?',
description: <>Удалить <strong>«{form.name || 'без названия'}»</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) {
await remove.mutateAsync(form.id!)
setForm(null); setNameErr(null)
}
@ -152,6 +159,7 @@ export function RetailPointsPage() {
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -6,6 +6,8 @@ import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, AsyncSelect, MoneyInput, NumberInput } from '@/components/Field'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
@ -53,6 +55,7 @@ export function RetailSaleEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form>(empty)
const [pickerOpen, setPickerOpen] = useState(false)
@ -225,7 +228,12 @@ export function RetailSaleEditPage() {
type="button"
variant="secondary"
onClick={async () => {
if (!confirm('Создать возврат по этому чеку?')) return
if (!await confirm({
title: 'Создать возврат?',
description: <>Создать возврат по чеку <strong>{existing.data?.number || '—'}</strong>?</>,
confirmLabel: 'Создать возврат',
tone: 'warning',
})) return
const r = await api.post<RetailSaleDto>(`/api/sales/retail/${id}/create-return`)
navigate(`/sales/retail/${r.data.id}`)
}}
@ -239,7 +247,13 @@ export function RetailSaleEditPage() {
</Button>
)}
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Button type="button" variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить черновик чека?',
description: <>Удалить черновик <strong>{existing.data?.number || '—'}</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) remove.mutate()
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
@ -417,6 +431,7 @@ export function RetailSaleEditPage() {
</div>
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
<ConfirmDialog {...dialogProps} />
</form>
)
}

View file

@ -7,6 +7,8 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Store } from '@/lib/types'
@ -33,6 +35,7 @@ export function StoresPage() {
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null)
const { confirm, dialogProps } = useConfirm()
const save = async () => {
if (!form) return
@ -87,7 +90,11 @@ export function StoresPage() {
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить склад?')) {
if (await confirm({
title: 'Удалить склад?',
description: <>Удалить <strong>«{form.name || 'без названия'}»</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) {
await remove.mutateAsync(form.id!)
setForm(null); setNameErr(null)
}
@ -128,6 +135,7 @@ export function StoresPage() {
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -7,6 +7,8 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput } from '@/components/Field'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { UnitOfMeasure } from '@/lib/types'
@ -25,6 +27,7 @@ export function SuperAdminUnitsOfMeasurePage() {
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const { confirm, dialogProps } = useConfirm()
const save = async () => {
if (!form) return
@ -43,7 +46,11 @@ export function SuperAdminUnitsOfMeasurePage() {
const onDelete = async () => {
if (!form?.id) return
if (!confirm('Деактивировать единицу? Если на неё ссылаются товары или орги — операция не пройдёт.')) return
if (!await confirm({
title: 'Деактивировать единицу?',
description: <>Деактивировать <strong>«{form.name || 'без названия'}»</strong>? Если на неё ссылаются товары или орги операция не пройдёт.</>,
confirmLabel: 'Деактивировать',
})) return
try {
await remove.mutateAsync(form.id)
setForm(null)
@ -125,6 +132,7 @@ export function SuperAdminUnitsOfMeasurePage() {
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -7,6 +7,8 @@ import { Button } from '@/components/Button'
import { Field, TextArea, Select, AsyncSelect, MoneyInput, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { SupplierReturnStatus, type SupplierReturnDto, type Product } from '@/lib/types'
@ -50,6 +52,7 @@ export function SupplierReturnEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
@ -208,7 +211,13 @@ export function SupplierReturnEditPage() {
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Button type="button" variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить черновик возврата?',
description: <>Удалить черновик <strong>{existing.data?.number || '—'}</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) remove.mutate()
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
@ -270,11 +279,21 @@ export function SupplierReturnEditPage() {
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0}
onChange={(v) => {
onChange={async (v) => {
if (v) {
if (confirm('Провести? Товар спишется со склада в адрес поставщика.')) post.mutate()
if (await confirm({
title: 'Провести возврат поставщику?',
description: 'Товар спишется со склада в адрес поставщика.',
confirmLabel: 'Провести',
tone: 'warning',
})) post.mutate()
} else {
if (confirm('Снять проведение? Товар вернётся на склад.')) unpost.mutate()
if (await confirm({
title: 'Снять проведение?',
description: 'Товар вернётся на склад.',
confirmLabel: 'Снять',
tone: 'warning',
})) unpost.mutate()
}
}}
/>
@ -361,6 +380,7 @@ export function SupplierReturnEditPage() {
onClose={() => setPickerOpen(false)}
onPick={(p) => { addLineFromProduct(p); setPickerOpen(false) }}
/>
<ConfirmDialog {...dialogProps} />
</form>
)
}

View file

@ -8,6 +8,8 @@ import { Field, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
@ -64,6 +66,7 @@ export function SupplyEditPage() {
const currencies = useCurrencies()
const org = useOrgSettings()
const priceTypes = usePriceTypes()
const { confirm, dialogProps } = useConfirm()
// Системный (главный) тип цен — на нём по умолчанию ведётся розница на кассе.
// Заголовок колонки «Розничная» подменяется его именем чтобы соответствовать
// тому, что увидит пользователь в карточке товара и в справочнике типов цен.
@ -282,7 +285,13 @@ export function SupplyEditPage() {
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик приёмки?')) remove.mutate() }}>
<Button type="button" variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить черновик приёмки?',
description: <>Удалить черновик <strong>{existing.data?.number || '—'}</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) remove.mutate()
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
@ -357,11 +366,21 @@ export function SupplyEditPage() {
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0
|| form.lines.some(l => l.quantity <= 0 || l.unitPrice <= 0)}
onChange={(v) => {
onChange={async (v) => {
if (v) {
if (confirm('После проведения товары будут оприходованы на склад и обновят себестоимость (скользящее среднее). Продолжить?')) post.mutate()
if (await confirm({
title: 'Провести приёмку?',
description: 'После проведения товары будут оприходованы на склад и обновят себестоимость (скользящее среднее).',
confirmLabel: 'Провести',
tone: 'warning',
})) post.mutate()
} else {
if (confirm('Снять проведение? Остатки откатятся, себестоимость останется (пересчитать вручную при необходимости).')) unpost.mutate()
if (await confirm({
title: 'Снять проведение?',
description: 'Остатки откатятся, себестоимость останется (пересчитать вручную при необходимости).',
confirmLabel: 'Снять',
tone: 'warning',
})) unpost.mutate()
}
}}
/>
@ -492,6 +511,7 @@ export function SupplyEditPage() {
)}
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
<ConfirmDialog {...dialogProps} />
</form>
)
}

View file

@ -7,6 +7,8 @@ import { Button } from '@/components/Button'
import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useStores } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { TransferStatus, type TransferDto, type Product } from '@/lib/types'
@ -46,6 +48,7 @@ export function TransferEditPage() {
const stores = useStores()
const org = useOrgSettings()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
@ -190,7 +193,13 @@ export function TransferEditPage() {
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Button type="button" variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить черновик перемещения?',
description: <>Удалить черновик <strong>{existing.data?.number || '—'}</strong>? Действие необратимо.</>,
confirmLabel: 'Удалить',
})) remove.mutate()
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
@ -248,11 +257,21 @@ export function TransferEditPage() {
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0}
onChange={(v) => {
onChange={async (v) => {
if (v) {
if (confirm('Провести? Товар спишется с первого склада и встанет на второй.')) post.mutate()
if (await confirm({
title: 'Провести перемещение?',
description: 'Товар спишется с первого склада и встанет на второй.',
confirmLabel: 'Провести',
tone: 'warning',
})) post.mutate()
} else {
if (confirm('Снять проведение? Остатки обоих складов вернутся к предыдущим значениям.')) unpost.mutate()
if (await confirm({
title: 'Снять проведение?',
description: 'Остатки обоих складов вернутся к предыдущим значениям.',
confirmLabel: 'Снять',
tone: 'warning',
})) unpost.mutate()
}
}}
/>
@ -339,6 +358,7 @@ export function TransferEditPage() {
onClose={() => setPickerOpen(false)}
onPick={(p) => { addLineFromProduct(p); setPickerOpen(false) }}
/>
<ConfirmDialog {...dialogProps} />
</form>
)
}