feat(ui): inline-create option in searchable Select

Опциональный 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 196658e548
commit 2321010608
2 changed files with 67 additions and 6 deletions

View file

@ -67,12 +67,25 @@ function extractOptions(children: ReactNode): SelectOption[] {
/** Drop-in замена нативного <select>: визуально похож на TextInput, но при клике
* раскрывает выпадающий список с поиском по подстроке. API совместим со старым
* Select onChange получает synthetic event с e.target.value. Дочерние <option>
* парсятся в options array (label = текст внутри <option>). */
export function Select({ value, onChange, disabled, className, children, placeholder, ...rest }: SelectHTMLAttributes<HTMLSelectElement> & { placeholder?: string }) {
* парсятся в options array (label = текст внутри <option>).
*
* Опциональный 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 [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [highlight, setHighlight] = useState(0)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
@ -102,13 +115,29 @@ export function Select({ value, onChange, disabled, className, children, placeho
}, [open])
const choose = (v: string) => {
setOpen(false); setQuery('')
setOpen(false); setQuery(''); setCreateError(null)
if (onChange) {
const fake = { target: { value: v }, currentTarget: { value: v } } as unknown as React.ChangeEvent<HTMLSelectElement>
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
? selected.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))
} else if (e.key === 'Enter') {
e.preventDefault()
if (filtered.length === 0 && canCreate) { handleCreate(); return }
const opt = filtered[highlight]
if (opt && !opt.disabled) choose(opt.value)
}
@ -153,7 +183,7 @@ export function Select({ value, onChange, disabled, className, children, placeho
/>
</div>
<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>
) : filtered.map((opt, i) => (
<li key={`${opt.value}-${i}`}>
@ -173,6 +203,21 @@ export function Select({ value, onChange, disabled, className, children, placeho
</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>
)}

View file

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