feat(ui): AsyncSelect — серверный поиск в дропдаунах вместо pageSize=500
Some checks are pending
Some checks are pending
Добавлен 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:
parent
f61d8bc178
commit
f54c8bb5b7
|
|
@ -3,8 +3,10 @@ import {
|
||||||
type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes,
|
type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { ChevronDown } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { localizeNativeValidation } from '@/lib/validation'
|
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 {
|
interface MoneyInputProps {
|
||||||
value: number | null | undefined
|
value: number | null | undefined
|
||||||
onChange: (v: number | null) => void
|
onChange: (v: number | null) => void
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { useMutation } from '@tanstack/react-query'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Field, TextInput, Select } from '@/components/Field'
|
import { Field, TextInput, Select, AsyncSelect } from '@/components/Field'
|
||||||
import { useUnits, useProductGroups } from '@/lib/useLookups'
|
import { useUnits } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { BarcodeType, type Product } from '@/lib/types'
|
import { BarcodeType, type Product } from '@/lib/types'
|
||||||
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
|
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
|
||||||
|
|
@ -26,7 +26,6 @@ interface Props {
|
||||||
* pre-fill'ятся из набранного запроса в зависимости от типа. */
|
* pre-fill'ятся из набранного запроса в зависимости от типа. */
|
||||||
export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) {
|
export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) {
|
||||||
const units = useUnits()
|
const units = useUnits()
|
||||||
const groups = useProductGroups()
|
|
||||||
const org = useOrgSettings()
|
const org = useOrgSettings()
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
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]
|
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
|
||||||
setUnitId(sht.id)
|
setUnitId(sht.id)
|
||||||
}
|
}
|
||||||
if (!groupId && groups.data?.length) {
|
}, [open, units.data, unitId])
|
||||||
const food = groups.data.find((g) => g.name === 'Продукты питания') ?? groups.data[0]
|
|
||||||
setGroupId(food.id)
|
|
||||||
}
|
|
||||||
}, [open, units.data, groups.data, unitId, groupId])
|
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
@ -134,9 +129,13 @@ export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: P
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Группа *">
|
<Field label="Группа *">
|
||||||
<Select value={groupId} onChange={(e) => setGroupId(e.target.value)}>
|
<AsyncSelect
|
||||||
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
|
url="/api/catalog/product-groups"
|
||||||
</Select>
|
value={groupId}
|
||||||
|
onChange={setGroupId}
|
||||||
|
placeholder="Выберите группу…"
|
||||||
|
getLabel={(item) => String(item['path'] ?? item['name'] ?? '')}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Единица измерения *">
|
<Field label="Единица измерения *">
|
||||||
<Select value={unitId} onChange={(e) => setUnitId(e.target.value)}>
|
<Select value={unitId} onChange={(e) => setUnitId(e.target.value)}>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
function useLookup<T>(key: string, url: string) {
|
function useLookup<T>(key: string, url: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [`lookup:${key}`],
|
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' гарантируют что любая страница
|
// staleTime=0 + refetchOnMount: 'always' гарантируют что любая страница
|
||||||
// (например ProductEditPage), монтирующая usePriceTypes / useUnits / etc,
|
// (например ProductEditPage), монтирующая usePriceTypes / useUnits / etc,
|
||||||
// сразу подтянет свежий снапшот справочника после правки в его UI —
|
// сразу подтянет свежий снапшот справочника после правки в его UI —
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export function useOrgInfra() {
|
||||||
const cashRegisters = useQuery({
|
const cashRegisters = useQuery({
|
||||||
queryKey: ['lookup:retail-points'],
|
queryKey: ['lookup:retail-points'],
|
||||||
queryFn: async () =>
|
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,
|
staleTime: 0,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Button } from '@/components/Button'
|
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 {
|
import {
|
||||||
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes,
|
useUnits, useCountries, useCurrencies, usePriceTypes,
|
||||||
} from '@/lib/useLookups'
|
} from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
||||||
|
|
@ -57,7 +57,6 @@ export function ProductEditPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const units = useUnits()
|
const units = useUnits()
|
||||||
const groups = useProductGroups()
|
|
||||||
const countries = useCountries()
|
const countries = useCountries()
|
||||||
const currencies = useCurrencies()
|
const currencies = useCurrencies()
|
||||||
const priceTypes = usePriceTypes()
|
const priceTypes = usePriceTypes()
|
||||||
|
|
@ -99,11 +98,7 @@ export function ProductEditPage() {
|
||||||
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
|
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
|
||||||
setForm((f) => ({ ...f, unitOfMeasureId: sht.id }))
|
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) {
|
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
|
||||||
const def = org.data?.defaultCurrencyId
|
const def = org.data?.defaultCurrencyId
|
||||||
? currencies.data?.find(c => c.id === 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 }],
|
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({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
@ -448,9 +443,13 @@ export function ProductEditPage() {
|
||||||
<Section title="Классификация">
|
<Section title="Классификация">
|
||||||
<Grid cols={3}>
|
<Grid cols={3}>
|
||||||
<Field label="Группа *">
|
<Field label="Группа *">
|
||||||
<Select required value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
|
<AsyncSelect
|
||||||
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
|
url="/api/catalog/product-groups"
|
||||||
</Select>
|
value={form.productGroupId}
|
||||||
|
onChange={(v) => setForm({ ...form, productGroupId: v })}
|
||||||
|
placeholder="Выберите группу…"
|
||||||
|
getLabel={(item) => String(item['path'] ?? item['name'] ?? '')}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Единица измерения *">
|
<Field label="Единица измерения *">
|
||||||
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Button } from '@/components/Button'
|
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 { ProductPicker } from '@/components/ProductPicker'
|
||||||
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
|
import { useStores, useCurrencies } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
|
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -53,7 +53,6 @@ export function RetailSaleEditPage() {
|
||||||
const stores = useStores()
|
const stores = useStores()
|
||||||
const currencies = useCurrencies()
|
const currencies = useCurrencies()
|
||||||
const org = useOrgSettings()
|
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 [form, setForm] = useState<Form>(empty)
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
|
@ -268,11 +267,13 @@ export function RetailSaleEditPage() {
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
<Field label="Покупатель (опц.)">
|
<Field label="Покупатель (опц.)">
|
||||||
<Select value={form.customerId} disabled={isPosted}
|
<AsyncSelect
|
||||||
onChange={(e) => setForm({ ...form, customerId: e.target.value })}>
|
url="/api/catalog/counterparties"
|
||||||
<option value="">— анонимный —</option>
|
value={form.customerId}
|
||||||
{customers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
disabled={isPosted}
|
||||||
</Select>
|
onChange={(v) => setForm({ ...form, customerId: v })}
|
||||||
|
placeholder="— анонимный —"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Способ оплаты">
|
<Field label="Способ оплаты">
|
||||||
<Select value={form.payment} disabled={isPosted}
|
<Select value={form.payment} disabled={isPosted}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Button } from '@/components/Button'
|
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 { DateField } from '@/components/DateField'
|
||||||
import { ProductPicker } from '@/components/ProductPicker'
|
import { ProductPicker } from '@/components/ProductPicker'
|
||||||
import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
|
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 { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
|
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -63,7 +63,6 @@ export function SupplyEditPage() {
|
||||||
const stores = useStores()
|
const stores = useStores()
|
||||||
const currencies = useCurrencies()
|
const currencies = useCurrencies()
|
||||||
const org = useOrgSettings()
|
const org = useOrgSettings()
|
||||||
const suppliers = useSuppliers()
|
|
||||||
const priceTypes = usePriceTypes()
|
const priceTypes = usePriceTypes()
|
||||||
// Системный (главный) тип цен — на нём по умолчанию ведётся розница на кассе.
|
// Системный (главный) тип цен — на нём по умолчанию ведётся розница на кассе.
|
||||||
// Заголовок колонки «Розничная» подменяется его именем чтобы соответствовать
|
// Заголовок колонки «Розничная» подменяется его именем чтобы соответствовать
|
||||||
|
|
@ -131,11 +130,8 @@ export function SupplyEditPage() {
|
||||||
: currencies.data.find((c) => c.code === 'KZT')
|
: currencies.data.find((c) => c.code === 'KZT')
|
||||||
if (def) setForm((f) => ({ ...f, currencyId: def.id }))
|
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 isDraft = isNew || existing.data?.status === SupplyStatus.Draft
|
||||||
const isPosted = existing.data?.status === SupplyStatus.Posted
|
const isPosted = existing.data?.status === SupplyStatus.Posted
|
||||||
|
|
@ -312,27 +308,23 @@ export function SupplyEditPage() {
|
||||||
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
|
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Поставщик *">
|
<Field label="Поставщик *">
|
||||||
<Select
|
<AsyncSelect
|
||||||
|
url="/api/catalog/counterparties"
|
||||||
value={form.supplierId}
|
value={form.supplierId}
|
||||||
disabled={isPosted}
|
disabled={isPosted}
|
||||||
onChange={(e) => setForm({ ...form, supplierId: e.target.value })}
|
onChange={(v) => setForm({ ...form, supplierId: v })}
|
||||||
|
placeholder="Выберите поставщика…"
|
||||||
createLabel="Создать поставщика"
|
createLabel="Создать поставщика"
|
||||||
onCreate={async (name) => {
|
onCreate={async (name) => {
|
||||||
// Быстрое создание — только Name + Type=LegalEntity, остальное
|
|
||||||
// редактируется потом в справочнике контрагентов.
|
|
||||||
const created = await api.post<{ id: string }>('/api/catalog/counterparties', {
|
const created = await api.post<{ id: string }>('/api/catalog/counterparties', {
|
||||||
name, legalName: null, type: 1,
|
name, legalName: null, type: 1,
|
||||||
bin: null, iin: null, taxNumber: null, countryId: null,
|
bin: null, iin: null, taxNumber: null, countryId: null,
|
||||||
address: null, phone: null, email: null,
|
address: null, phone: null, email: null,
|
||||||
bankName: null, bankAccount: null, bik: null, contactPerson: null, notes: null,
|
bankName: null, bankAccount: null, bik: null, contactPerson: null, notes: null,
|
||||||
})
|
})
|
||||||
await qc.invalidateQueries({ queryKey: ['lookup:counterparties'] })
|
|
||||||
return created.data.id
|
return created.data.id
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="">—</option>
|
|
||||||
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
</Field>
|
||||||
{(stores.data?.length ?? 0) > 1 && (
|
{(stores.data?.length ?? 0) > 1 && (
|
||||||
<Field label="Склад *">
|
<Field label="Склад *">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue