From e9f8da1b82a51a7dd4b73fae125dbcc750649057 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:55:57 +0500 Subject: [PATCH] =?UTF-8?q?feat(supply):=20inline=20line=20quick-add=20?= =?UTF-8?q?=E2=80=94=20scanner=20+=20autocomplete=20+=20create-on-fly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/ProductQuickCreateModal.tsx | 153 +++++++++ .../src/components/SupplyLineQuickAdd.tsx | 311 ++++++++++++++++++ .../src/pages/SupplyEditPage.tsx | 44 ++- 3 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 src/food-market.web/src/components/ProductQuickCreateModal.tsx create mode 100644 src/food-market.web/src/components/SupplyLineQuickAdd.tsx diff --git a/src/food-market.web/src/components/ProductQuickCreateModal.tsx b/src/food-market.web/src/components/ProductQuickCreateModal.tsx new file mode 100644 index 0000000..07fe56a --- /dev/null +++ b/src/food-market.web/src/components/ProductQuickCreateModal.tsx @@ -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.Ean13) + const [unitId, setUnitId] = useState('') + const [groupId, setGroupId] = useState('') + const [error, setError] = useState(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('/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 ( + + + + } + > +
+ {error &&
{error}
} + + setName(e.target.value)} autoFocus={prefill?.kind !== 'name'} /> + +
+ + setArticle(e.target.value)} placeholder="авто" /> + + + setBarcode(e.target.value)} /> + +
+
+ + + + + + +
+

+ Остальные поля (цены, описание, поставщик) можно дозаполнить позже в карточке товара. +

+
+
+ ) +} diff --git a/src/food-market.web/src/components/SupplyLineQuickAdd.tsx b/src/food-market.web/src/components/SupplyLineQuickAdd.tsx new file mode 100644 index 0000000..9f2a1db --- /dev/null +++ b/src/food-market.web/src/components/SupplyLineQuickAdd.tsx @@ -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([]) + const [open, setOpen] = useState(false) + const [highlight, setHighlight] = useState(0) + const [loading, setLoading] = useState(false) + const [hint, setHint] = useState(null) + const [createPrefill, setCreatePrefill] = useState(null) + const inputRef = useRef(null) + const wrapRef = useRef(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('/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(`/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 => { + 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) => { + 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 ( +
+ 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 && ( +
+ {hint} +
+ )} + {open && (query.trim().length > 0 || items.length > 0) && ( +
+ {loading && items.length === 0 ? ( +
Ищу…
+ ) : items.length === 0 ? ( +
Ничего не найдено
+ ) : ( +
    + {items.map((it, i) => ( +
  • + +
  • + ))} +
+ )} + {showCreateRow && ( +
+ +
+ )} +
+ )} + + setCreatePrefill(null)} + prefill={createPrefill} + onCreated={onCreated} + /> +
+ ) +} + +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 {qty} +} + +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)} + {text.slice(i, i + q.length)} + {text.slice(i + q.length)} + + ) +} diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index aa40cbd..019430b 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -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) => 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 && ( )} > {form.lines.length === 0 ? ( -
В приёмке должна быть хотя бы одна позиция. Нажми «Добавить товар».
+
В приёмке должна быть хотя бы одна позиция. Сканируйте штрихкод или начните вводить ниже.
) : (
@@ -416,6 +446,16 @@ export function SupplyEditPage() {
)} + + {!isPosted && ( +
+ +
+ )}