feat: enhance swap modal with search functionality for seismographs and modems
This commit is contained in:
@@ -249,22 +249,36 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="swap-form" class="p-6 space-y-4">
|
||||
<form id="swap-form" class="p-6 space-y-5">
|
||||
<!-- Seismograph picker -->
|
||||
<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>
|
||||
<select id="swap-unit-id" name="unit_id"
|
||||
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>
|
||||
<option value="">Loading units...</option>
|
||||
</select>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Seismograph <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="swap-unit-search" type="text" placeholder="Search by ID or model..."
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Modem picker -->
|
||||
<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>
|
||||
<select id="swap-modem-id" name="modem_id"
|
||||
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">
|
||||
<option value="">No modem</option>
|
||||
</select>
|
||||
<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>
|
||||
<input id="swap-modem-search" type="text" placeholder="Search by ID, model, or IP..."
|
||||
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>
|
||||
@@ -350,6 +364,10 @@ async function openSwapModal() {
|
||||
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
|
||||
document.getElementById('swap-error').classList.add('hidden');
|
||||
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()]);
|
||||
}
|
||||
|
||||
@@ -357,26 +375,94 @@ function closeSwapModal() {
|
||||
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() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||
if (!response.ok) throw new Error('Failed to load units');
|
||||
const data = await response.json();
|
||||
const select = document.getElementById('swap-unit-id');
|
||||
select.innerHTML = '<option value="">Select a seismograph</option>';
|
||||
|
||||
if (!data.length) {
|
||||
document.getElementById('swap-units-empty').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('swap-units-empty').classList.add('hidden');
|
||||
document.getElementById('swap-unit-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">No available seismographs.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.forEach(unit => {
|
||||
const option = document.createElement('option');
|
||||
option.value = unit.id;
|
||||
option.textContent = unit.id + (unit.model ? ` \u2022 ${unit.model}` : '') + (unit.location ? ` \u2014 ${unit.location}` : '');
|
||||
select.appendChild(option);
|
||||
});
|
||||
document.getElementById('swap-units-empty').classList.add('hidden');
|
||||
_swapUnits = data.map(u => ({
|
||||
value: u.id,
|
||||
primary: u.id,
|
||||
secondary: [u.model, u.location].filter(Boolean).join(' — '),
|
||||
searchText: u.model + ' ' + u.location,
|
||||
}));
|
||||
_renderSwapList('unit', _swapUnits, document.getElementById('swap-unit-id').value);
|
||||
} catch (err) {
|
||||
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
|
||||
document.getElementById('swap-error').classList.remove('hidden');
|
||||
@@ -388,21 +474,16 @@ async function loadSwapModems() {
|
||||
const response = await fetch(`/api/projects/${projectId}/available-modems`);
|
||||
if (!response.ok) throw new Error('Failed to load modems');
|
||||
const data = await response.json();
|
||||
const select = document.getElementById('swap-modem-id');
|
||||
select.innerHTML = '<option value="">No modem</option>';
|
||||
|
||||
data.forEach(modem => {
|
||||
const option = document.createElement('option');
|
||||
option.value = modem.id;
|
||||
let label = modem.id;
|
||||
if (modem.hardware_model) label += ` \u2022 ${modem.hardware_model}`;
|
||||
if (modem.ip_address) label += ` \u2014 ${modem.ip_address}`;
|
||||
option.textContent = label;
|
||||
select.appendChild(option);
|
||||
});
|
||||
_swapModems = data.map(m => ({
|
||||
value: m.id,
|
||||
primary: m.id,
|
||||
secondary: [m.hardware_model, m.ip_address].filter(Boolean).join(' — '),
|
||||
searchText: (m.hardware_model || '') + ' ' + (m.ip_address || ''),
|
||||
}));
|
||||
filterSwapList('modem'); // renders with "No modem" prepended
|
||||
} catch (err) {
|
||||
// Modem list failure is non-fatal — just leave blank
|
||||
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