food-market/src/food-market.web/src/pages/SupplyEditPage.tsx
nns 792ea0fd41 feat(s29): простой режим — упрощённые формы товара/контрагента/приёмки (шаг 5/8)
Везде формула: simpleMode = me && !me.advancedMode && !SuperAdmin.
Значения скрытых полей в state сохраняются и при save на сервер
отправляются как есть; данные НЕ стираются.

- ProductEditPage: скрыты Артикул (auto-gen), Описание, Страна,
  Эталонная цена, Ставка НДС, «В т.ч. НДС», Валюта закупки,
  «Услуга», «Маркируемый», Максимальный остаток, не-retail типы
  цен в блоке «Цены продажи».
- CounterpartiesPage: скрыты Юр.название, Страна, Email, Банк,
  Р.счёт, БИК, Контактное лицо. Остаются: Название, Тип лица,
  БИН/ИИН, Адрес, Телефон, Заметки.
- SupplyEditPage: колонка «Валюта» всегда скрыта в простом режиме
  (одна валюта по умолчанию).

«Поставщик/Покупатель»-галочки отложены — требуют новых колонок
IsSupplier/IsBuyer на Counterparty (новая функциональность).

pnpm run build — ✓ 2.36s, 0 TS errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 11:48:23 +05:00

560 lines
26 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 { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { useMe } from '@/lib/useMe'
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 me = useMe()
// Sprint 29 — простой режим: мульти-валюта всегда выключена, независимо
// от org-настроек. В UI колонка валюты скрыта, send-side уже отправляет
// DefaultCurrencyId.
const simpleMode = !!me.data && !me.data.advancedMode && !me.data.roles?.includes('SuperAdmin')
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),
meta: { successMessage: 'Сохранено' },
})
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),
meta: { successMessage: 'Проведено' },
})
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)
},
meta: { successMessage: 'Снято с проведения' },
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/purchases/supplies/${id}`) },
onSuccess: () => navigate('/purchases/supplies'),
onError: (e: Error) => setError(e.message),
meta: { successMessage: 'Удалено' },
})
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
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
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
// Esc отключается когда открыт ConfirmDialog, чтобы не закрыть диалог
// и одновременно не уйти на список.
useShortcuts({
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
'Escape': () => navigate('/purchases/supplies'),
}, !dialogProps.open)
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />
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">
{/* WCAG 4.1.2: icon-only link нуждается в aria-label для screen-reader'ов. */}
<Link to="/purchases/supplies" aria-label="К списку приёмок" className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 flex-shrink-0">
<ArrowLeft className="w-5 h-5" aria-hidden="true" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Закупки' },
{ label: 'Приёмки', to: '/purchases/supplies' },
{ label: isNew ? 'Новая приёмка' : (existing.data?.number || 'Приёмка') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая приёмка' : existing.data?.number ?? 'Приёмка'}
</h1>
{isPosted && (
<p className="text-xs text-slate-500 dark:text-slate-400">
<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>
)}
{!simpleMode && 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 dark:text-slate-400 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 dark:text-slate-400">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 w-[90px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 w-[140px] text-right">Количество</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 w-[140px] text-right">Цена</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 w-[160px] text-right">{systemPriceTypeName} (карточка)</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 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 dark:text-slate-400">{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 dark:text-slate-400">
{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>
)
}