diff --git a/frontend/src/BillingManager.jsx b/frontend/src/BillingManager.jsx index 398b00c..1ddf6ba 100644 --- a/frontend/src/BillingManager.jsx +++ b/frontend/src/BillingManager.jsx @@ -7,6 +7,7 @@ import Pagination from './components/Pagination.jsx'; import PageHeader from './components/PageHeader.jsx'; import PageHeaderActions from './components/PageHeaderActions.jsx'; import FormField from './components/FormField.jsx'; +import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx'; import ErrorAlert from './components/ErrorAlert.jsx'; import { IconPlus, @@ -1622,15 +1623,16 @@ function AddBillingForm({ item, onItemChange, servers }) { />
- Связанный сервер (опционально) + onItemChange({ ...item, serverId: value })} - options={serverOptions} - helpText="Используется для связи с разделом «Серверы»" + onChange={(value) => onItemChange({ ...item, serverId: value || '' })} + servers={servers} + placeholder="Поиск по IP, DNS, провайдеру..." /> +
+ Используется для связи с разделом «Серверы». Можно оставить пустым. +
- Связанный сервер (опционально) + onItemChange({ ...item, serverId: value })} - options={serverOptions} - helpText="Используется для связи с разделом «Серверы»" + onChange={(value) => onItemChange({ ...item, serverId: value || '' })} + servers={servers} + placeholder="Поиск по IP, DNS, провайдеру..." /> +
+ Используется для связи с разделом «Серверы». Можно оставить пустым. +
{ + const q = String(value || '').toLowerCase(); + + const list = (servers || []).map((s) => { + const id = s.id || s.ip; + const labelIp = s.ip || '—'; + const labelDns = s.dns || ''; + const labelProvider = s.provider || ''; + const label = + labelDns && labelProvider + ? `${labelIp} (${labelDns}) – ${labelProvider}` + : labelDns + ? `${labelIp} (${labelDns})` + : labelProvider + ? `${labelIp} – ${labelProvider}` + : `${labelIp}`; + + return { + raw: s, + value: id, + label, + search: [ + id, + s.ip, + s.extIp, + s.internalIp, + s.dns, + s.provider, + s.country, + s.gateway, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(), + }; + }); + + if (!q) return list.slice(0, maxSuggestions); + const filtered = list.filter((item) => item.search.includes(q)); + return filtered.slice(0, maxSuggestions); + }, [value, servers, maxSuggestions]); + + useEffect(() => { + const handleOutside = (e) => { + if (!containerRef.current) return; + if (!containerRef.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('click', handleOutside); + return () => document.removeEventListener('click', handleOutside); + }, []); + + const selectItem = (item) => { + onChange(item.value); + if (onSelectMeta) onSelectMeta(item.raw); + setOpen(false); + setActiveIndex(-1); + if (inputRef.current) inputRef.current.focus(); + }; + + const handleKeyDown = (e) => { + if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + setOpen(true); + return; + } + if (!open) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + if (activeIndex >= 0 && activeIndex < suggestions.length) { + e.preventDefault(); + selectItem(suggestions[activeIndex]); + } + } else if (e.key === 'Escape') { + setOpen(false); + setActiveIndex(-1); + } + }; + + // Текст в input: ищем label по выбранному serverId + const displayValue = useMemo(() => { + if (!value) return ''; + const found = (servers || []).find((s) => (s.id || s.ip) === value); + if (!found) return ''; + const ip = found.ip || '—'; + const dns = found.dns || ''; + const provider = found.provider || ''; + if (dns && provider) return `${ip} (${dns}) – ${provider}`; + if (dns) return `${ip} (${dns})`; + if (provider) return `${ip} – ${provider}`; + return ip; + }, [value, servers]); + + return ( +
+
+ + + + { + onChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + autoComplete="off" + /> +
+ + {open && suggestions.length > 0 && ( +
+ + + {suggestions.map((s, idx) => ( + + ))} +
+ )} +
+ ); +} + +export default ServerAutocompleteInput; + + +