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 += '';
- }
- if (taken.length) {
- 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 += '';
- }
- if (taken.length) {
- 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; }