From f54c8bb5b778611fce40af5708da9315c73b6603 Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:13:29 +0500 Subject: [PATCH] =?UTF-8?q?feat(ui):=20AsyncSelect=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=20=D0=B2=20=D0=B4=D1=80=D0=BE=D0=BF=D0=B4?= =?UTF-8?q?=D0=B0=D1=83=D0=BD=D0=B0=D1=85=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20pageSize=3D500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен AsyncSelect в Field.tsx: дебаунс 300ms, fetch ?search=&pageSize=20, поддержка onCreate, getLabel для кастомного отображения (path у групп товаров). Переведены на AsyncSelect: - SupplyEditPage: поставщик - RetailSaleEditPage: покупатель - ProductEditPage: группа товара - ProductQuickCreateModal: группа товара useLookups/useOrgInfra: pageSize 500→100 для малых справочников. Co-Authored-By: Claude Sonnet 4.6 --- src/food-market.web/src/components/Field.tsx | 177 ++++++++++++++++++ .../components/ProductQuickCreateModal.tsx | 21 +-- src/food-market.web/src/lib/useLookups.ts | 2 +- src/food-market.web/src/lib/useOrgInfra.ts | 2 +- .../src/pages/ProductEditPage.tsx | 23 ++- .../src/pages/RetailSaleEditPage.tsx | 17 +- .../src/pages/SupplyEditPage.tsx | 24 +-- 7 files changed, 217 insertions(+), 49 deletions(-) diff --git a/src/food-market.web/src/components/Field.tsx b/src/food-market.web/src/components/Field.tsx index dd6fcd3..053b894 100644 --- a/src/food-market.web/src/components/Field.tsx +++ b/src/food-market.web/src/components/Field.tsx @@ -3,8 +3,10 @@ import { type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes, } from 'react' import { createPortal } from 'react-dom' +import { useQuery } from '@tanstack/react-query' import { ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' +import { api } from '@/lib/api' import { useOrgSettings } from '@/lib/useOrgSettings' import { localizeNativeValidation } from '@/lib/validation' @@ -275,6 +277,181 @@ export function Select({ ) } +/** Select с серверным поиском: при каждом вводе дёргает endpoint с ?search=&pageSize=20. + * Не требует заранее загруженных опций — подходит для справочников с неограниченным ростом + * (контрагенты, группы товаров). API: value/onChange работают с ID строкой. + * getLabel — как извлечь отображаемый текст из пришедшего объекта (дефолт: item.name). */ +export function AsyncSelect({ + url, value, onChange, placeholder, disabled, className, name: htmlName, + getLabel = (item: Record) => String(item['name'] ?? ''), + onCreate, createLabel = 'Создать', +}: { + url: string + value?: string | null + onChange: (value: string) => void + placeholder?: string + disabled?: boolean + className?: string + name?: string + getLabel?: (item: Record) => string + onCreate?: (label: string) => Promise + createLabel?: string +}) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [highlight, setHighlight] = useState(0) + const [selectedLabel, setSelectedLabel] = useState('') + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState(null) + const wrapRef = useRef(null) + const searchRef = useRef(null) + const dropdownRef = useRef(null) + const [pos, setPos] = useState<{ top: number; left: number; width: number; openUp: boolean } | null>(null) + + useEffect(() => { + const t = setTimeout(() => setDebouncedQuery(query), 300) + return () => clearTimeout(t) + }, [query]) + + const { data: options = [], isFetching } = useQuery[]>({ + queryKey: ['async-select', url, debouncedQuery], + queryFn: async () => { + const sep = url.includes('?') ? '&' : '?' + const res = await api.get<{ items: Record[] }>( + `${url}${sep}search=${encodeURIComponent(debouncedQuery.trim())}&pageSize=20`, + ) + return res.data.items + }, + enabled: open, + staleTime: 30_000, + placeholderData: (prev) => prev, + }) + + useEffect(() => { + if (!value) { setSelectedLabel(''); return } + const found = options.find((o) => o['id'] === value) + if (found) setSelectedLabel(getLabel(found)) + }, [options, value, getLabel]) + + const recomputePos = () => { + const el = wrapRef.current + if (!el) return + const r = el.getBoundingClientRect() + const spaceBelow = window.innerHeight - r.bottom + const openUp = spaceBelow < 240 && r.top > spaceBelow + setPos({ top: openUp ? r.top - 4 : r.bottom + 4, left: r.left, width: r.width, openUp }) + } + useLayoutEffect(() => { + if (!open) return + recomputePos() + const fn = () => setOpen(false) + window.addEventListener('scroll', fn, true) + window.addEventListener('resize', fn) + return () => { window.removeEventListener('scroll', fn, true); window.removeEventListener('resize', fn) } + }, [open]) + + useEffect(() => { + if (!open) return + const onDoc = (e: MouseEvent) => { + const t = e.target as Node + if (!wrapRef.current?.contains(t) && !dropdownRef.current?.contains(t)) { setOpen(false); setQuery('') } + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, [open]) + + useEffect(() => { if (open) requestAnimationFrame(() => searchRef.current?.focus()) }, [open]) + useEffect(() => { setHighlight(0) }, [query, open]) + + const choose = (id: string, label: string) => { + setOpen(false); setQuery(''); setCreateError(null) + setSelectedLabel(label); onChange(id) + } + + const trimmed = query.trim() + const exactMatch = options.some((o) => getLabel(o).toLowerCase() === trimmed.toLowerCase()) + const canCreate = !!onCreate && trimmed.length > 0 && !exactMatch + + const handleCreate = async () => { + if (!onCreate || !trimmed) return + setCreating(true); setCreateError(null) + try { choose(await onCreate(trimmed), trimmed) } + catch (e) { setCreateError((e as Error).message || 'Не удалось создать') } + finally { setCreating(false) } + } + + const triggerLabel = selectedLabel || (value ? '…' : (placeholder ?? '—')) + + return ( +
+ + {open && pos && createPortal( +
+
+ setQuery(e.target.value)} + placeholder="Поиск…" + className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]" + onKeyDown={(e) => { + if (e.key === 'Escape') { e.preventDefault(); setOpen(false); setQuery('') } + else if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, Math.max(0, options.length - 1))) } + else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)) } + else if (e.key === 'Enter') { + e.preventDefault() + if (options.length === 0 && canCreate) { handleCreate(); return } + const opt = options[highlight] + if (opt) choose(String(opt['id'] ?? ''), getLabel(opt)) + } + }} + /> +
+
    + {isFetching ? ( +
  • Загрузка…
  • + ) : options.length === 0 && !canCreate ? ( +
  • Ничего не найдено
  • + ) : options.map((opt, i) => { + const id = String(opt['id'] ?? ''); const label = getLabel(opt) + return ( +
  • + +
  • + ) + })} + {canCreate && ( +
  • + + {createError &&
    {createError}
    } +
  • + )} +
+
, + document.body, + )} +
+ ) +} + interface MoneyInputProps { value: number | null | undefined onChange: (v: number | null) => void diff --git a/src/food-market.web/src/components/ProductQuickCreateModal.tsx b/src/food-market.web/src/components/ProductQuickCreateModal.tsx index 07fe56a..50a979f 100644 --- a/src/food-market.web/src/components/ProductQuickCreateModal.tsx +++ b/src/food-market.web/src/components/ProductQuickCreateModal.tsx @@ -3,8 +3,8 @@ 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 { Field, TextInput, Select, AsyncSelect } from '@/components/Field' +import { useUnits } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' import { BarcodeType, type Product } from '@/lib/types' import { generateEan13InternalPrefix2 } from '@/lib/barcode' @@ -26,7 +26,6 @@ interface Props { * pre-fill'ятся из набранного запроса в зависимости от типа. */ export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) { const units = useUnits() - const groups = useProductGroups() const org = useOrgSettings() const [name, setName] = useState('') @@ -67,11 +66,7 @@ export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: P 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]) + }, [open, units.data, unitId]) const create = useMutation({ mutationFn: async () => { @@ -134,9 +129,13 @@ export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: P
- + String(item['path'] ?? item['name'] ?? '')} + /> setForm({ ...form, productGroupId: e.target.value })}> - {groups.data?.map((g) => )} - + setForm({ ...form, productGroupId: v })} + placeholder="Выберите группу…" + getLabel={(item) => String(item['path'] ?? item['name'] ?? '')} + /> setForm({ ...form, customerId: e.target.value })}> - - {customers.data?.map((c) => )} - + setForm({ ...form, customerId: v })} + placeholder="— анонимный —" + /> setForm({ ...form, supplierId: e.target.value })} + onChange={(v) => setForm({ ...form, supplierId: v })} + placeholder="Выберите поставщика…" createLabel="Создать поставщика" onCreate={async (name) => { - // Быстрое создание — только Name + Type=LegalEntity, остальное - // редактируется потом в справочнике контрагентов. const created = await api.post<{ id: string }>('/api/catalog/counterparties', { name, legalName: null, type: 1, bin: null, iin: null, taxNumber: null, countryId: null, address: null, phone: null, email: null, bankName: null, bankAccount: null, bik: null, contactPerson: null, notes: null, }) - await qc.invalidateQueries({ queryKey: ['lookup:counterparties'] }) return created.data.id }} - > - - {suppliers.data?.map((c) => )} - + /> {(stores.data?.length ?? 0) > 1 && (