feat: enhance swap modal with search functionality for seismographs and modems
This commit is contained in:
@@ -249,22 +249,36 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="swap-form" class="p-6 space-y-4">
|
<form id="swap-form" class="p-6 space-y-5">
|
||||||
|
<!-- Seismograph picker -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Seismograph <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<select id="swap-unit-id" name="unit_id"
|
Seismograph <span class="text-red-500">*</span>
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
</label>
|
||||||
<option value="">Loading units...</option>
|
<input id="swap-unit-search" type="text" placeholder="Search by ID or model..."
|
||||||
</select>
|
oninput="filterSwapList('unit')"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm mb-2 focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<div id="swap-unit-list"
|
||||||
|
class="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
|
||||||
|
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="swap-unit-id" name="unit_id" required>
|
||||||
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
|
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modem picker -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Modem <span class="text-xs text-gray-400">(optional)</span></label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<select id="swap-modem-id" name="modem_id"
|
Modem <span class="text-xs text-gray-400">(optional)</span>
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
</label>
|
||||||
<option value="">No modem</option>
|
<input id="swap-modem-search" type="text" placeholder="Search by ID, model, or IP..."
|
||||||
</select>
|
oninput="filterSwapList('modem')"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm mb-2 focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<div id="swap-modem-list"
|
||||||
|
class="max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
|
||||||
|
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="swap-modem-id" name="modem_id">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -350,6 +364,10 @@ async function openSwapModal() {
|
|||||||
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
|
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
|
||||||
document.getElementById('swap-error').classList.add('hidden');
|
document.getElementById('swap-error').classList.add('hidden');
|
||||||
document.getElementById('swap-notes').value = '';
|
document.getElementById('swap-notes').value = '';
|
||||||
|
document.getElementById('swap-unit-search').value = '';
|
||||||
|
document.getElementById('swap-modem-search').value = '';
|
||||||
|
document.getElementById('swap-unit-id').value = '';
|
||||||
|
document.getElementById('swap-modem-id').value = '';
|
||||||
await Promise.all([loadSwapUnits(), loadSwapModems()]);
|
await Promise.all([loadSwapUnits(), loadSwapModems()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,26 +375,94 @@ function closeSwapModal() {
|
|||||||
document.getElementById('swap-modal').classList.add('hidden');
|
document.getElementById('swap-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _swapUnits = [];
|
||||||
|
let _swapModems = [];
|
||||||
|
|
||||||
|
function _fuzzyMatch(query, text) {
|
||||||
|
if (!query) return true;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
// Substring match first (fast), then character-sequence fuzzy
|
||||||
|
if (t.includes(q)) return true;
|
||||||
|
let qi = 0;
|
||||||
|
for (let i = 0; i < t.length && qi < q.length; i++) {
|
||||||
|
if (t[i] === q[qi]) qi++;
|
||||||
|
}
|
||||||
|
return qi === q.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderSwapList(type, items, selectedId, noSelectionLabel) {
|
||||||
|
const listEl = document.getElementById(`swap-${type}-list`);
|
||||||
|
if (!items.length) {
|
||||||
|
listEl.innerHTML = `<div class="px-3 py-4 text-center text-sm text-gray-400">No results</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = items.map(item => {
|
||||||
|
const isSelected = item.value === selectedId;
|
||||||
|
return `<button type="button"
|
||||||
|
onclick="selectSwapItem('${type}', '${item.value}', this)"
|
||||||
|
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${isSelected ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white text-sm">${item.primary}</span>
|
||||||
|
${item.secondary ? `<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">${item.secondary}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${isSelected ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSwapItem(type, value, btn) {
|
||||||
|
document.getElementById(`swap-${type}-id`).value = value;
|
||||||
|
// Update visual state
|
||||||
|
const list = document.getElementById(`swap-${type}-list`);
|
||||||
|
list.querySelectorAll('button').forEach(b => {
|
||||||
|
b.classList.remove('bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
b.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-gray-400 dark:border-gray-500';
|
||||||
|
});
|
||||||
|
btn.classList.add('bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
btn.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-seismo-orange bg-seismo-orange';
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSwapList(type) {
|
||||||
|
const query = document.getElementById(`swap-${type}-search`).value;
|
||||||
|
const items = type === 'unit' ? _swapUnits : _swapModems;
|
||||||
|
const selectedId = document.getElementById(`swap-${type}-id`).value;
|
||||||
|
const filtered = items.filter(item =>
|
||||||
|
_fuzzyMatch(query, item.primary + ' ' + (item.secondary || '') + ' ' + (item.searchText || ''))
|
||||||
|
);
|
||||||
|
_renderSwapList(type, filtered, selectedId, type === 'modem' ? 'No modem' : null);
|
||||||
|
// Re-add "No modem" option for modems
|
||||||
|
if (type === 'modem') {
|
||||||
|
const listEl = document.getElementById('swap-modem-list');
|
||||||
|
const noModemBtn = `<button type="button"
|
||||||
|
onclick="selectSwapItem('modem', '', this)"
|
||||||
|
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${!selectedId ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400 italic">No modem</span>
|
||||||
|
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${!selectedId ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
|
||||||
|
</button>`;
|
||||||
|
listEl.insertAdjacentHTML('afterbegin', noModemBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSwapUnits() {
|
async function loadSwapUnits() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||||
if (!response.ok) throw new Error('Failed to load units');
|
if (!response.ok) throw new Error('Failed to load units');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const select = document.getElementById('swap-unit-id');
|
|
||||||
select.innerHTML = '<option value="">Select a seismograph</option>';
|
|
||||||
|
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
document.getElementById('swap-units-empty').classList.remove('hidden');
|
document.getElementById('swap-units-empty').classList.remove('hidden');
|
||||||
} else {
|
document.getElementById('swap-unit-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">No available seismographs.</div>';
|
||||||
document.getElementById('swap-units-empty').classList.add('hidden');
|
return;
|
||||||
}
|
}
|
||||||
|
document.getElementById('swap-units-empty').classList.add('hidden');
|
||||||
data.forEach(unit => {
|
_swapUnits = data.map(u => ({
|
||||||
const option = document.createElement('option');
|
value: u.id,
|
||||||
option.value = unit.id;
|
primary: u.id,
|
||||||
option.textContent = unit.id + (unit.model ? ` \u2022 ${unit.model}` : '') + (unit.location ? ` \u2014 ${unit.location}` : '');
|
secondary: [u.model, u.location].filter(Boolean).join(' — '),
|
||||||
select.appendChild(option);
|
searchText: u.model + ' ' + u.location,
|
||||||
});
|
}));
|
||||||
|
_renderSwapList('unit', _swapUnits, document.getElementById('swap-unit-id').value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
|
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
|
||||||
document.getElementById('swap-error').classList.remove('hidden');
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
@@ -388,21 +474,16 @@ async function loadSwapModems() {
|
|||||||
const response = await fetch(`/api/projects/${projectId}/available-modems`);
|
const response = await fetch(`/api/projects/${projectId}/available-modems`);
|
||||||
if (!response.ok) throw new Error('Failed to load modems');
|
if (!response.ok) throw new Error('Failed to load modems');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const select = document.getElementById('swap-modem-id');
|
_swapModems = data.map(m => ({
|
||||||
select.innerHTML = '<option value="">No modem</option>';
|
value: m.id,
|
||||||
|
primary: m.id,
|
||||||
data.forEach(modem => {
|
secondary: [m.hardware_model, m.ip_address].filter(Boolean).join(' — '),
|
||||||
const option = document.createElement('option');
|
searchText: (m.hardware_model || '') + ' ' + (m.ip_address || ''),
|
||||||
option.value = modem.id;
|
}));
|
||||||
let label = modem.id;
|
filterSwapList('modem'); // renders with "No modem" prepended
|
||||||
if (modem.hardware_model) label += ` \u2022 ${modem.hardware_model}`;
|
|
||||||
if (modem.ip_address) label += ` \u2014 ${modem.ip_address}`;
|
|
||||||
option.textContent = label;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Modem list failure is non-fatal — just leave blank
|
|
||||||
console.warn('Failed to load modems:', err);
|
console.warn('Failed to load modems:', err);
|
||||||
|
document.getElementById('swap-modem-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">Could not load modems.</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user