diff --git a/api.php b/api.php index b7139e8..6c985da 100644 --- a/api.php +++ b/api.php @@ -4412,10 +4412,10 @@ switch ($action) { $deviceMap = []; foreach ($devices as $d) { $deviceMap[$d['nimi']] = $d; } - // Hae IPAM VLANit ja IP:t dropdown-valikkoja varten + // Hae IPAM VLANit, IP:t ja subnetit valikkoja varten $ipamAll = dbLoadIpam($companyId); $vlans = array_values(array_filter($ipamAll, fn($e) => $e['tyyppi'] === 'vlan')); - $ips = array_values(array_filter($ipamAll, fn($e) => $e['tyyppi'] === 'ip')); + $ips = array_values(array_filter($ipamAll, fn($e) => $e['tyyppi'] === 'ip' || $e['tyyppi'] === 'subnet')); // Rikasta liittymädata laitetiedoilla foreach ($connections as &$conn) { diff --git a/index.html b/index.html index ac28ccb..5cbec61 100644 --- a/index.html +++ b/index.html @@ -1105,25 +1105,31 @@
- +
+ + +
+
- +
+ + +
+
- - + +
+ + +
+
diff --git a/script.js b/script.js index f6e2f29..d456128 100644 --- a/script.js +++ b/script.js @@ -684,68 +684,25 @@ function createLiittymaRow(data = {}, index = 0) {
-
-
+
+
-
+
`; div.querySelector('.btn-remove-row').addEventListener('click', () => { div.remove(); renumberLiittymaRows(); }); - // Populoi dropdownit IPAM/laite-datasta - populateLiittymaRowDropdowns(div, data); + // Populoi hakukentät IPAM/laite-datasta + populateLiittymaRowCombos(div, data); return div; } -// Populoi yksittäisen liittymärivin VLAN/Laite/IP dropdownit -function populateLiittymaRowDropdowns(row, data = {}) { - const vlanSel = row.querySelector('.l-vlan'); - const laiteSel = row.querySelector('.l-laite'); - const ipSel = row.querySelector('.l-ip'); - // Käytä netadminData:a tai fallback ipamData/devicesData:an +// Populoi liittymärivin comboboxit +function populateLiittymaRowCombos(row, data = {}) { const vlans = (netadminData.vlans && netadminData.vlans.length) ? netadminData.vlans : (ipamData || []).filter(e => e.tyyppi === 'vlan'); - const ips = (netadminData.ips && netadminData.ips.length) ? netadminData.ips : (ipamData || []).filter(e => e.tyyppi === 'ip'); + const ips = (netadminData.ips && netadminData.ips.length) ? netadminData.ips : (ipamData || []).filter(e => e.tyyppi === 'ip' || e.tyyppi === 'subnet'); const devices = (netadminData.devices && netadminData.devices.length) ? netadminData.devices : (devicesData || []); - // VLAN - let vlanHtml = ''; - [...vlans].sort((a, b) => (a.vlan_id || 0) - (b.vlan_id || 0)).forEach(v => { - const val = String(v.vlan_id || ''); - const label = val + (v.nimi ? ` — ${v.nimi}` : ''); - vlanHtml += ``; - }); - if (data.vlan && !vlans.some(v => String(v.vlan_id) === String(data.vlan))) { - vlanHtml += ``; - } - vlanSel.innerHTML = vlanHtml; - vlanSel.value = data.vlan || ''; - // Laite - let laiteHtml = ''; - [...devices].sort((a, b) => (a.nimi || '').localeCompare(b.nimi || '')).forEach(d => { - const label = d.nimi + (d.hallintaosoite ? ` (${d.hallintaosoite})` : ''); - laiteHtml += ``; - }); - if (data.laite && !devices.some(d => d.nimi === data.laite)) { - laiteHtml += ``; - } - laiteSel.innerHTML = laiteHtml; - laiteSel.value = data.laite || ''; - // IP - let ipHtml = ''; - const free = ips.filter(i => i.tila === 'vapaa').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || '')); - const taken = ips.filter(i => i.tila === 'varattu').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || '')); - if (free.length) { - ipHtml += ''; - free.forEach(i => { ipHtml += ``; }); - ipHtml += ''; - } - if (taken.length) { - ipHtml += ''; - taken.forEach(i => { ipHtml += ``; }); - ipHtml += ''; - } - if (data.ip && !ips.some(i => i.verkko === data.ip)) { - ipHtml += ``; - } - ipSel.innerHTML = ipHtml; - ipSel.value = data.ip || ''; + initCombo(row.querySelector('.l-combo-vlan'), getVlanComboOptions(vlans), data.vlan || ''); + initCombo(row.querySelector('.l-combo-laite'), getDeviceComboOptions(devices), data.laite || ''); + initCombo(row.querySelector('.l-combo-ip'), getIpComboOptions(ips), data.ip || ''); } function renumberLiittymaRows() { @@ -764,10 +721,10 @@ function collectLiittymatFromForm() { hinta: row.querySelector('.l-hinta').value, sopimuskausi: row.querySelector('.l-sopimuskausi').value, alkupvm: row.querySelector('.l-alkupvm').value, - vlan: row.querySelector('.l-vlan').value, - laite: row.querySelector('.l-laite').value, + vlan: row.querySelector('.l-vlan')?.value || '', + laite: row.querySelector('.l-laite')?.value || '', portti: row.querySelector('.l-portti').value, - ip: row.querySelector('.l-ip').value, + ip: row.querySelector('.l-ip')?.value || '', })); } @@ -4692,72 +4649,176 @@ document.getElementById('netadmin-filter-city')?.addEventListener('change', rend document.getElementById('netadmin-filter-speed')?.addEventListener('change', renderNetadminTable); document.getElementById('netadmin-filter-device')?.addEventListener('change', renderNetadminTable); -// Populoi VLAN-dropdown IPAM VLANeista -function populateVlanDropdown(selectEl, currentValue) { - const vlans = netadminData.vlans || []; - let html = ''; - vlans.sort((a, b) => (a.vlan_id || 0) - (b.vlan_id || 0)); - vlans.forEach(v => { - const val = String(v.vlan_id || ''); - const label = val + (v.nimi ? ` — ${v.nimi}` : '') + (v.site_name ? ` (${v.site_name})` : ''); - html += ``; - }); - // Jos nykyinen arvo ei ole listalla, lisää se custom-optiona - if (currentValue && !vlans.some(v => String(v.vlan_id) === String(currentValue))) { - html += ``; +// ---- Searchable Combobox ---- + +// Luo combobox hakukenttä wrap-elementin sisälle +// options: [{value, label, sub, badge, badgeClass, searchStr}] +function initCombo(wrapEl, options, currentValue) { + const input = wrapEl.querySelector('input[type="text"]'); + const hidden = wrapEl.querySelector('input[type="hidden"]'); + const list = wrapEl.querySelector('.combo-list'); + if (!input || !hidden || !list) return; + + wrapEl._comboOptions = options; + hidden.value = currentValue || ''; + + // Näytä nykyinen arvo inputissa + if (currentValue) { + const match = options.find(o => o.value === currentValue); + input.value = match ? match.label : currentValue; + } else { + input.value = ''; } - selectEl.innerHTML = html; - selectEl.value = currentValue || ''; + + function renderList(query) { + const q = (query || '').toLowerCase().trim(); + let filtered = options; + if (q) { + filtered = options.filter(o => + (o.searchStr || o.label || '').toLowerCase().includes(q) || + (o.value || '').toLowerCase().includes(q) + ); + } + if (filtered.length === 0) { + list.innerHTML = '
Ei tuloksia
'; + } else { + let lastGroup = null; + list.innerHTML = filtered.map(o => { + let grpHtml = ''; + if (o.group && o.group !== lastGroup) { + lastGroup = o.group; + grpHtml = `
${esc(o.group)}
`; + } + const badge = o.badge ? `${esc(o.badge)}` : ''; + const sub = o.sub ? `${esc(o.sub)}` : ''; + return grpHtml + `
${badge}${esc(o.label)}${sub}
`; + }).join(''); + } + list.classList.add('open'); + } + + function selectValue(val) { + hidden.value = val; + const match = options.find(o => o.value === val); + input.value = match ? match.label : val; + list.classList.remove('open'); + } + + // Poista vanhat listenerit (uudelleeninitiin) + const newInput = input.cloneNode(true); + input.parentNode.replaceChild(newInput, input); + const newList = list.cloneNode(true); + list.parentNode.replaceChild(newList, list); + + newInput.addEventListener('focus', () => renderList(newInput.value)); + newInput.addEventListener('input', () => { + renderList(newInput.value); + // Jos tyhjä, tyhjennä valinta + if (!newInput.value.trim()) hidden.value = ''; + }); + newInput.addEventListener('blur', () => { + // Pieni viive jotta klikkaus ehtii rekisteröityä + setTimeout(() => { + newList.classList.remove('open'); + // Jos input ei vastaa mitään optiota, käytä vapaa teksti arvona + if (newInput.value.trim() && !hidden.value) { + hidden.value = newInput.value.trim(); + } + }, 200); + }); + newInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { newList.classList.remove('open'); newInput.blur(); } + if (e.key === 'Enter') { + e.preventDefault(); + const active = newList.querySelector('.combo-opt.active'); + if (active && active.dataset.value !== undefined) { + selectValue(active.dataset.value); + } else { + // Valitse ensimmäinen tulos + const first = newList.querySelector('.combo-opt[data-value]'); + if (first) selectValue(first.dataset.value); + else { hidden.value = newInput.value.trim(); newList.classList.remove('open'); } + } + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const items = [...newList.querySelectorAll('.combo-opt[data-value]')]; + if (!items.length) return; + const idx = items.findIndex(i => i.classList.contains('active')); + items.forEach(i => i.classList.remove('active')); + let next = e.key === 'ArrowDown' ? idx + 1 : idx - 1; + if (next < 0) next = items.length - 1; + if (next >= items.length) next = 0; + items[next].classList.add('active'); + items[next].scrollIntoView({ block: 'nearest' }); + } + }); + newList.addEventListener('mousedown', (e) => { + const opt = e.target.closest('.combo-opt[data-value]'); + if (opt) { e.preventDefault(); selectValue(opt.dataset.value); } + }); } -// Populoi laite-dropdown -function populateDeviceDropdown(selectEl, currentValue) { - const devices = netadminData.devices || []; - let html = ''; - devices.sort((a, b) => (a.nimi || '').localeCompare(b.nimi || '')); - devices.forEach(d => { - const label = d.nimi + (d.hallintaosoite ? ` (${d.hallintaosoite})` : '') + (d.malli ? ` — ${d.malli}` : ''); - const pingDot = d.ping_status === 'up' ? '🟢 ' : d.ping_status === 'down' ? '🔴 ' : ''; - html += ``; - }); - // Jos nykyinen arvo ei ole listalla, lisää se custom-optiona - if (currentValue && !devices.some(d => d.nimi === currentValue)) { - html += ``; - } - selectEl.innerHTML = html; - selectEl.value = currentValue || ''; +// Rakennetaan VLAN-combobox optiot +function getVlanComboOptions(source) { + const vlans = source || netadminData.vlans || []; + return [...vlans].sort((a, b) => (a.vlan_id || 0) - (b.vlan_id || 0)).map(v => ({ + value: String(v.vlan_id || ''), + label: String(v.vlan_id || '') + (v.nimi ? ` — ${v.nimi}` : ''), + sub: v.site_name || '', + searchStr: `${v.vlan_id} ${v.nimi || ''} ${v.site_name || ''}`, + })); } -// Populoi IP-dropdown IPAM IP-osoitteista -function populateIpDropdown(selectEl, currentValue) { - const ips = netadminData.ips || []; - let html = ''; - // Ryhmittele: vapaat ensin, sitten varatut - const free = ips.filter(i => i.tila === 'vapaa').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || '')); - const taken = ips.filter(i => i.tila === 'varattu').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || '')); +// Rakennetaan laite-combobox optiot +function getDeviceComboOptions(source) { + const devices = source || netadminData.devices || []; + return [...devices].sort((a, b) => (a.nimi || '').localeCompare(b.nimi || '')).map(d => { + const pingDot = d.ping_status === 'up' ? '🟢' : d.ping_status === 'down' ? '🔴' : ''; + return { + value: d.nimi, + label: (pingDot ? pingDot + ' ' : '') + d.nimi, + sub: [d.hallintaosoite, d.malli].filter(Boolean).join(' — '), + searchStr: `${d.nimi} ${d.hallintaosoite || ''} ${d.malli || ''} ${d.funktio || ''} ${d.site_name || ''}`, + }; + }); +} - if (free.length) { - html += ''; - free.forEach(i => { - const label = i.verkko + (i.nimi ? ` — ${i.nimi}` : '') + (i.site_name ? ` (${i.site_name})` : ''); - html += ``; - }); - html += ''; - } - if (taken.length) { - html += ''; - taken.forEach(i => { - const label = i.verkko + (i.asiakas ? ` ← ${i.asiakas}` : '') + (i.nimi ? ` — ${i.nimi}` : ''); - html += ``; - }); - html += ''; - } - // Jos nykyinen arvo ei ole listalla, lisää se custom-optiona - if (currentValue && !ips.some(i => i.verkko === currentValue)) { - html += ``; - } - selectEl.innerHTML = html; - selectEl.value = currentValue || ''; +// Rakennetaan IP/verkko -combobox optiot +function getIpComboOptions(source) { + const ips = source || netadminData.ips || []; + const items = []; + // Vapaat IP:t + const free = ips.filter(i => i.tila === 'vapaa' && i.tyyppi === 'ip').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || '')); + free.forEach(i => items.push({ + value: i.verkko, + label: i.verkko, + sub: [i.nimi, i.site_name].filter(Boolean).join(' — '), + badge: 'vapaa', badgeClass: 'free', + group: 'Vapaat IP:t', + searchStr: `${i.verkko} ${i.nimi || ''} ${i.site_name || ''}`, + })); + // Varatut IP:t + const taken = ips.filter(i => i.tila === 'varattu' && i.tyyppi === 'ip').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || '')); + taken.forEach(i => items.push({ + value: i.verkko, + label: i.verkko, + sub: [i.asiakas, i.nimi].filter(Boolean).join(' — '), + badge: 'varattu', badgeClass: 'taken', + group: 'Varatut IP:t', + searchStr: `${i.verkko} ${i.nimi || ''} ${i.asiakas || ''} ${i.site_name || ''}`, + })); + // Subnetit + const subnets = ips.filter(i => i.tyyppi === 'subnet').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || '')); + subnets.forEach(i => items.push({ + value: i.verkko, + label: i.verkko, + sub: [i.nimi, i.site_name].filter(Boolean).join(' — '), + badge: 'subnet', badgeClass: 'subnet', + group: 'Verkot', + searchStr: `${i.verkko} ${i.nimi || ''} ${i.site_name || ''}`, + })); + return items; } async function openNetadminDetail(connId) { @@ -4779,10 +4840,10 @@ async function openNetadminDetail(connId) { speedSel.insertBefore(opt, speedSel.lastElementChild); } speedSel.value = speed; - // Populoi VLAN, Laite ja IP dropdownit IPAM/Tekniikka-datasta - populateVlanDropdown(document.getElementById('na-edit-vlan'), conn.vlan || ''); - populateDeviceDropdown(document.getElementById('na-edit-laite'), conn.laite || ''); - populateIpDropdown(document.getElementById('na-edit-ip'), conn.ip || ''); + // Populoi VLAN, Laite ja IP hakukentät IPAM/Tekniikka-datasta + initCombo(document.getElementById('na-combo-vlan'), getVlanComboOptions(), conn.vlan || ''); + initCombo(document.getElementById('na-combo-laite'), getDeviceComboOptions(), conn.laite || ''); + initCombo(document.getElementById('na-combo-ip'), getIpComboOptions(), conn.ip || ''); document.getElementById('na-edit-portti').value = conn.portti || ''; document.getElementById('netadmin-detail-modal').style.display = ''; } catch (e) { alert('Liittymän avaus epäonnistui: ' + e.message); } diff --git a/style.css b/style.css index 1a36e66..d147a2e 100644 --- a/style.css +++ b/style.css @@ -1820,3 +1820,32 @@ span.empty { border-radius: 3px; font-size: 0.82rem; } + +/* Searchable combobox */ +.combo-wrap { position: relative; } +.combo-wrap input { + width: 100%; box-sizing: border-box; + padding: 0.5rem 0.7rem; border: 1px solid #d1d5db; border-radius: 6px; + font-size: 0.9rem; background: #fff; +} +.combo-wrap input:focus { border-color: var(--primary-color); outline: none; box-shadow: 0 0 0 2px rgba(59,130,246,.15); } +.combo-list { + display: none; position: absolute; left: 0; right: 0; top: 100%; + max-height: 220px; overflow-y: auto; z-index: 100; + background: #fff; border: 1px solid #d1d5db; border-top: none; + border-radius: 0 0 8px 8px; box-shadow: 0 4px 12px rgba(0,0,0,.1); +} +.combo-list.open { display: block; } +.combo-opt { + padding: 0.4rem 0.7rem; cursor: pointer; font-size: 0.85rem; + display: flex; align-items: center; gap: 0.4rem; +} +.combo-opt:hover, .combo-opt.active { background: #eff6ff; } +.combo-opt .combo-sub { font-size: 0.78rem; color: #888; margin-left: auto; white-space: nowrap; } +.combo-opt .combo-badge { + font-size: 0.7rem; padding: 1px 5px; border-radius: 4px; font-weight: 500; +} +.combo-badge.free { background: #dcfce7; color: #166534; } +.combo-badge.taken { background: #fee2e2; color: #991b1b; } +.combo-badge.subnet { background: #e0e7ff; color: #3730a3; } +.combo-grp { padding: 0.3rem 0.7rem; font-size: 0.75rem; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.03em; }