Change: user sets date of previous calibration, not upcoming expire dates.

- seismograph list page enhanced with better visabilty, filtering, sorting, and calibration dates color coded.
This commit is contained in:
serversdwn
2026-02-06 21:17:14 +00:00
parent eb0a99796d
commit 89662d2fa5
8 changed files with 408 additions and 86 deletions

View File

@@ -9,12 +9,20 @@ import httpx
import os
from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences
from backend.services.slmm_sync import sync_slm_to_slmm
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
logger = logging.getLogger(__name__)
def get_calibration_interval(db: Session) -> int:
"""Get calibration interval from user preferences, default 365 days."""
prefs = db.query(UserPreferences).first()
if prefs and prefs.calibration_interval_days:
return prefs.calibration_interval_days
return 365
# SLMM backend URL for syncing device configs to cache
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
@@ -185,11 +193,11 @@ async def add_roster_unit(
except ValueError:
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
# Auto-calculate next_calibration_due (1 year from last_calibrated)
# This is calculated internally but not shown to user (they just see last_calibrated)
# Auto-calculate next_calibration_due from last_calibrated using calibration interval
next_cal_date = None
if last_cal_date:
next_cal_date = last_cal_date + timedelta(days=365)
cal_interval = get_calibration_interval(db)
next_cal_date = last_cal_date + timedelta(days=cal_interval)
elif next_calibration_due:
# Fallback: allow explicit setting if no last_calibrated
try:
@@ -522,11 +530,11 @@ async def edit_roster_unit(
except ValueError:
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
# Auto-calculate next_calibration_due (1 year from last_calibrated)
# This is calculated internally but not shown to user (they just see last_calibrated)
# Auto-calculate next_calibration_due from last_calibrated using calibration interval
next_cal_date = None
if last_cal_date:
next_cal_date = last_cal_date + timedelta(days=365)
cal_interval = get_calibration_interval(db)
next_cal_date = last_cal_date + timedelta(days=cal_interval)
elif next_calibration_due:
# Fallback: allow explicit setting if no last_calibrated
try:
@@ -1007,9 +1015,10 @@ async def import_csv(
if row.get('last_calibrated'):
last_cal = _parse_date(row.get('last_calibrated'))
existing_unit.last_calibrated = last_cal
# Auto-calculate next_calibration_due (1 year from last_calibrated)
# Auto-calculate next_calibration_due using calibration interval
if last_cal:
existing_unit.next_calibration_due = last_cal + timedelta(days=365)
cal_interval = get_calibration_interval(db)
existing_unit.next_calibration_due = last_cal + timedelta(days=cal_interval)
elif row.get('next_calibration_due'):
# Only use explicit next_calibration_due if no last_calibrated
existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due'))
@@ -1048,6 +1057,14 @@ async def import_csv(
results["updated"].append(unit_id)
else:
# Calculate next_calibration_due from last_calibrated
last_cal = _parse_date(row.get('last_calibrated', ''))
if last_cal:
cal_interval = get_calibration_interval(db)
next_cal = last_cal + timedelta(days=cal_interval)
else:
next_cal = _parse_date(row.get('next_calibration_due', ''))
# Create new unit with all fields
new_unit = RosterUnit(
id=unit_id,
@@ -1062,12 +1079,8 @@ async def import_csv(
coordinates=_get_csv_value(row, 'coordinates'),
last_updated=datetime.utcnow(),
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
last_calibrated=_parse_date(row.get('last_calibrated', '')),
next_calibration_due=(
_parse_date(row.get('last_calibrated', '')) + timedelta(days=365)
if _parse_date(row.get('last_calibrated', ''))
else _parse_date(row.get('next_calibration_due', ''))
),
last_calibrated=last_cal,
next_calibration_due=next_cal,
deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'),
# Modem fields
ip_address=_get_csv_value(row, 'ip_address'),

View File

@@ -3,6 +3,8 @@ Seismograph Dashboard API Router
Provides endpoints for the seismograph-specific dashboard
"""
from datetime import date
from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
@@ -49,10 +51,14 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
async def get_seismo_units(
request: Request,
db: Session = Depends(get_db),
search: str = Query(None)
search: str = Query(None),
sort: str = Query("id"),
order: str = Query("asc"),
status: str = Query(None),
modem: str = Query(None)
):
"""
Returns HTML partial with filterable seismograph unit list
Returns HTML partial with filterable and sortable seismograph unit list
"""
query = db.query(RosterUnit).filter_by(
device_type="seismograph",
@@ -61,20 +67,52 @@ async def get_seismo_units(
# Apply search filter
if search:
search_lower = search.lower()
query = query.filter(
(RosterUnit.id.ilike(f"%{search}%")) |
(RosterUnit.note.ilike(f"%{search}%")) |
(RosterUnit.address.ilike(f"%{search}%"))
)
seismos = query.order_by(RosterUnit.id).all()
# Apply status filter
if status == "deployed":
query = query.filter(RosterUnit.deployed == True)
elif status == "benched":
query = query.filter(RosterUnit.deployed == False)
# Apply modem filter
if modem == "with":
query = query.filter(RosterUnit.deployed_with_modem_id.isnot(None))
elif modem == "without":
query = query.filter(RosterUnit.deployed_with_modem_id.is_(None))
# Apply sorting
sort_column_map = {
"id": RosterUnit.id,
"status": RosterUnit.deployed,
"modem": RosterUnit.deployed_with_modem_id,
"location": RosterUnit.address,
"last_calibrated": RosterUnit.last_calibrated,
"notes": RosterUnit.note
}
sort_column = sort_column_map.get(sort, RosterUnit.id)
if order == "desc":
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
seismos = query.all()
return templates.TemplateResponse(
"partials/seismo_unit_list.html",
{
"request": request,
"units": seismos,
"search": search or ""
"search": search or "",
"sort": sort,
"order": order,
"status": status or "",
"modem": modem or "",
"today": date.today()
}
)

View File

@@ -115,10 +115,10 @@
</div>
{% endif %}
{% else %}
{% if unit.next_calibration_due %}
{% if unit.last_calibrated %}
<div>
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
<span class="font-medium">{{ unit.next_calibration_due }}</span>
<span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
<span class="font-medium">{{ unit.last_calibrated }}</span>
</div>
{% endif %}
{% if unit.deployed_with_modem_id %}

View File

@@ -108,10 +108,10 @@
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
{% endif %}
{% else %}
{% if unit.next_calibration_due %}
{% if unit.last_calibrated %}
<div>
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
<span class="font-medium">{{ unit.next_calibration_due }}</span>
<span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
<span class="font-medium">{{ unit.last_calibrated }}</span>
</div>
{% endif %}
{% if unit.deployed_with_modem_id %}

View File

@@ -1,13 +1,92 @@
{% if units %}
{% if units is defined %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
{% set next_order = 'desc' if (sort == 'id' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=id&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Unit ID
{% if sort == 'id' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'status' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=status&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Status
{% if sort == 'status' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'modem' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=modem&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Modem
{% if sort == 'modem' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'location' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=location&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Location
{% if sort == 'location' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'last_calibrated' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=last_calibrated&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Last Calibrated
{% if sort == 'last_calibrated' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'notes' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=notes&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Notes
{% if sort == 'notes' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
@@ -54,6 +133,27 @@
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.last_calibrated %}
<span class="inline-flex items-center gap-1.5">
{% if unit.next_calibration_due and today %}
{% set days_until = (unit.next_calibration_due - today).days %}
{% if days_until < 0 %}
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
{% elif days_until <= 14 %}
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
{% endif %}
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
{% endif %}
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
{% if unit.note %}
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
@@ -72,9 +172,12 @@
</table>
</div>
{% if search %}
{% if search or status or modem %}
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
Found {{ units|length }} seismograph(s) matching "{{ search }}"
Found {{ units|length }} seismograph(s)
{% if search %} matching "{{ search }}"{% endif %}
{% if status %} ({{ status }}){% endif %}
{% if modem %} ({{ 'with modem' if modem == 'with' else 'without modem' }}){% endif %}
</div>
{% endif %}

View File

@@ -145,16 +145,12 @@
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
<input type="date" name="last_calibrated"
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
<input type="date" name="last_calibrated" id="addLastCalibrated"
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">Next calibration due date will be calculated automatically</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due"
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">Typically 1 year after last calibration</p>
</div>
<input type="hidden" name="next_calibration_due" id="addNextCalibrationDue">
<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>
{% set picker_id = "-add-seismo" %}
@@ -325,15 +321,12 @@
<div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
<input type="date" name="last_calibrated" id="editLastCalibrated"
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">Next calibration due date will be calculated automatically</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due" id="editNextCalibrationDue"
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>
<input type="hidden" name="next_calibration_due" id="editNextCalibrationDue">
<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>
{% set picker_id = "-edit-seismo" %}
@@ -598,6 +591,58 @@
</div>
<script>
// Calibration interval in days (default 365, will be loaded from preferences)
let calibrationIntervalDays = 365;
// Load calibration interval from preferences
async function loadCalibrationInterval() {
try {
const response = await fetch('/api/settings/preferences');
if (response.ok) {
const prefs = await response.json();
calibrationIntervalDays = prefs.calibration_interval_days || 365;
}
} catch (e) {
console.error('Failed to load calibration interval:', e);
}
}
// Calculate next calibration due date from last calibrated date
function calculateNextCalibrationDue(lastCalibratedStr) {
if (!lastCalibratedStr) return '';
const lastCalibrated = new Date(lastCalibratedStr);
const nextDue = new Date(lastCalibrated);
nextDue.setDate(nextDue.getDate() + calibrationIntervalDays);
return nextDue.toISOString().split('T')[0];
}
// Setup auto-calculation for calibration fields
function setupCalibrationAutoCalc() {
// Add form
const addLastCal = document.getElementById('addLastCalibrated');
const addNextCal = document.getElementById('addNextCalibrationDue');
if (addLastCal && addNextCal) {
addLastCal.addEventListener('change', function() {
addNextCal.value = calculateNextCalibrationDue(this.value);
});
}
// Edit form
const editLastCal = document.getElementById('editLastCalibrated');
const editNextCal = document.getElementById('editNextCalibrationDue');
if (editLastCal && editNextCal) {
editLastCal.addEventListener('change', function() {
editNextCal.value = calculateNextCalibrationDue(this.value);
});
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
loadCalibrationInterval();
setupCalibrationAutoCalc();
});
// Add Unit Modal
function openAddUnitModal() {
document.getElementById('addUnitModal').classList.remove('hidden');
@@ -891,8 +936,11 @@
document.getElementById('editRetiredCheckbox').checked = unit.retired;
// Seismograph fields
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
document.getElementById('editLastCalibrated').value = unit.last_calibrated || '';
// Calculate next calibration due from last calibrated
document.getElementById('editNextCalibrationDue').value = unit.last_calibrated
? calculateNextCalibrationDue(unit.last_calibrated)
: '';
// Populate modem picker for seismograph (uses -edit-seismo suffix)
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');

View File

@@ -27,25 +27,50 @@
<!-- Seismograph List -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
<div class="mb-4 flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
<!-- Search Box -->
<div class="relative">
<input
type="text"
id="seismo-search"
placeholder="Search seismographs..."
class="w-full sm:w-64 px-4 py-2 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-blue-500 focus:border-transparent"
hx-get="/api/seismo-dashboard/units"
hx-trigger="keyup changed delay:300ms"
hx-target="#seismo-units-list"
hx-include="[name='search']"
name="search"
/>
<svg class="absolute right-3 top-2.5 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>
<!-- Search Box -->
<div class="relative">
<input
type="text"
id="seismo-search"
placeholder="Search seismographs..."
class="w-full sm:w-64 px-4 py-2 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-blue-500 focus:border-transparent"
name="search"
/>
<svg class="absolute right-3 top-2.5 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>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-600 dark:text-gray-400">Filter:</span>
<!-- Status Filter -->
<select id="seismo-status-filter" name="status"
class="px-3 py-1.5 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-blue-500 focus:border-transparent">
<option value="">All Status</option>
<option value="deployed">Deployed</option>
<option value="benched">Benched</option>
</select>
<!-- Modem Filter -->
<select id="seismo-modem-filter" name="modem"
class="px-3 py-1.5 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-blue-500 focus:border-transparent">
<option value="">All Modems</option>
<option value="with">With Modem</option>
<option value="without">Without Modem</option>
</select>
<!-- Clear Filters Button -->
<button id="seismo-clear-filters" type="button"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Clear Filters
</button>
</div>
</div>
@@ -59,17 +84,53 @@
</div>
<script>
// Clear search input on escape key
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('seismo-search');
if (searchInput) {
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
this.value = '';
htmx.trigger(this, 'keyup');
}
});
const statusFilter = document.getElementById('seismo-status-filter');
const modemFilter = document.getElementById('seismo-modem-filter');
const clearBtn = document.getElementById('seismo-clear-filters');
const unitsList = document.getElementById('seismo-units-list');
// Build URL with current filter values
function buildUrl() {
const params = new URLSearchParams();
if (searchInput.value) params.set('search', searchInput.value);
if (statusFilter.value) params.set('status', statusFilter.value);
if (modemFilter.value) params.set('modem', modemFilter.value);
return '/api/seismo-dashboard/units' + (params.toString() ? '?' + params.toString() : '');
}
// Trigger HTMX refresh
function refreshList() {
htmx.ajax('GET', buildUrl(), {target: '#seismo-units-list', swap: 'innerHTML'});
}
// Search input with debounce
let debounceTimer;
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshList, 300);
});
// Clear search on escape
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
this.value = '';
refreshList();
}
});
// Filter changes
statusFilter.addEventListener('change', refreshList);
modemFilter.addEventListener('change', refreshList);
// Clear all filters
clearBtn.addEventListener('click', function() {
searchInput.value = '';
statusFilter.value = '';
modemFilter.value = '';
refreshList();
});
});
</script>

View File

@@ -144,12 +144,12 @@
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Seismograph Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Date of Last Calibration</label>
<p id="viewLastCalibrated" 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">Next Calibration Due</label>
<p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
<p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium inline-flex items-center gap-2">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
@@ -378,15 +378,12 @@
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Seismograph Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
<input type="date" name="last_calibrated" id="lastCalibrated"
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">Next calibration due date will be calculated automatically</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due" id="nextCalibrationDue"
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>
<input type="hidden" name="next_calibration_due" id="nextCalibrationDue">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<div class="flex gap-2">
@@ -589,6 +586,42 @@ let currentSnapshot = null;
let unitMap = null;
let mapMarker = null;
// Calibration interval in days (default 365, will be loaded from preferences)
let calibrationIntervalDays = 365;
// Load calibration interval from preferences
async function loadCalibrationInterval() {
try {
const response = await fetch('/api/settings/preferences');
if (response.ok) {
const prefs = await response.json();
calibrationIntervalDays = prefs.calibration_interval_days || 365;
}
} catch (e) {
console.error('Failed to load calibration interval:', e);
}
}
// Calculate next calibration due date from last calibrated date
function calculateNextCalibrationDue(lastCalibratedStr) {
if (!lastCalibratedStr) return '';
const lastCalibrated = new Date(lastCalibratedStr);
const nextDue = new Date(lastCalibrated);
nextDue.setDate(nextDue.getDate() + calibrationIntervalDays);
return nextDue.toISOString().split('T')[0];
}
// Setup auto-calculation for calibration fields
function setupCalibrationAutoCalc() {
const lastCal = document.getElementById('lastCalibrated');
const nextCal = document.getElementById('nextCalibrationDue');
if (lastCal && nextCal) {
lastCal.addEventListener('change', function() {
nextCal.value = calculateNextCalibrationDue(this.value);
});
}
}
// Fetch project display name (combines project_number, client_name, name)
async function fetchProjectDisplay(projectId) {
if (!projectId) return '';
@@ -819,7 +852,28 @@ function populateViewMode() {
// Seismograph fields
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
// Calculate next calibration due and show with status indicator
const nextCalDueEl = document.getElementById('viewNextCalibrationDue');
if (currentUnit.last_calibrated) {
const nextDue = calculateNextCalibrationDue(currentUnit.last_calibrated);
const today = new Date().toISOString().split('T')[0];
const daysUntil = Math.floor((new Date(nextDue) - new Date(today)) / (1000 * 60 * 60 * 24));
let dotColor = 'bg-green-500';
let tooltip = `Calibration valid (${daysUntil} days remaining)`;
if (daysUntil < 0) {
dotColor = 'bg-red-500';
tooltip = `Calibration expired ${-daysUntil} days ago`;
} else if (daysUntil <= 14) {
dotColor = 'bg-yellow-500';
tooltip = `Calibration expires in ${daysUntil} days`;
}
nextCalDueEl.innerHTML = `<span class="w-2 h-2 rounded-full ${dotColor}" title="${tooltip}"></span>${nextDue}`;
} else {
nextCalDueEl.textContent = '--';
}
// Deployed with modem - show as clickable link
const modemLink = document.getElementById('viewDeployedWithModemLink');
@@ -960,7 +1014,10 @@ function populateEditForm() {
// Seismograph fields
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
// Calculate next calibration due from last calibrated
document.getElementById('nextCalibrationDue').value = currentUnit.last_calibrated
? calculateNextCalibrationDue(currentUnit.last_calibrated)
: '';
// Populate modem picker for seismograph (uses -detail-seismo suffix)
const modemPickerValue = document.getElementById('modem-picker-value-detail-seismo');
@@ -1535,6 +1592,8 @@ async function pingModem() {
}
// Load data when page loads
loadCalibrationInterval();
setupCalibrationAutoCalc();
loadUnitData().then(() => {
loadPhotos();
loadUnitHistory();