fix(supply-quick-add): dropdown opens upward + show only N results + create-new at bottom
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 48s
CI / Web (React + Vite) (push) Successful in 34s
Docker Web / Build + push Web (push) Successful in 27s
Docker Web / Deploy Web on stage (push) Successful in 11s

UX как в МойСкладе:
- Dropdown открывается ВВЕРХ от input'а (anchor по input.top, fixed
  bottom). Max-height 60vh — не перекрывает шапку, внутри overflow-y.
  Раньше выпадал вниз и при добавлении строк визуально не оставалось
  места.
- Показываем первые 10 матчей (VISIBLE_LIMIT=10), под ними ссылка
  «Ещё N товаров» которая раскрывает полный список (limit=50 на
  сервере). На запрос приходит до 50 — этого достаточно для
  подавляющего большинства поисков.
- Под списком (через тонкий разделитель) — пункт «+ Создать новый
  товар: «{q}»» / «Создать товар со штрихкодом «…»». Всегда последний
  визуально, ближайший к input'у — самый удобный для быстрого клика.

Стрелки ↑↓ работают по видимой части (1..10 или весь список после
expand). Enter подбирает подсвеченный из видимой части.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 02:33:57 +05:00
parent 6f839bf57a
commit cad6b32f5e

View file

@ -54,6 +54,10 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [highlight, setHighlight] = useState(0) const [highlight, setHighlight] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// По умолчанию показываем первые 10 матчей. Ссылка «Ещё N товаров»
// расширяет dropdown до полного списка (limit=50 на запрос).
const VISIBLE_LIMIT = 10
const [showAll, setShowAll] = useState(false)
const [hint, setHint] = useState<string | null>(null) const [hint, setHint] = useState<string | null>(null)
const [createPrefill, setCreatePrefill] = useState<null | ( const [createPrefill, setCreatePrefill] = useState<null | (
| { kind: 'barcode'; value: string; barcodeType: BarcodeType } | { kind: 'barcode'; value: string; barcodeType: BarcodeType }
@ -63,15 +67,17 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const wrapRef = useRef<HTMLDivElement>(null) const wrapRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number } | null>(null) const [dropdownPos, setDropdownPos] = useState<{ bottom: number; left: number; width: number } | null>(null)
// Считаем позицию dropdown'а по rect input'а — без этого // Считаем позицию dropdown'а по rect input'а. Dropdown открывается ВВЕРХ
// он рендерится через portal в body и будет в углу. // от input'а (МС-стиль): фиксируем `bottom` относительно низа viewport,
// вычисленный из input.top, и max-height 60vh — расти вверх в пределах
// экрана. Без этого portal в body рендерил бы попап в углу.
const recomputePos = () => { const recomputePos = () => {
const el = inputRef.current const el = inputRef.current
if (!el) return if (!el) return
const r = el.getBoundingClientRect() const r = el.getBoundingClientRect()
setDropdownPos({ top: r.bottom + 4, left: r.left, width: r.width }) setDropdownPos({ bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width })
} }
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) return if (!open) return
@ -104,8 +110,9 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
if (q.length === 0) { setItems([]); setOpen(false); return } if (q.length === 0) { setItems([]); setOpen(false); return }
const ac = new AbortController() const ac = new AbortController()
setLoading(true) setLoading(true)
setShowAll(false)
api.get<QuickSearchItem[]>('/api/catalog/products/quick-search', { api.get<QuickSearchItem[]>('/api/catalog/products/quick-search', {
params: { search: q, storeId: storeId || undefined, limit: 20 }, params: { search: q, storeId: storeId || undefined, limit: 50 },
signal: ac.signal, signal: ac.signal,
}).then((res) => { }).then((res) => {
setItems(res.data) setItems(res.data)
@ -200,9 +207,10 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
const onKeyDown = async (e: KeyboardEvent<HTMLInputElement>) => { const onKeyDown = async (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') { setOpen(false); return } if (e.key === 'Escape') { setOpen(false); return }
if (e.key === 'Tab') { setOpen(false); return } if (e.key === 'Tab') { setOpen(false); return }
const visibleLen = (showAll ? items.length : Math.min(items.length, VISIBLE_LIMIT))
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
setHighlight((h) => Math.min(h + 1, items.length - 1)) setHighlight((h) => Math.min(h + 1, Math.max(0, visibleLen - 1)))
return return
} }
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
@ -216,9 +224,10 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
if (q.length === 0) return if (q.length === 0) return
// Сначала пробуем сканер-флоу — точный barcode lookup // Сначала пробуем сканер-флоу — точный barcode lookup
if (await tryScanByBarcode(q)) return if (await tryScanByBarcode(q)) return
// Иначе берём подсвеченный пункт из dropdown // Иначе берём подсвеченный пункт из видимой части dropdown'а
if (items.length > 0) { const visible = showAll ? items : items.slice(0, VISIBLE_LIMIT)
const target = items[highlight] ?? items[0] if (visible.length > 0) {
const target = visible[highlight] ?? visible[0]
await addById(target.id) await addById(target.id)
return return
} }
@ -269,16 +278,16 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
{open && (query.trim().length > 0 || items.length > 0) && dropdownPos && createPortal( {open && (query.trim().length > 0 || items.length > 0) && dropdownPos && createPortal(
<div <div
ref={dropdownRef} ref={dropdownRef}
style={{ position: 'fixed', top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width }} style={{ position: 'fixed', bottom: dropdownPos.bottom, left: dropdownPos.left, width: dropdownPos.width, maxHeight: '60vh' }}
className="z-[100] rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg max-h-80 overflow-auto" className="z-[100] rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg flex flex-col overflow-hidden"
> >
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="px-3 py-2 text-sm text-slate-400">Ищу</div> <div className="px-3 py-2 text-sm text-slate-400">Ищу</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</div> <div className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</div>
) : ( ) : (
<ul className="py-1"> <ul className="py-1 overflow-y-auto">
{items.map((it, i) => ( {(showAll ? items : items.slice(0, VISIBLE_LIMIT)).map((it, i) => (
<li key={it.id}> <li key={it.id}>
<button <button
type="button" type="button"
@ -299,6 +308,17 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
))} ))}
</ul> </ul>
)} )}
{!showAll && items.length > VISIBLE_LIMIT && (
<div className="border-t border-slate-100 dark:border-slate-800">
<button
type="button"
onClick={() => setShowAll(true)}
className="w-full text-left px-3 py-2 text-sm text-[var(--color-brand)] hover:bg-slate-100 dark:hover:bg-slate-800"
>
Ещё {items.length - VISIBLE_LIMIT} {plural(items.length - VISIBLE_LIMIT, 'товар', 'товара', 'товаров')}
</button>
</div>
)}
{showCreateRow && ( {showCreateRow && (
<div className="border-t border-slate-100 dark:border-slate-800"> <div className="border-t border-slate-100 dark:border-slate-800">
<button <button
@ -343,6 +363,14 @@ function StockBadge({ qty }: { qty: number | null }) {
return <span className={`text-xs font-mono px-1.5 py-0.5 rounded border ${cls}`}>{qty}</span> return <span className={`text-xs font-mono px-1.5 py-0.5 rounded border ${cls}`}>{qty}</span>
} }
function plural(n: number, one: string, few: string, many: string): string {
const mod10 = n % 10
const mod100 = n % 100
if (mod10 === 1 && mod100 !== 11) return one
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few
return many
}
function highlightMatch(text: string, q: string) { function highlightMatch(text: string, q: string) {
if (!q) return text if (!q) return text
const i = text.toLowerCase().indexOf(q.toLowerCase()) const i = text.toLowerCase().indexOf(q.toLowerCase())