BIG update: Update to 0.5.1. Added:
-Project management -Modem Managerment -Modem/unit pairing and more
This commit is contained in:
@@ -130,6 +130,13 @@
|
||||
Sound Level Meters
|
||||
</a>
|
||||
|
||||
<a href="/modems" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/modems' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
Modems
|
||||
</a>
|
||||
|
||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
@@ -377,10 +384,10 @@
|
||||
</script>
|
||||
|
||||
<!-- Offline Database -->
|
||||
<script src="/static/offline-db.js?v=0.4.3"></script>
|
||||
<script src="/static/offline-db.js?v=0.5.1"></script>
|
||||
|
||||
<!-- Mobile JavaScript -->
|
||||
<script src="/static/mobile.js?v=0.4.3"></script>
|
||||
<script src="/static/mobile.js?v=0.5.1"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
108
templates/modems.html
Normal file
108
templates/modems.html
Normal file
@@ -0,0 +1,108 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Field Modems - Terra-View{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
Field Modems
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage network connectivity devices for field equipment</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
||||
hx-get="/api/modem-dashboard/stats"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Stats will be loaded here -->
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modem List -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Modems</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="modem-search"
|
||||
placeholder="Search modems..."
|
||||
class="pl-9 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-transparent"
|
||||
hx-get="/api/modem-dashboard/units"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#modem-list"
|
||||
hx-include="[name='search']"
|
||||
name="search">
|
||||
<svg class="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<a href="/roster?device_type=modem" class="text-sm text-seismo-orange hover:underline">
|
||||
Add modem in roster
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modem-list"
|
||||
hx-get="/api/modem-dashboard/units"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="animate-pulse space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Ping a modem and show result
|
||||
async function pingModem(modemId) {
|
||||
const btn = document.getElementById(`ping-btn-${modemId}`);
|
||||
const resultDiv = document.getElementById(`ping-result-${modemId}`);
|
||||
|
||||
// Show loading state
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Pinging...';
|
||||
btn.disabled = true;
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.className = 'mt-2 text-xs text-gray-500';
|
||||
resultDiv.textContent = 'Testing connection...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/modem-dashboard/${modemId}/ping`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
resultDiv.className = 'mt-2 text-xs text-green-600 dark:text-green-400';
|
||||
resultDiv.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
|
||||
} else {
|
||||
resultDiv.className = 'mt-2 text-xs text-red-600 dark:text-red-400';
|
||||
resultDiv.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.className = 'mt-2 text-xs text-red-600 dark:text-red-400';
|
||||
resultDiv.textContent = 'Error: ' + error.message;
|
||||
}
|
||||
|
||||
// Restore button
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
|
||||
// Hide result after 10 seconds
|
||||
setTimeout(() => {
|
||||
resultDiv.classList.add('hidden');
|
||||
}, 10000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
89
templates/partials/modem_list.html
Normal file
89
templates/partials/modem_list.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!-- Modem List -->
|
||||
{% if modems %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for modem in modems %}
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/unit/{{ modem.id }}" class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||
{{ modem.id }}
|
||||
</a>
|
||||
{% if modem.hardware_model %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ modem.hardware_model }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if modem.ip_address %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-mono mt-1">{{ modem.ip_address }}</p>
|
||||
{% else %}
|
||||
<p class="text-sm text-red-500 mt-1">No IP configured</p>
|
||||
{% endif %}
|
||||
|
||||
{% if modem.phone_number %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ modem.phone_number }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
{% if modem.status == "retired" %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
||||
{% elif modem.status == "benched" %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
||||
{% elif modem.status == "in_use" %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">In Use</span>
|
||||
{% elif modem.status == "spare" %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Spare</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Paired Device -->
|
||||
{% if modem.paired_device %}
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Paired with:</p>
|
||||
<a href="/unit/{{ modem.paired_device.id }}" class="text-sm text-seismo-orange hover:underline">
|
||||
{{ modem.paired_device.id }}
|
||||
<span class="text-gray-500">({{ modem.paired_device.device_type }})</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location if available -->
|
||||
{% if modem.location or modem.project_id %}
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if modem.project_id %}
|
||||
<span class="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ modem.project_id }}</span>
|
||||
{% endif %}
|
||||
{% if modem.location %}
|
||||
{{ modem.location }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button onclick="pingModem('{{ modem.id }}')"
|
||||
id="ping-btn-{{ modem.id }}"
|
||||
class="text-xs px-3 py-1.5 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded transition-colors">
|
||||
Ping
|
||||
</button>
|
||||
<a href="/unit/{{ modem.id }}"
|
||||
class="text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 rounded transition-colors">
|
||||
Details
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Ping Result (hidden by default) -->
|
||||
<div id="ping-result-{{ modem.id }}" class="mt-2 text-xs hidden"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
<p>No modems found</p>
|
||||
<p class="text-sm mt-1">Add modems from the <a href="/roster" class="text-seismo-orange hover:underline">Fleet Roster</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
51
templates/partials/modem_paired_device.html
Normal file
51
templates/partials/modem_paired_device.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!-- Paired Device Info for Modem Detail Page -->
|
||||
{% if device %}
|
||||
<div class="flex items-center gap-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
||||
{% if device.device_type == "slm" %}
|
||||
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Currently paired with</p>
|
||||
<a href="/unit/{{ device.id }}" class="text-lg font-semibold text-green-700 dark:text-green-400 hover:underline">
|
||||
{{ device.id }}
|
||||
</a>
|
||||
<div class="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="capitalize">{{ device.device_type }}</span>
|
||||
{% if device.project_id %}
|
||||
<span class="text-gray-400">|</span>
|
||||
<span>{{ device.project_id }}</span>
|
||||
{% endif %}
|
||||
{% if device.deployed %}
|
||||
<span class="px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs rounded">Deployed</span>
|
||||
{% else %}
|
||||
<span class="px-1.5 py-0.5 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs rounded">Benched</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="/unit/{{ device.id }}" class="text-gray-400 hover:text-seismo-orange transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
|
||||
<svg class="w-6 h-6 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-gray-600 dark:text-gray-400">No device currently paired</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">This modem is available for assignment</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
128
templates/partials/modem_picker.html
Normal file
128
templates/partials/modem_picker.html
Normal file
@@ -0,0 +1,128 @@
|
||||
{#
|
||||
Modem Picker Component
|
||||
A reusable HTMX-based autocomplete for selecting modems.
|
||||
|
||||
Usage: include "partials/modem_picker.html" with context
|
||||
|
||||
Variables available in context:
|
||||
- selected_modem_id: Pre-selected modem ID (optional)
|
||||
- selected_modem_display: Display text for pre-selected modem (optional)
|
||||
- input_name: Name attribute for the hidden input (default: "deployed_with_modem_id")
|
||||
- picker_id: Unique ID suffix for multiple pickers on same page (default: "")
|
||||
#}
|
||||
|
||||
{% set picker_id = picker_id|default("") %}
|
||||
{% set input_name = input_name|default("deployed_with_modem_id") %}
|
||||
{% set selected_modem_id = selected_modem_id|default("") %}
|
||||
{% set selected_modem_display = selected_modem_display|default("") %}
|
||||
|
||||
<div class="modem-picker relative" id="modem-picker-container{{ picker_id }}">
|
||||
<!-- Hidden input for form submission (stores modem ID) -->
|
||||
<input type="hidden"
|
||||
name="{{ input_name }}"
|
||||
id="modem-picker-value{{ picker_id }}"
|
||||
value="{{ selected_modem_id }}">
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="modem-picker-search{{ picker_id }}"
|
||||
placeholder="Search by modem ID, IP, or note..."
|
||||
class="w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||
autocomplete="off"
|
||||
value="{{ selected_modem_display }}"
|
||||
hx-get="/api/roster/search/modems"
|
||||
hx-trigger="keyup changed delay:300ms, focus"
|
||||
hx-target="#modem-picker-dropdown{{ picker_id }}"
|
||||
hx-vals='{"picker_id": "{{ picker_id }}"}'
|
||||
name="q"
|
||||
onfocus="showModemDropdown('{{ picker_id }}')"
|
||||
oninput="handleModemSearchInput('{{ picker_id }}', this.value)">
|
||||
|
||||
<!-- Search icon -->
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Clear button (shown when modem is selected) -->
|
||||
<button type="button"
|
||||
id="modem-picker-clear{{ picker_id }}"
|
||||
class="absolute inset-y-0 right-8 flex items-center pr-1 {{ 'hidden' if not selected_modem_id else '' }}"
|
||||
onclick="clearModemSelection('{{ picker_id }}')"
|
||||
title="Clear selection">
|
||||
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown Results Container -->
|
||||
<div id="modem-picker-dropdown{{ picker_id }}"
|
||||
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
<!-- Results loaded via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{# Modem picker functions - defined once, work for any picker_id #}
|
||||
if (typeof selectModem === 'undefined') {
|
||||
function selectModem(modemId, displayText, pickerId = '') {
|
||||
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
||||
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
||||
const dropdown = document.getElementById('modem-picker-dropdown' + pickerId);
|
||||
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
||||
|
||||
if (valueInput) valueInput.value = modemId;
|
||||
if (searchInput) searchInput.value = displayText;
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
if (clearBtn) clearBtn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearModemSelection(pickerId = '') {
|
||||
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
||||
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
||||
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
||||
|
||||
if (valueInput) valueInput.value = '';
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
searchInput.focus();
|
||||
}
|
||||
if (clearBtn) clearBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showModemDropdown(pickerId = '') {
|
||||
const dropdown = document.getElementById('modem-picker-dropdown' + pickerId);
|
||||
if (dropdown) dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideModemDropdown(pickerId = '') {
|
||||
const dropdown = document.getElementById('modem-picker-dropdown' + pickerId);
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
}
|
||||
|
||||
function handleModemSearchInput(pickerId, value) {
|
||||
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
||||
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
||||
|
||||
// If user clears the search box, also clear the hidden value
|
||||
if (!value.trim()) {
|
||||
if (valueInput) valueInput.value = '';
|
||||
if (clearBtn) clearBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const pickers = document.querySelectorAll('.modem-picker');
|
||||
pickers.forEach(picker => {
|
||||
if (!picker.contains(event.target)) {
|
||||
const dropdown = picker.querySelector('[id^="modem-picker-dropdown"]');
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
61
templates/partials/modem_search_results.html
Normal file
61
templates/partials/modem_search_results.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{#
|
||||
Modem Search Results Partial
|
||||
Rendered by /api/roster/search/modems endpoint for HTMX dropdown.
|
||||
|
||||
Variables:
|
||||
- modems: List of modem dicts with id, ip_address, phone_number, note, deployed, display
|
||||
- query: The search query string
|
||||
- show_empty: Boolean - show "no results" message
|
||||
#}
|
||||
|
||||
{% set picker_id = request.query_params.get('picker_id', '') %}
|
||||
|
||||
{% if modems %}
|
||||
{% for modem in modems %}
|
||||
<div class="px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors"
|
||||
onclick="selectModem('{{ modem.id }}', '{{ modem.display|e }}', '{{ picker_id }}')">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||
<span class="text-seismo-orange font-semibold">{{ modem.id }}</span>
|
||||
{% if modem.ip_address %}
|
||||
<span class="text-gray-400 mx-1">-</span>
|
||||
<span class="text-gray-600 dark:text-gray-400 font-mono text-sm">{{ modem.ip_address }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if modem.note %}
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ modem.note }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if not modem.deployed %}
|
||||
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
|
||||
Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_empty %}
|
||||
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<p class="text-sm">No modems found matching "{{ query }}"</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not modems and not show_empty %}
|
||||
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<p class="text-sm">Start typing to search modems...</p>
|
||||
<p class="text-xs mt-1">Search by modem ID, IP address, or note</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
63
templates/partials/modem_stats.html
Normal file
63
templates/partials/modem_stats.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!-- Modem summary stat cards -->
|
||||
|
||||
<!-- Total Modems Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Total Modems</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ total_count }}</p>
|
||||
</div>
|
||||
<div class="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
|
||||
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In Use Card (paired with device) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">In Use</p>
|
||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{ in_use_count }}</p>
|
||||
</div>
|
||||
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
||||
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Paired with a device</p>
|
||||
</div>
|
||||
|
||||
<!-- Spare Card (deployed but not paired) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Spare</p>
|
||||
<p class="text-3xl font-bold text-seismo-orange mt-1">{{ spare_count }}</p>
|
||||
</div>
|
||||
<div class="bg-orange-100 dark:bg-orange-900/30 p-3 rounded-lg">
|
||||
<svg class="w-8 h-8 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Available for assignment</p>
|
||||
</div>
|
||||
|
||||
<!-- Benched Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Benched</p>
|
||||
<p class="text-3xl font-bold text-gray-500 dark:text-gray-400 mt-1">{{ benched_count }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
|
||||
<svg class="w-8 h-8 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
233
templates/partials/project_create_modal.html
Normal file
233
templates/partials/project_create_modal.html
Normal file
@@ -0,0 +1,233 @@
|
||||
{#
|
||||
Quick Create Project Modal
|
||||
Allows inline creation of a new project from the project picker.
|
||||
|
||||
Include this modal in pages that use the project picker.
|
||||
#}
|
||||
|
||||
<div id="quickCreateProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full mx-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||
<button type="button" onclick="closeCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="quickCreateProjectForm" class="p-6 space-y-4">
|
||||
<!-- Hidden field to track which picker opened this modal -->
|
||||
<input type="hidden" id="qcp-picker-id" value="">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Project Number
|
||||
<span class="text-gray-400 font-normal">(xxxx-YY)</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="project_number"
|
||||
id="qcp-project-number"
|
||||
pattern="\d{4}-\d{2}"
|
||||
placeholder="2567-23"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">TMI internal project number (optional)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="client_name"
|
||||
id="qcp-client-name"
|
||||
required
|
||||
placeholder="PJ Dick, Turner Construction, etc."
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Project Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
id="qcp-project-name"
|
||||
required
|
||||
placeholder="RKM Hall, CMU Campus, Building 7, etc."
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Site or building name</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Project Type <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="project_type_id"
|
||||
id="qcp-project-type"
|
||||
required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<option value="vibration_monitoring">Vibration Monitoring</option>
|
||||
<option value="sound_monitoring">Sound Monitoring</option>
|
||||
<option value="combined">Combined (Vibration + Sound)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit"
|
||||
id="qcp-submit-btn"
|
||||
class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Create & Select
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="closeCreateProjectModal()"
|
||||
class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg font-medium transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Quick create project modal functions
|
||||
if (typeof openCreateProjectModal === 'undefined') {
|
||||
function openCreateProjectModal(searchQuery, pickerId = '') {
|
||||
const modal = document.getElementById('quickCreateProjectModal');
|
||||
const pickerIdInput = document.getElementById('qcp-picker-id');
|
||||
const projectNumInput = document.getElementById('qcp-project-number');
|
||||
const clientNameInput = document.getElementById('qcp-client-name');
|
||||
const projectNameInput = document.getElementById('qcp-project-name');
|
||||
const errorDiv = document.getElementById('qcp-error');
|
||||
|
||||
// Store which picker opened this
|
||||
if (pickerIdInput) pickerIdInput.value = pickerId;
|
||||
|
||||
// Reset form
|
||||
document.getElementById('quickCreateProjectForm').reset();
|
||||
if (errorDiv) errorDiv.classList.add('hidden');
|
||||
|
||||
// Try to parse the search query intelligently
|
||||
if (searchQuery) {
|
||||
// Check if it looks like a project number (xxxx-YY pattern)
|
||||
const projectNumMatch = searchQuery.match(/(\d{4}-\d{2})/);
|
||||
if (projectNumMatch) {
|
||||
if (projectNumInput) projectNumInput.value = projectNumMatch[1];
|
||||
// If there's more after the number, use it as client name
|
||||
const remainder = searchQuery.replace(projectNumMatch[1], '').replace(/^[\s\-]+/, '').trim();
|
||||
if (remainder && clientNameInput) clientNameInput.value = remainder;
|
||||
} else {
|
||||
// Not a project number - assume it's client or project name
|
||||
// If short (likely a name fragment), put it in client name
|
||||
if (clientNameInput) clientNameInput.value = searchQuery;
|
||||
}
|
||||
}
|
||||
|
||||
// Show modal
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
|
||||
// Focus the first empty required field
|
||||
if (clientNameInput && !clientNameInput.value) {
|
||||
clientNameInput.focus();
|
||||
} else if (projectNameInput) {
|
||||
projectNameInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeCreateProjectModal() {
|
||||
const modal = document.getElementById('quickCreateProjectModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Handle quick create form submission
|
||||
document.getElementById('quickCreateProjectForm')?.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('qcp-submit-btn');
|
||||
const errorDiv = document.getElementById('qcp-error');
|
||||
const pickerId = document.getElementById('qcp-picker-id')?.value || '';
|
||||
|
||||
// Show loading state
|
||||
const originalBtnText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = `
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Creating...
|
||||
`;
|
||||
if (errorDiv) errorDiv.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/projects/create', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// Build display text from form values
|
||||
const parts = [];
|
||||
const projectNumber = formData.get('project_number');
|
||||
const clientName = formData.get('client_name');
|
||||
const projectName = formData.get('name');
|
||||
|
||||
if (projectNumber) parts.push(projectNumber);
|
||||
if (clientName) parts.push(clientName);
|
||||
if (projectName) parts.push(projectName);
|
||||
|
||||
const displayText = parts.join(' - ');
|
||||
|
||||
// Select the newly created project in the picker
|
||||
selectProject(result.project_id, displayText, pickerId);
|
||||
|
||||
// Close modal
|
||||
closeCreateProjectModal();
|
||||
} else {
|
||||
// Show error
|
||||
if (errorDiv) {
|
||||
errorDiv.textContent = result.detail || result.message || 'Failed to create project';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (errorDiv) {
|
||||
errorDiv.textContent = `Error: ${error.message}`;
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
} finally {
|
||||
// Restore button
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalBtnText;
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on backdrop click
|
||||
document.getElementById('quickCreateProjectModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateProjectModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('quickCreateProjectModal');
|
||||
if (modal && !modal.classList.contains('hidden')) {
|
||||
closeCreateProjectModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
128
templates/partials/project_picker.html
Normal file
128
templates/partials/project_picker.html
Normal file
@@ -0,0 +1,128 @@
|
||||
{#
|
||||
Project Picker Component
|
||||
A reusable HTMX-based autocomplete for selecting projects.
|
||||
|
||||
Usage: include "partials/project_picker.html" with context
|
||||
|
||||
Variables available in context:
|
||||
- selected_project_id: Pre-selected project UUID (optional)
|
||||
- selected_project_display: Display text for pre-selected project (optional)
|
||||
- input_name: Name attribute for the hidden input (default: "project_id")
|
||||
- picker_id: Unique ID suffix for multiple pickers on same page (default: "")
|
||||
#}
|
||||
|
||||
{% set picker_id = picker_id|default("") %}
|
||||
{% set input_name = input_name|default("project_id") %}
|
||||
{% set selected_project_id = selected_project_id|default("") %}
|
||||
{% set selected_project_display = selected_project_display|default("") %}
|
||||
|
||||
<div class="project-picker relative" id="project-picker-container{{ picker_id }}">
|
||||
<!-- Hidden input for form submission (stores project UUID) -->
|
||||
<input type="hidden"
|
||||
name="{{ input_name }}"
|
||||
id="project-picker-value{{ picker_id }}"
|
||||
value="{{ selected_project_id }}">
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="project-picker-search{{ picker_id }}"
|
||||
placeholder="Search by project number, client, or name..."
|
||||
class="w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||
autocomplete="off"
|
||||
value="{{ selected_project_display }}"
|
||||
hx-get="/api/projects/search"
|
||||
hx-trigger="keyup changed delay:300ms, focus"
|
||||
hx-target="#project-picker-dropdown{{ picker_id }}"
|
||||
hx-vals='{"picker_id": "{{ picker_id }}"}'
|
||||
name="q"
|
||||
onfocus="showProjectDropdown('{{ picker_id }}')"
|
||||
oninput="handleProjectSearchInput('{{ picker_id }}', this.value)">
|
||||
|
||||
<!-- Search icon -->
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Clear button (shown when project is selected) -->
|
||||
<button type="button"
|
||||
id="project-picker-clear{{ picker_id }}"
|
||||
class="absolute inset-y-0 right-8 flex items-center pr-1 {{ 'hidden' if not selected_project_id else '' }}"
|
||||
onclick="clearProjectSelection('{{ picker_id }}')"
|
||||
title="Clear selection">
|
||||
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown Results Container -->
|
||||
<div id="project-picker-dropdown{{ picker_id }}"
|
||||
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
<!-- Results loaded via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Project picker functions - defined once, work for any picker_id
|
||||
if (typeof selectProject === 'undefined') {
|
||||
function selectProject(projectId, displayText, pickerId = '') {
|
||||
const valueInput = document.getElementById('project-picker-value' + pickerId);
|
||||
const searchInput = document.getElementById('project-picker-search' + pickerId);
|
||||
const dropdown = document.getElementById('project-picker-dropdown' + pickerId);
|
||||
const clearBtn = document.getElementById('project-picker-clear' + pickerId);
|
||||
|
||||
if (valueInput) valueInput.value = projectId;
|
||||
if (searchInput) searchInput.value = displayText;
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
if (clearBtn) clearBtn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearProjectSelection(pickerId = '') {
|
||||
const valueInput = document.getElementById('project-picker-value' + pickerId);
|
||||
const searchInput = document.getElementById('project-picker-search' + pickerId);
|
||||
const clearBtn = document.getElementById('project-picker-clear' + pickerId);
|
||||
|
||||
if (valueInput) valueInput.value = '';
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
searchInput.focus();
|
||||
}
|
||||
if (clearBtn) clearBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showProjectDropdown(pickerId = '') {
|
||||
const dropdown = document.getElementById('project-picker-dropdown' + pickerId);
|
||||
if (dropdown) dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideProjectDropdown(pickerId = '') {
|
||||
const dropdown = document.getElementById('project-picker-dropdown' + pickerId);
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
}
|
||||
|
||||
function handleProjectSearchInput(pickerId, value) {
|
||||
const valueInput = document.getElementById('project-picker-value' + pickerId);
|
||||
const clearBtn = document.getElementById('project-picker-clear' + pickerId);
|
||||
|
||||
// If user clears the search box, also clear the hidden value
|
||||
if (!value.trim()) {
|
||||
if (valueInput) valueInput.value = '';
|
||||
if (clearBtn) clearBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const pickers = document.querySelectorAll('.project-picker');
|
||||
pickers.forEach(picker => {
|
||||
if (!picker.contains(event.target)) {
|
||||
const dropdown = picker.querySelector('[id^="project-picker-dropdown"]');
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
69
templates/partials/project_search_results.html
Normal file
69
templates/partials/project_search_results.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<!--
|
||||
Project Search Results Partial
|
||||
Rendered by /api/projects/search endpoint for HTMX dropdown.
|
||||
|
||||
Variables:
|
||||
- projects: List of project dicts with id, project_number, client_name, name, display, status
|
||||
- query: The search query string
|
||||
- show_create: Boolean - show "Create new project" option when no matches
|
||||
-->
|
||||
|
||||
{% set picker_id = request.query_params.get('picker_id', '') %}
|
||||
|
||||
{% if projects %}
|
||||
{% for project in projects %}
|
||||
<div class="px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors"
|
||||
onclick="selectProject('{{ project.id }}', '{{ project.display|e }}', '{{ picker_id }}')">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||
{% if project.project_number %}
|
||||
<span class="text-seismo-orange font-semibold">{{ project.project_number }}</span>
|
||||
{% if project.client_name or project.name %}
|
||||
<span class="text-gray-400 mx-1">-</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if project.client_name %}
|
||||
<span>{{ project.client_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if project.name %}
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if project.status == 'completed' %}
|
||||
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
|
||||
Completed
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_create %}
|
||||
<div class="px-4 py-3 hover:bg-green-50 dark:hover:bg-green-900/30 cursor-pointer border-t border-gray-200 dark:border-gray-600 transition-colors"
|
||||
onclick="openCreateProjectModal('{{ query|e }}', '{{ picker_id }}')">
|
||||
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
<span class="font-medium">Create new project "{{ query }}"</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-7">
|
||||
No matching projects found. Click to create a new one.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not projects and not show_create %}
|
||||
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<p class="text-sm">Start typing to search projects...</p>
|
||||
<p class="text-xs mt-1">Search by project number, client name, or project name</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
132
templates/partials/unit_picker.html
Normal file
132
templates/partials/unit_picker.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{#
|
||||
Unit Picker Component
|
||||
A reusable HTMX-based autocomplete for selecting seismographs/SLMs.
|
||||
|
||||
Usage: include "partials/unit_picker.html" with context
|
||||
|
||||
Variables available in context:
|
||||
- selected_unit_id: Pre-selected unit ID (optional)
|
||||
- selected_unit_display: Display text for pre-selected unit (optional)
|
||||
- input_name: Name attribute for the hidden input (default: "deployed_with_unit_id")
|
||||
- picker_id: Unique ID suffix for multiple pickers on same page (default: "")
|
||||
- device_type_filter: Filter by device type: "seismograph", "slm", or empty for all (default: "")
|
||||
- deployed_only: Only show deployed units (default: false)
|
||||
#}
|
||||
|
||||
{% set picker_id = picker_id|default("") %}
|
||||
{% set input_name = input_name|default("deployed_with_unit_id") %}
|
||||
{% set selected_unit_id = selected_unit_id|default("") %}
|
||||
{% set selected_unit_display = selected_unit_display|default("") %}
|
||||
{% set device_type_filter = device_type_filter|default("") %}
|
||||
{% set deployed_only = deployed_only|default(false) %}
|
||||
|
||||
<div class="unit-picker relative" id="unit-picker-container{{ picker_id }}">
|
||||
<!-- Hidden input for form submission (stores unit ID) -->
|
||||
<input type="hidden"
|
||||
name="{{ input_name }}"
|
||||
id="unit-picker-value{{ picker_id }}"
|
||||
value="{{ selected_unit_id }}">
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="unit-picker-search{{ picker_id }}"
|
||||
placeholder="Search by unit ID or note..."
|
||||
class="w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||
autocomplete="off"
|
||||
value="{{ selected_unit_display }}"
|
||||
hx-get="/api/roster/search/units"
|
||||
hx-trigger="keyup changed delay:300ms, focus"
|
||||
hx-target="#unit-picker-dropdown{{ picker_id }}"
|
||||
hx-vals='{"picker_id": "{{ picker_id }}", "device_type": "{{ device_type_filter }}", "deployed_only": "{{ deployed_only|lower }}"}'
|
||||
name="q"
|
||||
onfocus="showUnitDropdown('{{ picker_id }}')"
|
||||
oninput="handleUnitSearchInput('{{ picker_id }}', this.value)">
|
||||
|
||||
<!-- Search icon -->
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Clear button (shown when unit is selected) -->
|
||||
<button type="button"
|
||||
id="unit-picker-clear{{ picker_id }}"
|
||||
class="absolute inset-y-0 right-8 flex items-center pr-1 {{ 'hidden' if not selected_unit_id else '' }}"
|
||||
onclick="clearUnitSelection('{{ picker_id }}')"
|
||||
title="Clear selection">
|
||||
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown Results Container -->
|
||||
<div id="unit-picker-dropdown{{ picker_id }}"
|
||||
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
<!-- Results loaded via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{# Unit picker functions - defined once, work for any picker_id #}
|
||||
if (typeof selectUnit === 'undefined') {
|
||||
function selectUnit(unitId, displayText, pickerId = '') {
|
||||
const valueInput = document.getElementById('unit-picker-value' + pickerId);
|
||||
const searchInput = document.getElementById('unit-picker-search' + pickerId);
|
||||
const dropdown = document.getElementById('unit-picker-dropdown' + pickerId);
|
||||
const clearBtn = document.getElementById('unit-picker-clear' + pickerId);
|
||||
|
||||
if (valueInput) valueInput.value = unitId;
|
||||
if (searchInput) searchInput.value = displayText;
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
if (clearBtn) clearBtn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearUnitSelection(pickerId = '') {
|
||||
const valueInput = document.getElementById('unit-picker-value' + pickerId);
|
||||
const searchInput = document.getElementById('unit-picker-search' + pickerId);
|
||||
const clearBtn = document.getElementById('unit-picker-clear' + pickerId);
|
||||
|
||||
if (valueInput) valueInput.value = '';
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
searchInput.focus();
|
||||
}
|
||||
if (clearBtn) clearBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showUnitDropdown(pickerId = '') {
|
||||
const dropdown = document.getElementById('unit-picker-dropdown' + pickerId);
|
||||
if (dropdown) dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideUnitDropdown(pickerId = '') {
|
||||
const dropdown = document.getElementById('unit-picker-dropdown' + pickerId);
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
}
|
||||
|
||||
function handleUnitSearchInput(pickerId, value) {
|
||||
const valueInput = document.getElementById('unit-picker-value' + pickerId);
|
||||
const clearBtn = document.getElementById('unit-picker-clear' + pickerId);
|
||||
|
||||
// If user clears the search box, also clear the hidden value
|
||||
if (!value.trim()) {
|
||||
if (valueInput) valueInput.value = '';
|
||||
if (clearBtn) clearBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const pickers = document.querySelectorAll('.unit-picker');
|
||||
pickers.forEach(picker => {
|
||||
if (!picker.contains(event.target)) {
|
||||
const dropdown = picker.querySelector('[id^="unit-picker-dropdown"]');
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
66
templates/partials/unit_search_results.html
Normal file
66
templates/partials/unit_search_results.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{#
|
||||
Unit Search Results Partial
|
||||
Rendered by /api/roster/search/units endpoint for HTMX dropdown.
|
||||
|
||||
Variables:
|
||||
- units: List of unit dicts with id, device_type, note, deployed, display
|
||||
- query: The search query string
|
||||
- show_empty: Boolean - show "no results" message
|
||||
#}
|
||||
|
||||
{% set picker_id = request.query_params.get('picker_id', '') %}
|
||||
|
||||
{% if units %}
|
||||
{% for unit in units %}
|
||||
<div class="px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors"
|
||||
onclick="selectUnit('{{ unit.id }}', '{{ unit.display|e }}', '{{ picker_id }}')">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||
<span class="text-seismo-orange font-semibold">{{ unit.id }}</span>
|
||||
</div>
|
||||
{% if unit.note %}
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ unit.note }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if unit.device_type == 'seismograph' %}
|
||||
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300 rounded">
|
||||
Seismo
|
||||
</span>
|
||||
{% elif unit.device_type == 'slm' %}
|
||||
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 rounded">
|
||||
SLM
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if not unit.deployed %}
|
||||
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
|
||||
Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_empty %}
|
||||
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<p class="text-sm">No units found matching "{{ query }}"</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not units and not show_empty %}
|
||||
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<p class="text-sm">Start typing to search units...</p>
|
||||
<p class="text-xs mt-1">Search by unit ID or note</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -131,10 +131,8 @@
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||
<input type="text" name="project_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||
placeholder="PROJ-001">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||
{% include "partials/project_picker.html" with context %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
||||
@@ -159,8 +157,8 @@
|
||||
</div>
|
||||
<div id="modemPairingField" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
<input type="text" name="deployed_with_modem_id" placeholder="Modem ID"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
{% set picker_id = "-add-seismo" %}
|
||||
{% include "partials/modem_picker.html" with context %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,6 +181,21 @@
|
||||
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||
<select name="deployment_type"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<option value="">Not assigned</option>
|
||||
<option value="seismograph">Seismograph</option>
|
||||
<option value="slm">Sound Level Meter (SLM)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
||||
{% set picker_id = "-add-modem" %}
|
||||
{% set device_type_filter = "" %}
|
||||
{% include "partials/unit_picker.html" with context %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sound Level Meter-specific fields -->
|
||||
@@ -297,9 +310,9 @@
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||
<input type="text" name="project_id" id="editProjectId"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||
{% set picker_id = "-edit" %}
|
||||
{% include "partials/project_picker.html" with context %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||
@@ -327,8 +340,8 @@
|
||||
</div>
|
||||
<div id="editModemPairingField" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
<input type="text" name="deployed_with_modem_id" id="editDeployedWithModemId" placeholder="Modem ID"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
{% set picker_id = "-edit-seismo" %}
|
||||
{% include "partials/modem_picker.html" with context %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,6 +363,21 @@
|
||||
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||
<select name="deployment_type" id="editDeploymentType"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<option value="">Not assigned</option>
|
||||
<option value="seismograph">Seismograph</option>
|
||||
<option value="slm">Sound Level Meter (SLM)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
||||
{% set picker_id = "-edit-modem" %}
|
||||
{% set device_type_filter = "" %}
|
||||
{% include "partials/unit_picker.html" with context %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sound Level Meter-specific fields -->
|
||||
@@ -419,6 +447,55 @@
|
||||
<textarea name="note" id="editNote" rows="3"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Cascade to Paired Device Section -->
|
||||
<div id="editCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Also update paired device: <span id="editPairedDeviceName" class="text-seismo-orange"></span>
|
||||
</span>
|
||||
</div>
|
||||
<input type="hidden" name="cascade_to_unit_id" id="editCascadeToUnitId" value="">
|
||||
<div class="grid grid-cols-2 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_deployed" id="editCascadeDeployed" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_retired" id="editCascadeRetired" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_project" id="editCascadeProject" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_location" id="editCascadeLocation" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_coordinates" id="editCascadeCoordinates" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_note" id="editCascadeNote" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Check the fields you want to sync to the paired device
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||
Save Changes
|
||||
@@ -650,11 +727,7 @@
|
||||
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.successful) {
|
||||
closeAddUnitModal();
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
// Show success message
|
||||
alert('Unit added successfully!');
|
||||
} else {
|
||||
@@ -692,6 +765,33 @@
|
||||
function closeEditUnitModal() {
|
||||
document.getElementById('editUnitModal').classList.add('hidden');
|
||||
document.getElementById('editUnitForm').reset();
|
||||
// Also clear the project picker
|
||||
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
||||
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
||||
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
||||
if (projectPickerValue) projectPickerValue.value = '';
|
||||
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Fetch project display name for edit modal
|
||||
async function fetchProjectDisplayForEdit(projectId) {
|
||||
if (!projectId) return '';
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`);
|
||||
if (response.ok) {
|
||||
const project = await response.json();
|
||||
const parts = [
|
||||
project.project_number,
|
||||
project.client_name,
|
||||
project.name
|
||||
].filter(Boolean);
|
||||
return parts.join(' - ') || projectId;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch project:', e);
|
||||
}
|
||||
return projectId;
|
||||
}
|
||||
|
||||
// Toggle device-specific fields in edit modal
|
||||
@@ -753,7 +853,23 @@
|
||||
document.getElementById('editUnitId').value = unit.id;
|
||||
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
|
||||
document.getElementById('editUnitType').value = unit.unit_type;
|
||||
document.getElementById('editProjectId').value = unit.project_id;
|
||||
|
||||
// Populate project picker (uses -edit suffix)
|
||||
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
||||
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
||||
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
||||
if (projectPickerValue) projectPickerValue.value = unit.project_id || '';
|
||||
if (unit.project_id) {
|
||||
// Fetch project display name
|
||||
fetchProjectDisplayForEdit(unit.project_id).then(displayText => {
|
||||
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
||||
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
||||
});
|
||||
} else {
|
||||
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('editAddress').value = unit.address;
|
||||
document.getElementById('editCoordinates').value = unit.coordinates;
|
||||
document.getElementById('editNote').value = unit.note;
|
||||
@@ -765,12 +881,62 @@
|
||||
// Seismograph fields
|
||||
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
||||
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
||||
document.getElementById('editDeployedWithModemId').value = unit.deployed_with_modem_id;
|
||||
|
||||
// Populate modem picker for seismograph (uses -edit-seismo suffix)
|
||||
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');
|
||||
const modemPickerSearch = document.getElementById('modem-picker-search-edit-seismo');
|
||||
const modemPickerClear = document.getElementById('modem-picker-clear-edit-seismo');
|
||||
if (modemPickerValue) modemPickerValue.value = unit.deployed_with_modem_id || '';
|
||||
if (unit.deployed_with_modem_id) {
|
||||
// Fetch modem display (ID + IP + note)
|
||||
fetch(`/api/roster/${unit.deployed_with_modem_id}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(modem => {
|
||||
if (modem && modemPickerSearch) {
|
||||
let display = modem.id;
|
||||
if (modem.ip_address) display += ` - ${modem.ip_address}`;
|
||||
if (modem.note) display += ` - ${modem.note}`;
|
||||
modemPickerSearch.value = display;
|
||||
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (modemPickerSearch) modemPickerSearch.value = unit.deployed_with_modem_id;
|
||||
});
|
||||
} else {
|
||||
if (modemPickerSearch) modemPickerSearch.value = '';
|
||||
if (modemPickerClear) modemPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Modem fields
|
||||
document.getElementById('editIpAddress').value = unit.ip_address;
|
||||
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||
document.getElementById('editDeploymentType').value = unit.deployment_type || '';
|
||||
|
||||
// Populate unit picker for modem (uses -edit-modem suffix)
|
||||
const unitPickerValue = document.getElementById('unit-picker-value-edit-modem');
|
||||
const unitPickerSearch = document.getElementById('unit-picker-search-edit-modem');
|
||||
const unitPickerClear = document.getElementById('unit-picker-clear-edit-modem');
|
||||
if (unitPickerValue) unitPickerValue.value = unit.deployed_with_unit_id || '';
|
||||
if (unit.deployed_with_unit_id) {
|
||||
// Fetch unit display (ID + note)
|
||||
fetch(`/api/roster/${unit.deployed_with_unit_id}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(linkedUnit => {
|
||||
if (linkedUnit && unitPickerSearch) {
|
||||
const display = linkedUnit.note ? `${linkedUnit.id} - ${linkedUnit.note}` : linkedUnit.id;
|
||||
unitPickerSearch.value = display;
|
||||
if (unitPickerClear) unitPickerClear.classList.remove('hidden');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (unitPickerSearch) unitPickerSearch.value = unit.deployed_with_unit_id;
|
||||
});
|
||||
} else {
|
||||
if (unitPickerSearch) unitPickerSearch.value = '';
|
||||
if (unitPickerClear) unitPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
// SLM fields
|
||||
document.getElementById('editSlmModel').value = unit.slm_model || '';
|
||||
@@ -781,6 +947,35 @@
|
||||
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
|
||||
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
|
||||
|
||||
// Cascade section - show if there's a paired device
|
||||
const cascadeSection = document.getElementById('editCascadeSection');
|
||||
const cascadeToUnitId = document.getElementById('editCascadeToUnitId');
|
||||
const pairedDeviceName = document.getElementById('editPairedDeviceName');
|
||||
|
||||
// Determine paired device based on device type
|
||||
let pairedUnitId = null;
|
||||
if (unit.device_type === 'modem' && unit.deployed_with_unit_id) {
|
||||
pairedUnitId = unit.deployed_with_unit_id;
|
||||
} else if ((unit.device_type === 'seismograph' || unit.device_type === 'sound_level_meter') && unit.deployed_with_modem_id) {
|
||||
pairedUnitId = unit.deployed_with_modem_id;
|
||||
}
|
||||
|
||||
if (pairedUnitId) {
|
||||
cascadeToUnitId.value = pairedUnitId;
|
||||
pairedDeviceName.textContent = pairedUnitId;
|
||||
cascadeSection.classList.remove('hidden');
|
||||
// Reset checkboxes
|
||||
document.getElementById('editCascadeDeployed').checked = false;
|
||||
document.getElementById('editCascadeRetired').checked = false;
|
||||
document.getElementById('editCascadeProject').checked = false;
|
||||
document.getElementById('editCascadeLocation').checked = false;
|
||||
document.getElementById('editCascadeCoordinates').checked = false;
|
||||
document.getElementById('editCascadeNote').checked = false;
|
||||
} else {
|
||||
cascadeToUnitId.value = '';
|
||||
cascadeSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Store unit ID for form submission
|
||||
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||
|
||||
@@ -814,11 +1009,7 @@
|
||||
|
||||
if (response.ok) {
|
||||
closeEditUnitModal();
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
alert('Unit updated successfully!');
|
||||
} else {
|
||||
const result = await response.json();
|
||||
@@ -846,11 +1037,7 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
|
||||
} else {
|
||||
const result = await response.json();
|
||||
@@ -878,11 +1065,7 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
alert(`Unit ${unitId} moved to ignore list`);
|
||||
} else {
|
||||
const result = await response.json();
|
||||
@@ -905,11 +1088,7 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
alert(`Unit ${unitId} deleted successfully`);
|
||||
} else {
|
||||
const result = await response.json();
|
||||
@@ -948,11 +1127,7 @@
|
||||
`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
|
||||
// Close modal after 2 seconds
|
||||
setTimeout(() => closeImportModal(), 2000);
|
||||
@@ -968,35 +1143,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Handle roster tab switching with auto-refresh
|
||||
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab
|
||||
// Refresh device list (applies current client-side filters after load)
|
||||
function refreshDeviceList() {
|
||||
htmx.ajax('GET', '/partials/devices-all', {
|
||||
target: '#device-content',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
// Re-apply filters after content loads
|
||||
setTimeout(filterDevices, 100);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabButtons = document.querySelectorAll('.roster-tab-button');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
// Remove active-roster-tab class from all buttons
|
||||
tabButtons.forEach(btn => btn.classList.remove('active-roster-tab'));
|
||||
// Add active-roster-tab class to clicked button
|
||||
this.classList.add('active-roster-tab');
|
||||
|
||||
// Update current endpoint for auto-refresh
|
||||
currentRosterEndpoint = this.getAttribute('data-endpoint');
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-refresh the current active tab every 10 seconds
|
||||
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
||||
setInterval(() => {
|
||||
const rosterContent = document.getElementById('roster-content');
|
||||
if (rosterContent) {
|
||||
// Use HTMX to trigger a refresh of the current endpoint
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
const deviceContent = document.getElementById('device-content');
|
||||
if (deviceContent && !document.querySelector('.modal:not(.hidden)')) {
|
||||
// Only auto-refresh if no modal is open
|
||||
refreshDeviceList();
|
||||
}
|
||||
}, 10000); // 10 seconds
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// Un-ignore Unit (remove from ignored list)
|
||||
@@ -1345,4 +1511,7 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Include Project Create Modal for inline project creation -->
|
||||
{% include "partials/project_create_modal.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -121,8 +121,13 @@
|
||||
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
||||
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</label>
|
||||
<p id="viewProjectContainer" class="mt-1">
|
||||
<a id="viewProjectLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
||||
<span id="viewProjectText">--</span>
|
||||
</a>
|
||||
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||
@@ -172,6 +177,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paired Device (for modems only) -->
|
||||
<div id="viewPairedDeviceSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Paired Device</h3>
|
||||
<div id="pairedDeviceInfo">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connectivity (for modems only) -->
|
||||
<div id="viewConnectivitySection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connectivity</h3>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<button onclick="pingModem()" id="modemPingBtn"
|
||||
class="px-4 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded-lg flex items-center gap-2 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
Ping Test
|
||||
</button>
|
||||
<span id="modemPingResult" class="text-sm text-gray-500 dark:text-gray-400">--</span>
|
||||
</div>
|
||||
|
||||
<!-- Future Diagnostics Placeholders -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-60">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Signal Strength</p>
|
||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- dBm</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||
</div>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Data Usage</p>
|
||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- MB</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||
</div>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Uptime</p>
|
||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">--</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||
@@ -251,11 +298,11 @@
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
|
||||
<!-- Project ID -->
|
||||
<!-- Project -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||
<input type="text" name="project_id" id="projectId"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||
{% set picker_id = "-detail" %}
|
||||
{% include "partials/project_picker.html" with context %}
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
@@ -415,6 +462,26 @@ let currentSnapshot = null;
|
||||
let unitMap = null;
|
||||
let mapMarker = null;
|
||||
|
||||
// Fetch project display name (combines project_number, client_name, name)
|
||||
async function fetchProjectDisplay(projectId) {
|
||||
if (!projectId) return '';
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`);
|
||||
if (response.ok) {
|
||||
const project = await response.json();
|
||||
const parts = [
|
||||
project.project_number,
|
||||
project.client_name,
|
||||
project.name
|
||||
].filter(Boolean);
|
||||
return parts.join(' - ') || projectId;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch project:', e);
|
||||
}
|
||||
return projectId;
|
||||
}
|
||||
|
||||
// Load unit data on page load
|
||||
async function loadUnitData() {
|
||||
try {
|
||||
@@ -536,7 +603,31 @@ function populateViewMode() {
|
||||
// Basic info
|
||||
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
||||
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
||||
document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
|
||||
|
||||
// Project display with clickable link
|
||||
const projectId = currentUnit.project_id;
|
||||
const projectLink = document.getElementById('viewProjectLink');
|
||||
const projectNoLink = document.getElementById('viewProjectNoLink');
|
||||
const projectText = document.getElementById('viewProjectText');
|
||||
|
||||
if (projectId) {
|
||||
// Fetch project display name and show link
|
||||
fetchProjectDisplay(projectId).then(displayText => {
|
||||
if (projectText) projectText.textContent = displayText;
|
||||
if (projectLink) {
|
||||
projectLink.href = `/projects/${projectId}`;
|
||||
projectLink.classList.remove('hidden');
|
||||
}
|
||||
if (projectNoLink) projectNoLink.classList.add('hidden');
|
||||
});
|
||||
} else {
|
||||
if (projectNoLink) {
|
||||
projectNoLink.textContent = 'Not assigned';
|
||||
projectNoLink.classList.remove('hidden');
|
||||
}
|
||||
if (projectLink) projectLink.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
||||
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
||||
|
||||
@@ -557,9 +648,15 @@ function populateViewMode() {
|
||||
if (currentUnit.device_type === 'modem') {
|
||||
document.getElementById('viewSeismographFields').classList.add('hidden');
|
||||
document.getElementById('viewModemFields').classList.remove('hidden');
|
||||
document.getElementById('viewPairedDeviceSection').classList.remove('hidden');
|
||||
document.getElementById('viewConnectivitySection').classList.remove('hidden');
|
||||
// Load paired device info
|
||||
loadPairedDevice();
|
||||
} else {
|
||||
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
||||
document.getElementById('viewModemFields').classList.add('hidden');
|
||||
document.getElementById('viewPairedDeviceSection').classList.add('hidden');
|
||||
document.getElementById('viewConnectivitySection').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +664,22 @@ function populateViewMode() {
|
||||
function populateEditForm() {
|
||||
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
||||
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
||||
document.getElementById('projectId').value = currentUnit.project_id || '';
|
||||
|
||||
// Populate project picker (uses -detail suffix)
|
||||
const projectPickerValue = document.getElementById('project-picker-value-detail');
|
||||
const projectPickerSearch = document.getElementById('project-picker-search-detail');
|
||||
const projectPickerClear = document.getElementById('project-picker-clear-detail');
|
||||
if (projectPickerValue) projectPickerValue.value = currentUnit.project_id || '';
|
||||
if (currentUnit.project_id) {
|
||||
fetchProjectDisplay(currentUnit.project_id).then(displayText => {
|
||||
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
||||
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
||||
});
|
||||
} else {
|
||||
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('address').value = currentUnit.address || '';
|
||||
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||
@@ -999,10 +1111,66 @@ async function deleteHistoryEntry(historyId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load paired device info for modems
|
||||
async function loadPairedDevice() {
|
||||
try {
|
||||
const response = await fetch(`/api/modem-dashboard/${unitId}/paired-device-html`);
|
||||
if (response.ok) {
|
||||
const html = await response.text();
|
||||
document.getElementById('pairedDeviceInfo').innerHTML = html;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading paired device:', error);
|
||||
document.getElementById('pairedDeviceInfo').innerHTML = '<p class="text-red-500 text-sm">Failed to load paired device info</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Ping modem and show result
|
||||
async function pingModem() {
|
||||
const btn = document.getElementById('modemPingBtn');
|
||||
const resultSpan = document.getElementById('modemPingResult');
|
||||
|
||||
// Show loading state
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = `
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Pinging...
|
||||
`;
|
||||
btn.disabled = true;
|
||||
resultSpan.textContent = 'Testing connection...';
|
||||
resultSpan.className = 'text-sm text-gray-500 dark:text-gray-400';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/modem-dashboard/${unitId}/ping`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
|
||||
resultSpan.className = 'text-sm text-green-600 dark:text-green-400';
|
||||
} else {
|
||||
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
|
||||
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
} catch (error) {
|
||||
resultSpan.textContent = 'Error: ' + error.message;
|
||||
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
|
||||
// Restore button
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// Load data when page loads
|
||||
loadUnitData().then(() => {
|
||||
loadPhotos();
|
||||
loadUnitHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Include Project Create Modal for inline project creation -->
|
||||
{% include "partials/project_create_modal.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user