feat(supply): inline line quick-add — scanner + autocomplete + create-on-fly
Some checks are pending
Some checks are pending
UX как в МойСклад: под таблицей строк единый input full-width с
автофокусом, на каждый ввод — debounce 200ms и quick-search в API.
Dropdown показывает артикул + название (с подсветкой матча) +
бэйдж текущего остатка по складу документа (зелёный/красный/серый).
Сценарии:
- Сканер (цифры 8/12/13/14 + Enter) → точный barcode lookup,
единственный матч добавляется мгновенно. Несколько → пользователь
выбирает из dropdown.
- Текст + Enter → берёт подсвеченный пункт автокомплита.
- Дубль того же товара → Quantity += 1 у существующей строки,
всплывает «Кол-во увеличено на 1».
- Ничего не нашлось → пункт «Создать новый товар: «{q}»» в дропдауне
и при Enter, открывает ProductQuickCreateModal с pre-fill в зависимости
от типа запроса (barcode/article/name).
- ↑↓ навигация, Esc/Tab закрывают, после добавления input очищается
и возвращает фокус — для сканирования партии подряд.
Кнопка «+ Добавить из справочника» (правый верх секции) — без изменений,
открывает ProductPicker с фильтрами и multi-select для bulk-добавления.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
654481b2b9
commit
e9f8da1b82
153
src/food-market.web/src/components/ProductQuickCreateModal.tsx
Normal file
153
src/food-market.web/src/components/ProductQuickCreateModal.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { Button } from '@/components/Button'
|
||||
import { Field, TextInput, Select } from '@/components/Field'
|
||||
import { useUnits, useProductGroups } from '@/lib/useLookups'
|
||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { BarcodeType, type Product } from '@/lib/types'
|
||||
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
|
||||
|
||||
type Prefill =
|
||||
| { kind: 'barcode'; value: string; barcodeType: BarcodeType }
|
||||
| { kind: 'article'; value: string }
|
||||
| { kind: 'name'; value: string }
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
prefill: Prefill | null
|
||||
onCreated: (p: Product) => void
|
||||
}
|
||||
|
||||
/** Минималка для создания товара «на лету» из формы документа.
|
||||
* Поля: Название, Группа, Единица измерения. Штрихкод и артикул
|
||||
* pre-fill'ятся из набранного запроса в зависимости от типа. */
|
||||
export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) {
|
||||
const units = useUnits()
|
||||
const groups = useProductGroups()
|
||||
const org = useOrgSettings()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [article, setArticle] = useState('')
|
||||
const [barcode, setBarcode] = useState('')
|
||||
const [barcodeType, setBarcodeType] = useState<BarcodeType>(BarcodeType.Ean13)
|
||||
const [unitId, setUnitId] = useState('')
|
||||
const [groupId, setGroupId] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// На каждый open подставляем prefill и дефолты для пустых полей
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setError(null)
|
||||
if (prefill?.kind === 'barcode') {
|
||||
setBarcode(prefill.value)
|
||||
setBarcodeType(prefill.barcodeType)
|
||||
setName('')
|
||||
setArticle('')
|
||||
} else if (prefill?.kind === 'article') {
|
||||
setArticle(prefill.value)
|
||||
setBarcode(generateEan13InternalPrefix2())
|
||||
setBarcodeType(BarcodeType.Ean13)
|
||||
setName('')
|
||||
} else if (prefill?.kind === 'name') {
|
||||
setName(prefill.value)
|
||||
setArticle('')
|
||||
setBarcode(generateEan13InternalPrefix2())
|
||||
setBarcodeType(BarcodeType.Ean13)
|
||||
} else {
|
||||
setName(''); setArticle(''); setBarcode(generateEan13InternalPrefix2()); setBarcodeType(BarcodeType.Ean13)
|
||||
}
|
||||
}, [open, prefill])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!unitId && units.data?.length) {
|
||||
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
|
||||
setUnitId(sht.id)
|
||||
}
|
||||
if (!groupId && groups.data?.length) {
|
||||
const food = groups.data.find((g) => g.name === 'Продукты питания') ?? groups.data[0]
|
||||
setGroupId(food.id)
|
||||
}
|
||||
}, [open, units.data, groups.data, unitId, groupId])
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
article: article.trim() || null,
|
||||
description: null,
|
||||
unitOfMeasureId: unitId,
|
||||
vat: org.data?.showVatEnabledOnProduct ? (org.data?.vatRate ?? 0) : null,
|
||||
vatEnabled: true,
|
||||
productGroupId: groupId,
|
||||
defaultSupplierId: null,
|
||||
countryOfOriginId: null,
|
||||
isService: false,
|
||||
packaging: 0,
|
||||
isMarked: false,
|
||||
minStock: null,
|
||||
maxStock: null,
|
||||
referencePrice: null,
|
||||
purchaseCurrencyId: null,
|
||||
imageUrl: null,
|
||||
prices: [],
|
||||
barcodes: [{ code: barcode, type: barcodeType, isPrimary: true }],
|
||||
}
|
||||
const res = await api.post<Product>('/api/catalog/products', payload)
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (p) => { onCreated(p); onClose() },
|
||||
onError: (e: Error) => {
|
||||
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
|
||||
setError(msg)
|
||||
},
|
||||
})
|
||||
|
||||
const canSave = name.trim().length > 0 && unitId && groupId && barcode.trim().length > 0
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Быстрое создание товара"
|
||||
footer={<>
|
||||
<Button type="button" variant="secondary" onClick={onClose}>Отмена</Button>
|
||||
<Button type="button" disabled={!canSave || create.isPending} onClick={() => create.mutate()}>
|
||||
{create.isPending ? 'Создаю…' : 'Создать и добавить'}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
|
||||
<Field label="Название *">
|
||||
<TextInput value={name} onChange={(e) => setName(e.target.value)} autoFocus={prefill?.kind !== 'name'} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Артикул">
|
||||
<TextInput value={article} onChange={(e) => setArticle(e.target.value)} placeholder="авто" />
|
||||
</Field>
|
||||
<Field label="Штрихкод *">
|
||||
<TextInput value={barcode} onChange={(e) => setBarcode(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Группа *">
|
||||
<Select value={groupId} onChange={(e) => setGroupId(e.target.value)}>
|
||||
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Единица измерения *">
|
||||
<Select value={unitId} onChange={(e) => setUnitId(e.target.value)}>
|
||||
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Остальные поля (цены, описание, поставщик) можно дозаполнить позже в карточке товара.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
311
src/food-market.web/src/components/SupplyLineQuickAdd.tsx
Normal file
311
src/food-market.web/src/components/SupplyLineQuickAdd.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { BarcodeType, type Product } from '@/lib/types'
|
||||
import { ProductQuickCreateModal } from '@/components/ProductQuickCreateModal'
|
||||
|
||||
export interface QuickSearchItem {
|
||||
id: string
|
||||
name: string
|
||||
article: string | null
|
||||
defaultBarcode: string | null
|
||||
referencePrice: number | null
|
||||
stockQty: number | null
|
||||
}
|
||||
|
||||
export interface AddedProduct {
|
||||
id: string
|
||||
name: string
|
||||
article: string | null
|
||||
referencePrice: number | null
|
||||
unitName: string | null
|
||||
cost: number | null
|
||||
prices?: { amount: number }[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
storeId: string
|
||||
disabled?: boolean
|
||||
/** Колбэк добавления / инкремента строки. Возвращает true если был выполнен
|
||||
* инкремент (для всплывающего «Кол-во +1»), false если добавлена новая. */
|
||||
onPick: (product: AddedProduct) => boolean
|
||||
/** Текущее кол-во строк — для UX (autoscroll, sticky-position при необходимости). */
|
||||
linesCount?: number
|
||||
}
|
||||
|
||||
const isLikelyBarcode = (s: string) => /^\d{8,14}$/.test(s)
|
||||
function detectBarcodeType(s: string): BarcodeType {
|
||||
if (s.length === 8) return BarcodeType.Ean8
|
||||
if (s.length === 12) return BarcodeType.Upca
|
||||
if (s.length === 14) return BarcodeType.Other
|
||||
return BarcodeType.Ean13
|
||||
}
|
||||
|
||||
/** Inline-добавление строк в документ: единый input, автокомплит по
|
||||
* имени/артикулу/штрихкоду со стоковыми бэйджами, мгновенный pick по
|
||||
* сканеру (Enter после цифр), создание товара «на лету» если ничего
|
||||
* не нашлось. После каждого добавления input очищается и возвращает
|
||||
* фокус — чтобы можно было сканировать партию подряд. */
|
||||
export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [items, setItems] = useState<QuickSearchItem[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [highlight, setHighlight] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hint, setHint] = useState<string | null>(null)
|
||||
const [createPrefill, setCreatePrefill] = useState<null | (
|
||||
| { kind: 'barcode'; value: string; barcodeType: BarcodeType }
|
||||
| { kind: 'article'; value: string }
|
||||
| { kind: 'name'; value: string }
|
||||
)>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Автофокус при монтировании
|
||||
useEffect(() => {
|
||||
if (!disabled) inputRef.current?.focus()
|
||||
}, [disabled])
|
||||
|
||||
// Debounce 200ms
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(query), 200)
|
||||
return () => clearTimeout(t)
|
||||
}, [query])
|
||||
|
||||
// Поисковый запрос на каждое изменение debounced query
|
||||
useEffect(() => {
|
||||
const q = debounced.trim()
|
||||
if (q.length === 0) { setItems([]); setOpen(false); return }
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
api.get<QuickSearchItem[]>('/api/catalog/products/quick-search', {
|
||||
params: { search: q, storeId: storeId || undefined, limit: 20 },
|
||||
}).then((res) => {
|
||||
if (cancelled) return
|
||||
setItems(res.data)
|
||||
setOpen(true)
|
||||
setHighlight(0)
|
||||
}).catch(() => { if (!cancelled) setItems([]) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [debounced, storeId])
|
||||
|
||||
// Outside click — закрыть dropdown
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [open])
|
||||
|
||||
// Авто-исчезновение всплывающей подсказки
|
||||
useEffect(() => {
|
||||
if (!hint) return
|
||||
const t = setTimeout(() => setHint(null), 1500)
|
||||
return () => clearTimeout(t)
|
||||
}, [hint])
|
||||
|
||||
const fetchProductFull = useMutation({
|
||||
mutationFn: async (id: string) => (await api.get<Product>(`/api/catalog/products/${id}`)).data,
|
||||
})
|
||||
|
||||
const addById = async (id: string) => {
|
||||
const full = await fetchProductFull.mutateAsync(id)
|
||||
const incremented = onPick({
|
||||
id: full.id,
|
||||
name: full.name,
|
||||
article: full.article,
|
||||
referencePrice: full.referencePrice,
|
||||
unitName: full.unitName,
|
||||
cost: full.cost,
|
||||
prices: full.prices?.map((p) => ({ amount: p.amount })),
|
||||
})
|
||||
if (incremented) setHint('Кол-во увеличено на 1')
|
||||
setQuery(''); setDebounced(''); setItems([]); setOpen(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
// Точный barcode-поиск (для сканера: Enter сразу после цифр)
|
||||
const tryScanByBarcode = async (q: string): Promise<boolean> => {
|
||||
if (!isLikelyBarcode(q)) return false
|
||||
try {
|
||||
const res = await api.get(`/api/catalog/products/by-barcode/${encodeURIComponent(q)}`, {
|
||||
params: { storeId: storeId || undefined },
|
||||
})
|
||||
const data = res.data as QuickSearchItem | { items: QuickSearchItem[] }
|
||||
if ('items' in data && Array.isArray(data.items)) {
|
||||
// Несколько матчей — отдадим в dropdown через items, пользователь выберет.
|
||||
setItems(data.items); setOpen(true); setHighlight(0)
|
||||
return true
|
||||
}
|
||||
const it = data as QuickSearchItem
|
||||
await addById(it.id)
|
||||
return true
|
||||
} catch (e) {
|
||||
const status = (e as { response?: { status?: number } }).response?.status
|
||||
if (status === 404) {
|
||||
// Не нашли — предложим создать с pre-fill штрихкода
|
||||
setCreatePrefill({ kind: 'barcode', value: q, barcodeType: detectBarcodeType(q) })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = async (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') { setOpen(false); return }
|
||||
if (e.key === 'Tab') { setOpen(false); return }
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setHighlight((h) => Math.min(h + 1, items.length - 1))
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setHighlight((h) => Math.max(0, h - 1))
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const q = query.trim()
|
||||
if (q.length === 0) return
|
||||
// Сначала пробуем сканер-флоу — точный barcode lookup
|
||||
if (await tryScanByBarcode(q)) return
|
||||
// Иначе берём подсвеченный пункт из dropdown
|
||||
if (items.length > 0) {
|
||||
const target = items[highlight] ?? items[0]
|
||||
await addById(target.id)
|
||||
return
|
||||
}
|
||||
// Ничего не нашли — открываем модалку создания с pre-fill
|
||||
const kind = isLikelyBarcode(q) ? 'barcode' : /^\d+$/.test(q) ? 'article' : 'name'
|
||||
if (kind === 'barcode') setCreatePrefill({ kind, value: q, barcodeType: detectBarcodeType(q) })
|
||||
else if (kind === 'article') setCreatePrefill({ kind, value: q })
|
||||
else setCreatePrefill({ kind: 'name', value: q })
|
||||
}
|
||||
}
|
||||
|
||||
const onCreated = async (p: Product) => {
|
||||
onPick({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
article: p.article,
|
||||
referencePrice: p.referencePrice,
|
||||
unitName: p.unitName,
|
||||
cost: p.cost,
|
||||
prices: p.prices?.map((pr) => ({ amount: pr.amount })),
|
||||
})
|
||||
setQuery(''); setDebounced(''); setItems([]); setOpen(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const showCreateRow = useMemo(() => {
|
||||
const q = query.trim()
|
||||
return q.length > 0 && !loading
|
||||
}, [query, loading])
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
disabled={disabled}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => { if (items.length > 0 || query.trim()) setOpen(true) }}
|
||||
placeholder="Сканируйте штрихкод или начните вводить название / артикул / штрихкод…"
|
||||
className="w-full h-11 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50"
|
||||
/>
|
||||
{hint && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
{open && (query.trim().length > 0 || items.length > 0) && (
|
||||
<div className="absolute z-30 mt-1 w-full rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg max-h-80 overflow-auto">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-slate-400">Ищу…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</div>
|
||||
) : (
|
||||
<ul className="py-1">
|
||||
{items.map((it, i) => (
|
||||
<li key={it.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
onClick={() => addById(it.id)}
|
||||
className={`w-full flex items-center justify-between gap-3 px-3 py-1.5 text-left text-sm ${i === highlight ? 'bg-slate-100 dark:bg-slate-800' : ''}`}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate">
|
||||
{it.article && <span className="font-mono text-slate-400 mr-2">{it.article}</span>}
|
||||
<span className="text-slate-900 dark:text-slate-100">{highlightMatch(it.name, query.trim())}</span>
|
||||
{it.defaultBarcode && (
|
||||
<span className="ml-2 text-xs text-slate-400 font-mono">{it.defaultBarcode}</span>
|
||||
)}
|
||||
</span>
|
||||
<StockBadge qty={it.stockQty} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{showCreateRow && (
|
||||
<div className="border-t border-slate-100 dark:border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const q = query.trim()
|
||||
const kind = isLikelyBarcode(q) ? 'barcode' : /^\d+$/.test(q) ? 'article' : 'name'
|
||||
if (kind === 'barcode') setCreatePrefill({ kind, value: q, barcodeType: detectBarcodeType(q) })
|
||||
else if (kind === 'article') setCreatePrefill({ kind, value: q })
|
||||
else setCreatePrefill({ kind: 'name', value: q })
|
||||
setOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--color-brand)] hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{isLikelyBarcode(query.trim())
|
||||
? <>Создать товар со штрихкодом «{query.trim()}»</>
|
||||
: <>Создать новый товар: «{query.trim()}»</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductQuickCreateModal
|
||||
open={createPrefill !== null}
|
||||
onClose={() => setCreatePrefill(null)}
|
||||
prefill={createPrefill}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StockBadge({ qty }: { qty: number | null }) {
|
||||
if (qty === null || qty === undefined) return null
|
||||
const cls = qty > 0
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: qty < 0 ? 'bg-red-50 text-red-700 border-red-200'
|
||||
: 'bg-slate-50 text-slate-500 border-slate-200'
|
||||
return <span className={`text-xs font-mono px-1.5 py-0.5 rounded border ${cls}`}>{qty}</span>
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, q: string) {
|
||||
if (!q) return text
|
||||
const i = text.toLowerCase().indexOf(q.toLowerCase())
|
||||
if (i < 0) return text
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, i)}
|
||||
<mark className="bg-yellow-200 dark:bg-yellow-800 text-inherit px-0.5 rounded-sm">{text.slice(i, i + q.length)}</mark>
|
||||
{text.slice(i + q.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { api } from '@/lib/api'
|
|||
import { Button } from '@/components/Button'
|
||||
import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
||||
import { ProductPicker } from '@/components/ProductPicker'
|
||||
import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
|
||||
import { useStores, useCurrencies, useSuppliers, usePriceTypes } from '@/lib/useLookups'
|
||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
|
||||
|
|
@ -200,6 +201,35 @@ export function SupplyEditPage() {
|
|||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/** 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),
|
||||
})
|
||||
return true
|
||||
}
|
||||
const defaultRetail = p.prices?.[0]?.amount ?? null
|
||||
setForm({
|
||||
...form,
|
||||
lines: [...form.lines, {
|
||||
productId: p.id,
|
||||
productName: p.name,
|
||||
productArticle: p.article,
|
||||
unitName: p.unitName,
|
||||
quantity: 1,
|
||||
unitPrice: p.referencePrice ?? p.cost ?? 0,
|
||||
currentRetailPrice: defaultRetail,
|
||||
retailPriceManuallyOverridden: false,
|
||||
retailPriceOverride: null,
|
||||
}],
|
||||
})
|
||||
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) =>
|
||||
|
|
@ -332,12 +362,12 @@ export function SupplyEditPage() {
|
|||
title="Позиции"
|
||||
action={!isPosted && (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
|
||||
<Plus className="w-3.5 h-3.5" /> Добавить товар
|
||||
<Plus className="w-3.5 h-3.5" /> Добавить из справочника
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{form.lines.length === 0 ? (
|
||||
<div className="text-sm text-red-600 py-4 text-center">В приёмке должна быть хотя бы одна позиция. Нажми «Добавить товар».</div>
|
||||
<div className="text-sm text-red-600 py-3 text-center">В приёмке должна быть хотя бы одна позиция. Сканируйте штрихкод или начните вводить ниже.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
|
|
@ -416,6 +446,16 @@ export function SupplyEditPage() {
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPosted && (
|
||||
<div className="mt-3">
|
||||
<SupplyLineQuickAdd
|
||||
storeId={form.storeId}
|
||||
onPick={addOrIncrementLine}
|
||||
linesCount={form.lines.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue