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 { Button } from '@/components/Button'
|
||||||
import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
||||||
import { ProductPicker } from '@/components/ProductPicker'
|
import { ProductPicker } from '@/components/ProductPicker'
|
||||||
|
import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
|
||||||
import { useStores, useCurrencies, useSuppliers, usePriceTypes } from '@/lib/useLookups'
|
import { useStores, useCurrencies, useSuppliers, usePriceTypes } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
|
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>) =>
|
const updateLine = (i: number, patch: Partial<LineRow>) =>
|
||||||
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
|
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
|
||||||
const removeLine = (i: number) =>
|
const removeLine = (i: number) =>
|
||||||
|
|
@ -332,12 +362,12 @@ export function SupplyEditPage() {
|
||||||
title="Позиции"
|
title="Позиции"
|
||||||
action={!isPosted && (
|
action={!isPosted && (
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{form.lines.length === 0 ? (
|
{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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
|
|
@ -416,6 +446,16 @@ export function SupplyEditPage() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isPosted && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<SupplyLineQuickAdd
|
||||||
|
storeId={form.storeId}
|
||||||
|
onPick={addOrIncrementLine}
|
||||||
|
linesCount={form.lines.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue