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:
parent
196658e548
commit
2321010608
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue