feat(ui): inline-create option in searchable Select
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 42s
CI / Web (React + Vite) (push) Successful in 34s
Docker Web / Build + push Web (push) Successful in 27s
Docker Web / Deploy Web on stage (push) Failing after 47s

Опциональный onCreate(label) пропс — если задан и пользователь набрал
текст, не совпадающий ни с одним пунктом списка, в дропдауне появляется
кнопка «Создать «query»». По клику колбэк создаёт сущность на сервере
и возвращает id, который сразу подставляется как выбранное значение.
Enter в пустом результате тоже триггерит создание.

Подключено в приёмке для поля «Поставщик» — POST в counterparties с
дефолтами (Type=LegalEntity, остальные поля null), затем invalidate
лукапа. Полные реквизиты редактируются позже в справочнике.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 01:04:42 +05:00
parent 4859ece60b
commit c9f17b80fd
2 changed files with 67 additions and 6 deletions

View file

@ -67,12 +67,25 @@ function extractOptions(children: ReactNode): SelectOption[] {
/** Drop-in замена нативного <select>: визуально похож на TextInput, но при клике /** Drop-in замена нативного <select>: визуально похож на TextInput, но при клике
* раскрывает выпадающий список с поиском по подстроке. API совместим со старым * раскрывает выпадающий список с поиском по подстроке. API совместим со старым
* Select onChange получает synthetic event с e.target.value. Дочерние <option> * Select onChange получает synthetic event с e.target.value. Дочерние <option>
* парсятся в options array (label = текст внутри <option>). */ * парсятся в options array (label = текст внутри <option>).
export function Select({ value, onChange, disabled, className, children, placeholder, ...rest }: SelectHTMLAttributes<HTMLSelectElement> & { placeholder?: string }) { *
* Опциональный onCreate(label) если задан, в выпадающем списке появляется пункт
* «Создать «query»» когда введённый текст не совпадает ни с одним label.
* Колбэк должен создать сущность на сервере и вернуть value (id) нового элемента,
* после чего Select автоматически подставит его как выбранный. */
export function Select({
value, onChange, disabled, className, children, placeholder, onCreate, createLabel = 'Создать', ...rest
}: SelectHTMLAttributes<HTMLSelectElement> & {
placeholder?: string
onCreate?: (label: string) => Promise<string>
createLabel?: string
}) {
const options = useMemo(() => extractOptions(children), [children]) const options = useMemo(() => extractOptions(children), [children])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [highlight, setHighlight] = useState(0) const [highlight, setHighlight] = useState(0)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const wrapRef = useRef<HTMLDivElement>(null) const wrapRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null) const searchRef = useRef<HTMLInputElement>(null)
@ -102,13 +115,29 @@ export function Select({ value, onChange, disabled, className, children, placeho
}, [open]) }, [open])
const choose = (v: string) => { const choose = (v: string) => {
setOpen(false); setQuery('') setOpen(false); setQuery(''); setCreateError(null)
if (onChange) { if (onChange) {
const fake = { target: { value: v }, currentTarget: { value: v } } as unknown as React.ChangeEvent<HTMLSelectElement> const fake = { target: { value: v }, currentTarget: { value: v } } as unknown as React.ChangeEvent<HTMLSelectElement>
onChange(fake) onChange(fake)
} }
} }
const trimmed = query.trim()
const exactMatch = options.some((o) => o.label.toLowerCase() === trimmed.toLowerCase())
const canCreate = !!onCreate && trimmed.length > 0 && !exactMatch
const handleCreate = async () => {
if (!onCreate || !trimmed) return
setCreating(true); setCreateError(null)
try {
const newId = await onCreate(trimmed)
choose(newId)
} catch (e) {
setCreateError((e as Error).message || 'Не удалось создать')
} finally {
setCreating(false)
}
}
const triggerLabel = selected const triggerLabel = selected
? selected.label ? selected.label
: (placeholder ?? (options.find((o) => o.value === '')?.label ?? '—')) : (placeholder ?? (options.find((o) => o.value === '')?.label ?? '—'))
@ -146,6 +175,7 @@ export function Select({ value, onChange, disabled, className, children, placeho
setHighlight((h) => Math.max(0, h - 1)) setHighlight((h) => Math.max(0, h - 1))
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
if (filtered.length === 0 && canCreate) { handleCreate(); return }
const opt = filtered[highlight] const opt = filtered[highlight]
if (opt && !opt.disabled) choose(opt.value) if (opt && !opt.disabled) choose(opt.value)
} }
@ -153,7 +183,7 @@ export function Select({ value, onChange, disabled, className, children, placeho
/> />
</div> </div>
<ul className="py-1 overflow-auto" role="listbox"> <ul className="py-1 overflow-auto" role="listbox">
{filtered.length === 0 ? ( {filtered.length === 0 && !canCreate ? (
<li className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</li> <li className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</li>
) : filtered.map((opt, i) => ( ) : filtered.map((opt, i) => (
<li key={`${opt.value}-${i}`}> <li key={`${opt.value}-${i}`}>
@ -173,6 +203,21 @@ export function Select({ value, onChange, disabled, className, children, placeho
</button> </button>
</li> </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> </ul>
</div> </div>
)} )}

View file

@ -263,8 +263,24 @@ export function SupplyEditPage() {
onChange={(e) => setForm({ ...form, date: e.target.value })} /> onChange={(e) => setForm({ ...form, date: e.target.value })} />
</Field> </Field>
<Field label="Поставщик *"> <Field label="Поставщик *">
<Select value={form.supplierId} disabled={isPosted} <Select
onChange={(e) => setForm({ ...form, supplierId: e.target.value })}> value={form.supplierId}
disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierId: e.target.value })}
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> <option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} {suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select> </Select>