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:
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
|
||||
<!-- 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">
|
||||
<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 -->
|
||||
@@ -37,10 +38,6 @@
|
||||
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">
|
||||
@@ -49,6 +46,34 @@
|
||||
</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>
|
||||
|
||||
<!-- Units List (loaded via HTMX) -->
|
||||
<div id="seismo-units-list"
|
||||
hx-get="/api/seismo-dashboard/units"
|
||||
@@ -59,17 +84,53 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Clear search input on escape key
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('seismo-search');
|
||||
if (searchInput) {
|
||||
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 = '';
|
||||
htmx.trigger(this, 'keyup');
|
||||
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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user