BIG update: Update to 0.5.1. Added:
-Project management -Modem Managerment -Modem/unit pairing and more
This commit is contained in:
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 %}
|
||||
Reference in New Issue
Block a user