feat(ui): AsyncSelect — серверный поиск в дропдаунах вместо pageSize=500
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m5s
CI / Web (React + Vite) (push) Successful in 41s
Docker Web / Build + push Web (push) Successful in 32s
Docker Web / Deploy Web on stage (push) Successful in 12s

Добавлен 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 <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-28 14:13:29 +05:00
parent f61d8bc178
commit f54c8bb5b7
7 changed files with 217 additions and 49 deletions

View file

@ -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, unknown>) => 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, unknown>) => string
onCreate?: (label: string) => Promise<string>
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<string | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(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<Record<string, unknown>[]>({
queryKey: ['async-select', url, debouncedQuery],
queryFn: async () => {
const sep = url.includes('?') ? '&' : '?'
const res = await api.get<{ items: Record<string, unknown>[] }>(
`${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 (
<div ref={wrapRef} className="relative">
<button type="button" disabled={disabled} onClick={() => !disabled && setOpen((o) => !o)}
data-name={htmlName}
className={cn(inputClass, 'flex items-center justify-between text-left pr-9', className)}
>
<span className={cn('truncate', !selectedLabel && !value && 'text-slate-400')}>{triggerLabel}</span>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
</button>
{open && pos && createPortal(
<div ref={dropdownRef}
style={{ position: 'fixed', left: pos.left, width: pos.width, ...(pos.openUp ? { bottom: window.innerHeight - pos.top } : { top: pos.top }) }}
className="z-[100] max-h-72 overflow-hidden rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg flex flex-col"
>
<div className="p-2 border-b border-slate-100 dark:border-slate-800">
<input ref={searchRef} value={query} onChange={(e) => 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))
}
}}
/>
</div>
<ul className="py-1 overflow-auto" role="listbox">
{isFetching ? (
<li className="px-3 py-2 text-sm text-slate-400">Загрузка</li>
) : options.length === 0 && !canCreate ? (
<li className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</li>
) : options.map((opt, i) => {
const id = String(opt['id'] ?? ''); const label = getLabel(opt)
return (
<li key={id}>
<button type="button" onMouseEnter={() => setHighlight(i)} onClick={() => choose(id, label)}
className={cn('w-full text-left px-3 py-1.5 text-sm block',
i === highlight && 'bg-slate-100 dark:bg-slate-800',
id === value && 'font-medium text-[var(--color-brand)]',
)}
>
<span className="truncate block">{label}</span>
</button>
</li>
)
})}
{canCreate && (
<li className="border-t border-slate-100 dark:border-slate-800 mt-1 pt-1">
<button type="button" disabled={creating} onClick={handleCreate}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-slate-100 dark:hover:bg-slate-800 text-[var(--color-brand)] disabled:opacity-60"
>
{creating ? 'Создаю…' : `${createLabel} «${trimmed}»`}
</button>
{createError && <div className="px-3 py-1 text-xs text-red-600">{createError}</div>}
</li>
)}
</ul>
</div>,
document.body,
)}
</div>
)
}
interface MoneyInputProps {
value: number | null | undefined
onChange: (v: number | null) => void

View file

@ -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
</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>
<AsyncSelect
url="/api/catalog/product-groups"
value={groupId}
onChange={setGroupId}
placeholder="Выберите группу…"
getLabel={(item) => String(item['path'] ?? item['name'] ?? '')}
/>
</Field>
<Field label="Единица измерения *">
<Select value={unitId} onChange={(e) => setUnitId(e.target.value)}>

View file

@ -8,7 +8,7 @@ import type {
function useLookup<T>(key: string, url: string) {
return useQuery({
queryKey: [`lookup:${key}`],
queryFn: async () => (await api.get<PagedResult<T>>(`${url}?pageSize=500`)).data.items,
queryFn: async () => (await api.get<PagedResult<T>>(`${url}?pageSize=100`)).data.items,
// staleTime=0 + refetchOnMount: 'always' гарантируют что любая страница
// (например ProductEditPage), монтирующая usePriceTypes / useUnits / etc,
// сразу подтянет свежий снапшот справочника после правки в его UI —

View file

@ -12,7 +12,7 @@ export function useOrgInfra() {
const cashRegisters = useQuery({
queryKey: ['lookup:retail-points'],
queryFn: async () =>
(await api.get<PagedResult<RetailPoint>>('/api/catalog/retail-points?pageSize=500')).data.items,
(await api.get<PagedResult<RetailPoint>>('/api/catalog/retail-points?pageSize=100')).data.items,
staleTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: true,

View file

@ -4,9 +4,9 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import {
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes,
useUnits, useCountries, useCurrencies, usePriceTypes,
} from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, Packaging, type Product } from '@/lib/types'
@ -57,7 +57,6 @@ export function ProductEditPage() {
const qc = useQueryClient()
const units = useUnits()
const groups = useProductGroups()
const countries = useCountries()
const currencies = useCurrencies()
const priceTypes = usePriceTypes()
@ -99,11 +98,7 @@ export function ProductEditPage() {
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
setForm((f) => ({ ...f, unitOfMeasureId: sht.id }))
}
if (isNew && form.productGroupId === '' && groups.data?.length) {
// Дефолт — «Продукты питания», иначе первая.
const food = groups.data.find((g) => g.name === 'Продукты питания') ?? groups.data[0]
setForm((f) => ({ ...f, productGroupId: food.id }))
}
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
const def = org.data?.defaultCurrencyId
? currencies.data?.find(c => c.id === org.data!.defaultCurrencyId)
@ -129,7 +124,7 @@ export function ProductEditPage() {
barcodes: [{ code: generateEan13InternalPrefix2(), type: BarcodeType.Ean13, isPrimary: true }],
}))
}
}, [isNew, units.data, groups.data, currencies.data, org.data, form.unitOfMeasureId, form.productGroupId, form.purchaseCurrencyId, form.vat, form.article, form.barcodes.length])
}, [isNew, units.data, currencies.data, org.data, form.unitOfMeasureId, form.purchaseCurrencyId, form.vat, form.article, form.barcodes.length])
const save = useMutation({
mutationFn: async () => {
@ -448,9 +443,13 @@ export function ProductEditPage() {
<Section title="Классификация">
<Grid cols={3}>
<Field label="Группа *">
<Select required value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
</Select>
<AsyncSelect
url="/api/catalog/product-groups"
value={form.productGroupId}
onChange={(v) => setForm({ ...form, productGroupId: v })}
placeholder="Выберите группу…"
getLabel={(item) => String(item['path'] ?? item['name'] ?? '')}
/>
</Field>
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>

View file

@ -4,9 +4,9 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, MoneyInput, NumberInput } from '@/components/Field'
import { Field, TextInput, TextArea, Select, AsyncSelect, MoneyInput, NumberInput } from '@/components/Field'
import { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
@ -53,7 +53,6 @@ export function RetailSaleEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const customers = useSuppliers() // we re-use the suppliers hook (returns all counterparties) — fine for MVP
const [form, setForm] = useState<Form>(empty)
const [pickerOpen, setPickerOpen] = useState(false)
@ -268,11 +267,13 @@ export function RetailSaleEditPage() {
</Field>
)}
<Field label="Покупатель (опц.)">
<Select value={form.customerId} disabled={isPosted}
onChange={(e) => setForm({ ...form, customerId: e.target.value })}>
<option value=""> анонимный </option>
{customers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
<AsyncSelect
url="/api/catalog/counterparties"
value={form.customerId}
disabled={isPosted}
onChange={(v) => setForm({ ...form, customerId: v })}
placeholder="— анонимный —"
/>
</Field>
<Field label="Способ оплаты">
<Select value={form.payment} disabled={isPosted}

View file

@ -4,11 +4,11 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import { Field, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
import { useStores, useCurrencies, useSuppliers, usePriceTypes } from '@/lib/useLookups'
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
@ -63,7 +63,6 @@ export function SupplyEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const suppliers = useSuppliers()
const priceTypes = usePriceTypes()
// Системный (главный) тип цен — на нём по умолчанию ведётся розница на кассе.
// Заголовок колонки «Розничная» подменяется его именем чтобы соответствовать
@ -131,11 +130,8 @@ export function SupplyEditPage() {
: currencies.data.find((c) => c.code === 'KZT')
if (def) setForm((f) => ({ ...f, currencyId: def.id }))
}
if (!form.supplierId && suppliers.data?.length) {
setForm((f) => ({ ...f, supplierId: suppliers.data![0].id }))
}
}
}, [isNew, stores.data, currencies.data, suppliers.data, org.data?.defaultCurrencyId, form.storeId, form.currencyId, form.supplierId])
}, [isNew, stores.data, currencies.data, org.data?.defaultCurrencyId, form.storeId, form.currencyId])
const isDraft = isNew || existing.data?.status === SupplyStatus.Draft
const isPosted = existing.data?.status === SupplyStatus.Posted
@ -312,27 +308,23 @@ export function SupplyEditPage() {
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
</Field>
<Field label="Поставщик *">
<Select
<AsyncSelect
url="/api/catalog/counterparties"
value={form.supplierId}
disabled={isPosted}
onChange={(e) => 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
}}
>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
/>
</Field>
{(stores.data?.length ?? 0) > 1 && (
<Field label="Склад *">