fix(supply-quick-add): keep input focused after scan / clear on add
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 44s
CI / Web (React + Vite) (push) Successful in 36s
Docker Web / Build + push Web (push) Successful in 28s
Docker Web / Deploy Web on stage (push) Successful in 11s

Сценарий — приёмщик подряд сканирует 50 штрихкодов без клика мышью:

- Sticky-bar с input'ом теперь ВНЕ Section'а (overflow-hidden родителя
  ломал sticky), прибит к низу скроллируемого тела документа. После
  любого добавления строки — input всегда виден.
- Очистка query и refocus вызываются СРАЗУ после клика/Enter, до
  await на /products/{id}: пока сеть в полёте, юзер уже может начать
  следующий скан. После завершения запроса — повторный refocus
  (двойной guarded focus + requestAnimationFrame), чтобы перебить
  любые ререндеры родителя, которые могут увести фокус.
- Поиск по quick-search теперь через AbortController — устаревший
  ответ при быстром вводе подряд не подменяет свежий список.
- Закрытие ProductQuickCreateModal тоже возвращает фокус в input
  (и при «Создать», и при «Отмена»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 02:08:49 +05:00
parent c8a7efde47
commit 45f2ce682f
2 changed files with 52 additions and 33 deletions

View file

@ -1,6 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { createPortal } from 'react-dom'
import { useMutation } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { api } from '@/lib/api'
import { BarcodeType, type Product } from '@/lib/types'
@ -97,22 +96,26 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
return () => clearTimeout(t)
}, [query])
// Поисковый запрос на каждое изменение debounced query
// Поисковый запрос на каждое изменение debounced query. AbortController
// обрывает устаревший запрос, чтобы при быстрых последовательных вводах
// (или сразу после скана) старый ответ не подменил свежий список.
useEffect(() => {
const q = debounced.trim()
if (q.length === 0) { setItems([]); setOpen(false); return }
let cancelled = false
const ac = new AbortController()
setLoading(true)
api.get<QuickSearchItem[]>('/api/catalog/products/quick-search', {
params: { search: q, storeId: storeId || undefined, limit: 20 },
signal: ac.signal,
}).then((res) => {
if (cancelled) return
setItems(res.data)
setOpen(true)
setHighlight(0)
}).catch(() => { if (!cancelled) setItems([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}).catch((e) => {
if ((e as { name?: string }).name === 'CanceledError' || (e as { code?: string }).code === 'ERR_CANCELED') return
setItems([])
}).finally(() => setLoading(false))
return () => ac.abort()
}, [debounced, storeId])
// Outside click — закрыть dropdown. Dropdown рендерится в Portal, поэтому
@ -136,12 +139,22 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
return () => clearTimeout(t)
}, [hint])
const fetchProductFull = useMutation({
mutationFn: async (id: string) => (await api.get<Product>(`/api/catalog/products/${id}`)).data,
})
// Возврат фокуса после добавления строки — двойной guarded focus:
// сразу + через requestAnimationFrame, чтобы перебить любые ререндеры,
// которые могут увести фокус в null. Без этого подряд сканирование
// ломается на 2-3 скане, когда DOM подменяет input.
const refocus = () => {
inputRef.current?.focus()
requestAnimationFrame(() => inputRef.current?.focus())
}
const addById = async (id: string) => {
const full = await fetchProductFull.mutateAsync(id)
// Очищаем поле и возвращаем фокус СРАЗУ — пока сетевой запрос в полёте,
// юзер уже может начать ввод следующего штрихкода.
setQuery(''); setDebounced(''); setItems([]); setOpen(false)
refocus()
try {
const full = (await api.get<Product>(`/api/catalog/products/${id}`)).data
const incremented = onPick({
id: full.id,
name: full.name,
@ -152,8 +165,9 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
prices: full.prices?.map((p) => ({ amount: p.amount })),
})
if (incremented) setHint('Кол-во увеличено на 1')
setQuery(''); setDebounced(''); setItems([]); setOpen(false)
inputRef.current?.focus()
} finally {
refocus()
}
}
// Точный barcode-поиск (для сканера: Enter сразу после цифр)
@ -227,7 +241,7 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
prices: p.prices?.map((pr) => ({ amount: pr.amount })),
})
setQuery(''); setDebounced(''); setItems([]); setOpen(false)
inputRef.current?.focus()
refocus()
}
const showCreateRow = useMemo(() => {
@ -312,7 +326,7 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
<ProductQuickCreateModal
open={createPrefill !== null}
onClose={() => setCreatePrefill(null)}
onClose={() => { setCreatePrefill(null); refocus() }}
prefill={createPrefill}
onCreated={onCreated}
/>

View file

@ -447,8 +447,14 @@ export function SupplyEditPage() {
</div>
)}
</Section>
{!isPosted && (
<div className="mt-3">
// Sticky-bottom вне Section (overflow-hidden родителя ломает sticky):
// пользователь может подряд сканировать партию штрихкодов — после
// каждого скана строка добавляется в таблицу выше, а input остаётся
// прибит к низу скроллируемого тела документа и принимает следующий ввод.
<div className="sticky bottom-0 -mx-3 sm:-mx-6 px-3 sm:px-6 py-3 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 z-10 shadow-[0_-4px_12px_rgba(0,0,0,0.04)]">
<SupplyLineQuickAdd
storeId={form.storeId}
onPick={addOrIncrementLine}
@ -456,7 +462,6 @@ export function SupplyEditPage() {
/>
</div>
)}
</Section>
</div>
</div>