Some checks are pending
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>
530 lines
24 KiB
TypeScript
530 lines
24 KiB
TypeScript
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>
|
||
)
|
||
}
|