From 45f2ce682fd3646a1c26f3fd12454b085aaaa8a8 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:08:49 +0500 Subject: [PATCH] fix(supply-quick-add): keep input focused after scan / clear on add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сценарий — приёмщик подряд сканирует 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) --- .../src/components/SupplyLineQuickAdd.tsx | 62 ++++++++++++------- .../src/pages/SupplyEditPage.tsx | 23 ++++--- 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/src/food-market.web/src/components/SupplyLineQuickAdd.tsx b/src/food-market.web/src/components/SupplyLineQuickAdd.tsx index 333a27a..91667c4 100644 --- a/src/food-market.web/src/components/SupplyLineQuickAdd.tsx +++ b/src/food-market.web/src/components/SupplyLineQuickAdd.tsx @@ -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('/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,24 +139,35 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) { return () => clearTimeout(t) }, [hint]) - const fetchProductFull = useMutation({ - mutationFn: async (id: string) => (await api.get(`/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) - 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() + refocus() + try { + const full = (await api.get(`/api/catalog/products/${id}`)).data + 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') + } 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) { setCreatePrefill(null)} + onClose={() => { setCreatePrefill(null); refocus() }} prefill={createPrefill} onCreated={onCreated} /> diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index 019430b..191fc16 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -447,16 +447,21 @@ export function SupplyEditPage() { )} - {!isPosted && ( -
- -
- )} + + {!isPosted && ( + // Sticky-bottom вне Section (overflow-hidden родителя ломает sticky): + // пользователь может подряд сканировать партию штрихкодов — после + // каждого скана строка добавляется в таблицу выше, а input остаётся + // прибит к низу скроллируемого тела документа и принимает следующий ввод. +
+ +
+ )}