feat(supply): inline line quick-add — scanner + autocomplete + create-on-fly
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 43s
CI / Web (React + Vite) (push) Successful in 35s
Docker Web / Build + push Web (push) Successful in 27s
Docker Web / Deploy Web on stage (push) Successful in 12s

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:
nns 2026-04-26 01:55:57 +05:00
parent 654481b2b9
commit e9f8da1b82
3 changed files with 506 additions and 2 deletions

View 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>
)
}

View 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)}
</>
)
}

View file

@ -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>