food-market/src/food-market.web/src/pages/SupplyEditPage.tsx
nns 17a6da2f8b
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
feat(web): ConfirmDialog компонент + useConfirm hook вместо window.confirm()
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>
2026-05-30 10:38:31 +05:00

530 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
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'
interface LineRow {
id?: string
productId: string
productName: string
productArticle: string | null
productBarcode: string | null
unitName: string | null
quantity: number
unitPrice: number
// Розничная цена с карточки товара на момент загрузки документа
// (read-only baseline). Используется как placeholder для ручного override.
currentRetailPrice: number | null
retailPriceManuallyOverridden: boolean
retailPriceOverride: number | null
}
interface Form {
date: string
supplierId: string
storeId: string
currencyId: string
notes: string
lines: LineRow[]
}
// Локальный YYYY-MM-DD (без UTC-сдвига): toISOString() в часовом поясе KZ
// (UTC+5) с 03:00 до 05:00 утра вернул бы вчерашнюю дату.
const todayIso = () => {
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
const emptyForm: Form = {
date: todayIso(),
supplierId: '', storeId: '', currencyId: '',
notes: '',
lines: [],
}
export function SupplyEditPage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const navigate = useNavigate()
const qc = useQueryClient()
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const priceTypes = usePriceTypes()
const { confirm, dialogProps } = useConfirm()
// Системный (главный) тип цен — на нём по умолчанию ведётся розница на кассе.
// Заголовок колонки «Розничная» подменяется его именем чтобы соответствовать
// тому, что увидит пользователь в карточке товара и в справочнике типов цен.
const systemPriceTypeName = priceTypes.data?.find((pt) => pt.isSystem)?.name
?? priceTypes.data?.find((pt) => pt.isRetail)?.name
?? 'Розничная'
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
// Скролл-контейнер тела документа: после каждого добавления строки
// автоскроллим к низу, чтобы новая строка и input оказались в зоне видимости.
const scrollBodyRef = useRef<HTMLDivElement>(null)
const scrollToBottom = () => {
const el = scrollBodyRef.current
if (!el) return
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
})
}
const existing = useQuery({
queryKey: ['/api/purchases/supplies', id],
queryFn: async () => (await api.get<SupplyDto>(`/api/purchases/supplies/${id}`)).data,
enabled: !isNew,
})
useEffect(() => {
if (!isNew && existing.data) {
const s = existing.data
setForm({
date: s.date.slice(0, 10),
supplierId: s.supplierId,
storeId: s.storeId,
currencyId: s.currencyId,
notes: s.notes ?? '',
lines: s.lines.map((l) => ({
id: l.id ?? undefined,
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
productBarcode: l.productBarcode,
unitName: l.unitName,
quantity: l.quantity,
unitPrice: l.unitPrice,
currentRetailPrice: l.currentRetailPrice ?? null,
retailPriceManuallyOverridden: l.retailPriceManuallyOverridden ?? false,
retailPriceOverride: l.retailPriceOverride ?? null,
})),
})
}
}, [isNew, existing.data])
useEffect(() => {
// Prefill defaults for new document.
if (isNew) {
if (!form.storeId && stores.data?.length) {
const main = stores.data.find((s) => s.isMain) ?? stores.data[0]
setForm((f) => ({ ...f, storeId: main.id }))
}
if (!form.currencyId && currencies.data?.length) {
const def = org.data?.defaultCurrencyId
? currencies.data.find((c) => c.id === org.data!.defaultCurrencyId)
: currencies.data.find((c) => c.code === 'KZT')
if (def) setForm((f) => ({ ...f, currencyId: def.id }))
}
}
}, [isNew, stores.data, currencies.data, org.data?.defaultCurrencyId, form.storeId, form.currencyId])
const isDraft = isNew || existing.data?.status === SupplyStatus.Draft
const isPosted = existing.data?.status === SupplyStatus.Posted
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
const save = useMutation({
mutationFn: async () => {
const payload = {
date: new Date(form.date).toISOString(),
supplierId: form.supplierId,
storeId: form.storeId,
currencyId: form.currencyId,
notes: form.notes || null,
lines: form.lines.map((l) => ({
productId: l.productId,
quantity: l.quantity,
unitPrice: l.unitPrice,
retailPriceManuallyOverridden: l.retailPriceManuallyOverridden,
retailPriceOverride: l.retailPriceManuallyOverridden ? l.retailPriceOverride : null,
})),
}
if (isNew) {
return (await api.post<SupplyDto>('/api/purchases/supplies', payload)).data
}
await api.put(`/api/purchases/supplies/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
navigate(created ? `/purchases/supplies/${created.id}` : `/purchases/supplies/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg)
},
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/purchases/supplies/${id}`) },
onSuccess: () => navigate('/purchases/supplies'),
onError: (e: Error) => setError(e.message),
})
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
const addLineFromProduct = (p: Product) => {
const defaultRetail = p.prices?.[0]?.amount ?? null
const primaryBarcode = (p.barcodes ?? []).slice().sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))[0]?.code ?? null
setForm({
...form,
lines: [...form.lines, {
productId: p.id,
productName: p.name,
productArticle: p.article,
productBarcode: primaryBarcode,
unitName: p.unitName,
quantity: 1,
unitPrice: p.referencePrice ?? p.cost ?? 0,
currentRetailPrice: defaultRetail,
retailPriceManuallyOverridden: false,
retailPriceOverride: null,
}],
})
}
/** Inline-добавление: если такой productId уже есть в строках — Quantity +1
* (возвращаем true для UX-подсветки). Иначе создаём новую строку. */
const addOrIncrementLine = (p: AddedProduct): boolean => {
const idx = form.lines.findIndex((l) => l.productId === p.id)
if (idx >= 0) {
setForm({
...form,
lines: form.lines.map((l, ix) => ix === idx ? { ...l, quantity: l.quantity + 1 } : l),
})
scrollToBottom()
return true
}
const defaultRetail = p.prices?.[0]?.amount ?? null
setForm({
...form,
lines: [...form.lines, {
productId: p.id,
productName: p.name,
productArticle: p.article,
productBarcode: p.barcode,
unitName: p.unitName,
quantity: 1,
unitPrice: p.referencePrice ?? p.cost ?? 0,
currentRetailPrice: defaultRetail,
retailPriceManuallyOverridden: false,
retailPriceOverride: null,
}],
})
scrollToBottom()
return false
}
const updateLine = (i: number, patch: Partial<LineRow>) =>
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
const removeLine = (i: number) =>
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
const canSave = !!form.date && !!form.supplierId && !!form.storeId && !!form.currencyId
&& form.lines.length > 0
&& isDraft
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
{/* Sticky top bar */}
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="flex items-center gap-3 min-w-0">
<Link to="/purchases/supplies" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая приёмка' : existing.data?.number ?? 'Приёмка'}
</h1>
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не попадает на склад, пока не проведёшь'}
</p>
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{isDraft && !isNew && (
<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>
)}
{isDraft && (
<Button type="submit" disabled={!canSave || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
)}
</div>
</div>
{/* Scrollable body */}
<div ref={scrollBodyRef} className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
{error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
<Section title="Реквизиты документа">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата *">
<DateField required value={form.date || null} disabled={isPosted}
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
</Field>
<Field label="Поставщик *">
<AsyncSelect
url="/api/catalog/counterparties"
value={form.supplierId}
disabled={isPosted}
onChange={(v) => setForm({ ...form, supplierId: v })}
placeholder="Выберите поставщика…"
createLabel="Создать поставщика"
onCreate={async (name) => {
const created = await api.post<{ id: string }>('/api/catalog/counterparties', {
name, legalName: null, type: 1,
bin: null, iin: null, taxNumber: null, countryId: null,
address: null, phone: null, email: null,
bankName: null, bankAccount: null, bik: null, contactPerson: null, notes: null,
})
return created.data.id
}}
/>
</Field>
{(stores.data?.length ?? 0) > 1 && (
<Field label="Склад *">
<Select value={form.storeId} disabled={isPosted}
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
<option value=""></option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
)}
{org.data?.multiCurrencyEnabled && (
<Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted}
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
)}
<Field label="Примечание" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
</div>
{!isNew && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Checkbox
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0
|| form.lines.some(l => l.quantity <= 0 || l.unitPrice <= 0)}
onChange={async (v) => {
if (v) {
if (await confirm({
title: 'Провести приёмку?',
description: 'После проведения товары будут оприходованы на склад и обновят себестоимость (скользящее среднее).',
confirmLabel: 'Провести',
tone: 'warning',
})) post.mutate()
} else {
if (await confirm({
title: 'Снять проведение?',
description: 'Остатки откатятся, себестоимость останется (пересчитать вручную при необходимости).',
confirmLabel: 'Снять',
tone: 'warning',
})) unpost.mutate()
}
}}
/>
<p className="text-xs text-slate-500 mt-1">
Только проведённый документ влияет на остатки склада и себестоимость.
Черновик можно править, проведённый только распровести и редактировать заново.
{isPosted && existing.data?.postedAt && (
<> Проведён {new Date(existing.data.postedAt).toLocaleString('ru')}.</>
)}
</p>
</div>
)}
</Section>
<Section
title="Позиции"
action={!isPosted && (
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
<Plus className="w-3.5 h-3.5" /> Добавить из справочника
</Button>
)}
>
{form.lines.length === 0 ? (
<div className="text-sm text-red-600 py-3 text-center">В приёмке должна быть хотя бы одна позиция. Сканируйте штрихкод или начните вводить ниже.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="py-2 pr-3 font-medium text-xs uppercase tracking-wide text-slate-500">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[90px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Количество</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Цена</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[160px] text-right">{systemPriceTypeName} (карточка)</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[160px] text-right">Сумма</th>
<th className="py-2 pl-3 w-[40px]"></th>
</tr>
</thead>
<tbody>
{form.lines.map((l, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-2 pr-3">
<div className="font-medium">{l.productName}</div>
{(l.productArticle || l.productBarcode) && (
<div className="text-xs text-slate-400 font-mono">
{l.productArticle && <span>Арт: {l.productArticle}</span>}
{l.productArticle && l.productBarcode && <span className="mx-1.5">·</span>}
{l.productBarcode && <span>ШК: {l.productBarcode}</span>}
</div>
)}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3">
<NumberInput disabled={isPosted}
value={l.quantity}
onChange={(n) => updateLine(i, { quantity: n ?? 0 })} />
</td>
<td className="py-2 px-3">
<MoneyInput disabled={isPosted}
value={l.unitPrice}
onChange={(n) => updateLine(i, { unitPrice: n ?? 0 })}
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol}
/>
</td>
<td className="py-2 px-3">
<MoneyInput disabled={isPosted}
value={l.retailPriceManuallyOverridden ? l.retailPriceOverride : l.currentRetailPrice}
onChange={(n) => updateLine(i, {
retailPriceManuallyOverridden: true,
retailPriceOverride: n,
})}
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol}
placeholder={l.currentRetailPrice != null ? String(l.currentRetailPrice) : '—'}
/>
</td>
<td className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
</td>
<td className="py-2 pl-3">
{!isPosted && (
<button type="button" onClick={() => removeLine(i)} className="text-slate-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="py-3 pr-3 text-right text-sm font-semibold text-slate-600 dark:text-slate-300">
Итого:
</td>
<td className="py-3 px-3 text-right font-mono text-lg font-bold">
{grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}
{' '}
<span className="text-sm text-slate-500">
{currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}
</span>
</td>
<td />
</tr>
</tfoot>
</table>
</div>
)}
</Section>
</div>
</div>
{/* Quick-add bar — flex-sibling формы, всегда у нижнего края viewport.
* Не sticky внутри scroll-body, чтобы overflow родителей и высота
* содержимого не влияли на видимость. После каждого добавления
* строки тело документа автоскроллится к низу. */}
{!isPosted && (
<div className="flex-shrink-0 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 px-3 sm:px-6 py-3 shadow-[0_-4px_12px_rgba(0,0,0,0.04)]">
<div className="max-w-6xl mx-auto">
<SupplyLineQuickAdd
storeId={form.storeId}
onPick={addOrIncrementLine}
linesCount={form.lines.length}
/>
</div>
</div>
)}
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
<ConfirmDialog {...dialogProps} />
</form>
)
}
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
return (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{action}
</header>
<div className="p-5">{children}</div>
</section>
)
}