56bd3041cf
feat: Location no longer assigned directly to unit, locations and coords are assigned to location only, unit only is deployed or benched.
3743 lines
188 KiB
HTML
3743 lines
188 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Unit Detail - Seismo Fleet Manager{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mb-6">
|
|
<!-- Breadcrumb Navigation -->
|
|
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
|
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
|
<li class="inline-flex items-center">
|
|
<a href="/" class="text-gray-500 hover:text-seismo-orange dark:text-gray-400">
|
|
Dashboard
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<div class="flex items-center">
|
|
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
|
</svg>
|
|
<a href="/roster" class="ml-1 text-gray-500 hover:text-seismo-orange dark:text-gray-400">
|
|
Fleet Roster
|
|
</a>
|
|
</div>
|
|
</li>
|
|
<li aria-current="page">
|
|
<div class="flex items-center">
|
|
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
|
</svg>
|
|
<span class="ml-1 text-gray-700 dark:text-gray-300 font-medium" id="breadcrumb-unit">Unit</span>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<div class="flex justify-between items-center">
|
|
<div class="flex items-center gap-2">
|
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
|
|
<button onclick="copyToClipboard(window.currentUnitId, this)" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1" title="Copy Unit ID">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
|
</svg>
|
|
Edit Unit
|
|
</button>
|
|
<button onclick="deleteUnit()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
</svg>
|
|
Delete Unit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div id="loadingState" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-12 text-center">
|
|
<div class="animate-pulse">
|
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mx-auto mb-4"></div>
|
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 mx-auto"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main content (hidden until loaded) -->
|
|
<div id="mainContent" class="hidden space-y-6">
|
|
<!-- Status Card -->
|
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
|
<div class="flex justify-between items-start mb-6">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Status</h2>
|
|
<div class="flex items-center space-x-2">
|
|
<span id="statusIndicator" class="w-3 h-3 rounded-full"></span>
|
|
<span id="statusText" class="font-semibold"></span>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Last Seen</span>
|
|
<p id="lastSeen" class="font-medium text-gray-900 dark:text-white">--</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Age</span>
|
|
<p id="age" class="font-medium text-gray-900 dark:text-white">--</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed</span>
|
|
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Unit Status</span>
|
|
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Location Map -->
|
|
<div id="mapCard" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 hidden">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location</h2>
|
|
<div id="unit-map" style="height: 400px; width: 100%;" class="rounded-lg mb-4"></div>
|
|
<p id="locationText" class="text-sm text-gray-500 dark:text-gray-400"></p>
|
|
</div>
|
|
|
|
<!-- View Mode: Unit Information (Default) -->
|
|
<div id="viewMode" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Unit Information</h2>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Basic Info Grid -->
|
|
<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">Device Type</label>
|
|
<p id="viewDeviceType" 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">Unit Type</label>
|
|
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</label>
|
|
<p id="viewProjectContainer" class="mt-1">
|
|
<a id="viewProjectLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
|
<span id="viewProjectText">--</span>
|
|
</a>
|
|
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployment Location</label>
|
|
<p id="viewLocationContainer" class="mt-1">
|
|
<a id="viewLocationLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
|
<span id="viewLocationText">--</span>
|
|
</a>
|
|
<span id="viewLocationNoLink" class="text-gray-500 dark:text-gray-400 italic">Not deployed</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
|
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
|
|
<p id="viewCoordinates" class="mt-1 text-gray-900 dark:text-white font-medium font-mono text-sm">--</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Seismograph Info -->
|
|
<div id="viewSeismographFields" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">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">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 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>
|
|
<p id="viewDeployedWithModemContainer" class="mt-1">
|
|
<a id="viewDeployedWithModemLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
|
<span id="viewDeployedWithModemText">--</span>
|
|
</a>
|
|
<span id="viewDeployedWithModemNoLink" class="text-gray-900 dark:text-white font-medium">--</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modem Info -->
|
|
<div id="viewModemFields" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Modem 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">IP Address</label>
|
|
<p id="viewIpAddress" 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">Phone Number</label>
|
|
<p id="viewPhoneNumber" 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">Hardware Model</label>
|
|
<p id="viewHardwareModel" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div id="viewModemLoginSection" class="hidden">
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Management Interface</label>
|
|
<p class="mt-1">
|
|
<a id="viewModemLoginLink" href="#" target="_blank"
|
|
class="inline-flex items-center gap-2 text-seismo-orange hover:text-orange-600 font-medium">
|
|
<span id="viewModemLoginText">--</span>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
|
</svg>
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sound Level Meter Info -->
|
|
<div id="viewSlmFields" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Sound Level Meter 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">Model</label>
|
|
<p id="viewSlmModel" 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">Serial Number</label>
|
|
<p id="viewSlmSerialNumber" 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">Frequency Weighting</label>
|
|
<p id="viewSlmFrequencyWeighting" 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">Time Weighting</label>
|
|
<p id="viewSlmTimeWeighting" 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">Measurement Range</label>
|
|
<p id="viewSlmMeasurementRange" 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">Deployed With Modem</label>
|
|
<p id="viewSlmDeployedWithModemContainer" class="mt-1">
|
|
<a id="viewSlmDeployedWithModemLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
|
<span id="viewSlmDeployedWithModemText">--</span>
|
|
</a>
|
|
<span id="viewSlmDeployedWithModemNoLink" class="text-gray-900 dark:text-white font-medium">--</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Paired Device (for modems only) -->
|
|
<div id="viewPairedDeviceSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Paired Device</h3>
|
|
<div id="pairedDeviceInfo">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connectivity (for modems only) -->
|
|
<div id="viewConnectivitySection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connectivity</h3>
|
|
<div class="flex items-center gap-4 mb-4">
|
|
<button onclick="pingModem()" id="modemPingBtn"
|
|
class="px-4 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded-lg flex items-center gap-2 transition-colors">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
|
</svg>
|
|
Ping Test
|
|
</button>
|
|
<span id="modemPingResult" class="text-sm text-gray-500 dark:text-gray-400">--</span>
|
|
</div>
|
|
|
|
<!-- Future Diagnostics Placeholders -->
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-60">
|
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">Signal Strength</p>
|
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- dBm</p>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
|
</div>
|
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">Data Usage</p>
|
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- MB</p>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
|
</div>
|
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">Uptime</p>
|
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">--</p>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
|
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
|
</div>
|
|
|
|
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
|
unit_assignments + unit_history + SFM event overlay) -->
|
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<div class="flex justify-between items-center mb-4 flex-wrap gap-2">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
|
|
<div class="flex items-center gap-2">
|
|
<button onclick="openAddAssignmentModal()" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
|
+ Add deployment record
|
|
</button>
|
|
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
|
↻ Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Gantt chart — visual timeline of all deployments. Click
|
|
a bar to jump to its row in the list below. -->
|
|
<div id="deploymentGantt" class="mb-4 hidden">
|
|
<div class="bg-gray-50 dark:bg-slate-900/40 rounded-lg p-3">
|
|
<svg id="deploymentGanttSvg" class="w-full" style="height: 140px;" preserveAspectRatio="none"></svg>
|
|
<div id="deploymentGanttLegend" class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-gray-500 dark:text-gray-400"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="deploymentTimeline" class="space-y-3">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit-assignment modal -->
|
|
<div id="editAssignmentModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
|
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Edit deployment record</h3>
|
|
<button onclick="closeEditAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">×</button>
|
|
</div>
|
|
<div class="p-5 space-y-4">
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
<span id="editAssignmentLocation">—</span>
|
|
<span class="text-xs">·</span>
|
|
<span id="editAssignmentProject" class="text-xs">—</span>
|
|
</div>
|
|
<label class="block">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
|
|
<input id="editAssignedAt" type="datetime-local" step="60"
|
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
|
|
<div class="mt-1 flex items-center gap-2">
|
|
<input id="editAssignedUntil" type="datetime-local" step="60"
|
|
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
|
<input id="editAssignedUntilOpen" type="checkbox" onchange="_toggleEditOpenEnded()">
|
|
open-ended
|
|
</label>
|
|
</div>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Check "open-ended" to mark this assignment active (no end date).</p>
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
|
|
<textarea id="editAssignmentNotes" rows="2"
|
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
|
|
</label>
|
|
<div id="editAssignmentError" class="hidden text-sm text-red-600"></div>
|
|
</div>
|
|
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex items-center justify-between gap-2">
|
|
<button onclick="deleteAssignmentFromModal()" class="px-3 py-2 text-sm rounded-lg border border-red-300 text-red-700 dark:border-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20">
|
|
Delete
|
|
</button>
|
|
<div class="flex gap-2">
|
|
<button onclick="closeEditAssignmentModal()" class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
Cancel
|
|
</button>
|
|
<button id="editAssignmentSaveBtn" onclick="saveEditAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add-historical-assignment modal -->
|
|
<div id="addAssignmentModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
|
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add deployment record</h3>
|
|
<button onclick="closeAddAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">×</button>
|
|
</div>
|
|
<div class="p-5 space-y-4">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
Create a deployment record for this unit — usually to backfill a historical window so orphan events get attributed.
|
|
</p>
|
|
<label class="block">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</span>
|
|
<select id="addAssignmentProject" onchange="_addAssignmentProjectChanged()"
|
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
<option value="">Loading…</option>
|
|
</select>
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Location</span>
|
|
<select id="addAssignmentLocation"
|
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white" disabled>
|
|
<option value="">Pick a project first</option>
|
|
</select>
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
|
|
<input id="addAssignedAt" type="datetime-local" step="60"
|
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
|
|
<div class="mt-1 flex items-center gap-2">
|
|
<input id="addAssignedUntil" type="datetime-local" step="60"
|
|
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
|
<input id="addAssignedUntilOpen" type="checkbox" onchange="_toggleAddOpenEnded()">
|
|
open-ended
|
|
</label>
|
|
</div>
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
|
|
<textarea id="addAssignmentNotes" rows="2"
|
|
placeholder="e.g. Backfilled to attribute orphan events 2026-03-16 — 2026-03-25"
|
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
|
|
</label>
|
|
<div id="addAssignmentError" class="hidden text-sm text-red-600"></div>
|
|
</div>
|
|
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex justify-end gap-2">
|
|
<button onclick="closeAddAssignmentModal()" class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
Cancel
|
|
</button>
|
|
<button id="addAssignmentSaveBtn" onclick="saveAddAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
|
Create
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SFM Events (seismographs only) -->
|
|
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">SFM Events</h3>
|
|
<button onclick="loadUnitEvents()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
|
↻ Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<!-- KPI tiles -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
|
<span id="ue-stat-total" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Unattributed</span>
|
|
<span id="ue-stat-unattr" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">outside any assignment window</span>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overall Peak</span>
|
|
<span id="ue-stat-peak" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
<span id="ue-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</span>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
|
<span id="ue-stat-last" class="text-base font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs text-gray-500 dark:text-gray-400">Bucket</label>
|
|
<select id="ue-filter-bucket" onchange="loadUnitEvents()"
|
|
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">
|
|
<option value="all">All Events</option>
|
|
<option value="attributed">Attributed Only</option>
|
|
<option value="unattributed">Unattributed Only</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
|
|
<input type="datetime-local" id="ue-filter-from" onchange="loadUnitEvents()"
|
|
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">
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
|
|
<input type="datetime-local" id="ue-filter-to" onchange="loadUnitEvents()"
|
|
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">
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs text-gray-500 dark:text-gray-400">False Triggers</label>
|
|
<select id="ue-filter-ft" onchange="loadUnitEvents()"
|
|
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">
|
|
<option value="">All</option>
|
|
<option value="false">Real Only</option>
|
|
<option value="true">FT Only</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
|
|
<select id="ue-filter-limit" onchange="loadUnitEvents()"
|
|
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">
|
|
<option value="100">100</option>
|
|
<option value="250">250</option>
|
|
<option value="500" selected>500</option>
|
|
<option value="1000">1000</option>
|
|
</select>
|
|
</div>
|
|
<button onclick="clearUnitEventFilters()"
|
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
|
Clear
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Bulk false-trigger flagging -->
|
|
<div id="ue-bulk-actions" class="flex flex-wrap items-center gap-2 mb-3 text-sm">
|
|
<span id="ue-bulk-selected" class="text-gray-600 dark:text-gray-400">0 selected</span>
|
|
<button id="ue-bulk-flag-ft" onclick="flagSelectedUnitEvents(true)" disabled
|
|
class="px-3 py-1.5 text-sm rounded-lg border border-yellow-300 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
|
|
🚩 Flag as false trigger
|
|
</button>
|
|
<button id="ue-bulk-clear-ft" onclick="flagSelectedUnitEvents(false)" disabled
|
|
class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed">
|
|
✓ Clear false trigger
|
|
</button>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">For deletion / DB cleanup, use the <a href="/admin/events" class="text-seismo-orange hover:underline">Event DB Manager</a>.</span>
|
|
</div>
|
|
|
|
<!-- Event table -->
|
|
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">
|
|
Loading events…
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Photos -->
|
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
|
|
<div class="flex flex-col sm:flex-row gap-2">
|
|
<!-- Take Photo Button (Camera) -->
|
|
<label for="photoCameraUpload" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
|
<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="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Take Photo</span>
|
|
<span class="sm:hidden">Camera</span>
|
|
</label>
|
|
<!-- Choose from Library Button -->
|
|
<label for="photoLibraryUpload" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
|
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Choose Photo</span>
|
|
<span class="sm:hidden">Library</span>
|
|
</label>
|
|
<input type="file" id="photoCameraUpload" accept="image/*" capture="environment" class="hidden" onchange="uploadPhoto(this.files[0])">
|
|
<input type="file" id="photoLibraryUpload" accept="image/*" class="hidden" onchange="uploadPhoto(this.files[0])">
|
|
</div>
|
|
</div>
|
|
<div id="photoGallery" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading photos...</p>
|
|
</div>
|
|
<div id="uploadStatus" class="hidden mt-4 p-4 rounded-lg"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Deployment Modal -->
|
|
<div id="deploymentModal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg">
|
|
<div class="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<h3 id="deploymentModalTitle" class="text-lg font-semibold text-gray-900 dark:text-white">Log Deployment</h3>
|
|
<button onclick="closeDeploymentModal()" 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 class="p-6 space-y-4">
|
|
<input type="hidden" id="deploymentModalId">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Deployed Date</label>
|
|
<input type="date" id="deploymentDeployedDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Est. Removal Date</label>
|
|
<input type="date" id="deploymentEstRemovalDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
|
</div>
|
|
</div>
|
|
<div id="actualRemovalRow">
|
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Actual Removal Date <span class="text-gray-400 font-normal">(fill when returned)</span></label>
|
|
<input type="date" id="deploymentActualRemovalDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Job / Project</label>
|
|
<input type="text" id="deploymentProjectRef" placeholder="e.g. Fay I-80, CMU Campus" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location Name</label>
|
|
<input type="text" id="deploymentLocationName" placeholder="e.g. North Gate, VP-001" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
|
|
<textarea id="deploymentNotes" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange resize-none"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
|
<button onclick="closeDeploymentModal()" class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg text-sm transition-colors">Cancel</button>
|
|
<button onclick="saveDeployment()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm transition-colors">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Mode: Unit Information Form (Hidden by default) -->
|
|
<div id="editMode" class="hidden rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Edit Unit Information</h2>
|
|
<button onclick="cancelEdit()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
<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>
|
|
|
|
<form id="editForm" class="space-y-6">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<!-- Device Type -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type</label>
|
|
<select name="device_type" id="deviceType" onchange="toggleDetailFields()"
|
|
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="seismograph">Seismograph</option>
|
|
<option value="modem">Modem</option>
|
|
<option value="slm">Sound Level Meter</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Unit Type -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
|
|
<input type="text" name="unit_type" id="unitType"
|
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
</div>
|
|
|
|
<!-- Project -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
|
{% set picker_id = "-detail" %}
|
|
{% include "partials/project_picker.html" with context %}
|
|
</div>
|
|
|
|
<!-- Address / coordinates are managed on the project's
|
|
MonitoringLocation, not the unit itself. Edit them on
|
|
the project page. -->
|
|
<div class="md:col-span-2 rounded-lg bg-gray-50 dark:bg-slate-700/50 border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-600 dark:text-gray-400">
|
|
Address & coordinates are set on the deployment location.
|
|
Open the project to edit them.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Seismograph Fields -->
|
|
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<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">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>
|
|
<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">
|
|
<div class="flex-1">
|
|
{% set picker_id = "-detail-seismo" %}
|
|
{% set input_name = "deployed_with_modem_id" %}
|
|
{% include "partials/modem_picker.html" with context %}
|
|
</div>
|
|
<button type="button" onclick="openPairDeviceModal('seismograph')"
|
|
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
|
|
title="Pair with modem">
|
|
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modem Fields -->
|
|
<div id="modemFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modem 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">IP Address</label>
|
|
<input type="text" name="ip_address" id="ipAddress" placeholder="192.168.1.100"
|
|
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">Phone Number</label>
|
|
<input type="text" name="phone_number" id="phoneNumber" placeholder="+1-555-0123"
|
|
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">Hardware Model</label>
|
|
<select name="hardware_model" id="hardwareModel"
|
|
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="">Select model...</option>
|
|
<option value="RV50">RV50</option>
|
|
<option value="RV55">RV55</option>
|
|
<option value="RX55">RX55</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sound Level Meter Fields -->
|
|
<div id="slmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Sound Level Meter 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">Model</label>
|
|
<input type="text" name="slm_model" id="slmModel" placeholder="NL-43, NL-53, 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">Serial Number</label>
|
|
<input type="text" name="slm_serial_number" id="slmSerialNumber" placeholder="123456"
|
|
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">Frequency Weighting</label>
|
|
<select name="slm_frequency_weighting" id="slmFrequencyWeighting"
|
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
<option value="">Not set</option>
|
|
<option value="A">A-weighting</option>
|
|
<option value="C">C-weighting</option>
|
|
<option value="Z">Z-weighting (Flat)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
|
<select name="slm_time_weighting" id="slmTimeWeighting"
|
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
<option value="">Not set</option>
|
|
<option value="F">Fast (125ms)</option>
|
|
<option value="S">Slow (1s)</option>
|
|
<option value="I">Impulse (35ms)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
|
<input type="text" name="slm_measurement_range" id="slmMeasurementRange" placeholder="30-130 dB"
|
|
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">TCP Port (on modem)</label>
|
|
<input type="number" name="slm_tcp_port" id="slmTcpPort" placeholder="2255"
|
|
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">Default: 2255</p>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<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">
|
|
<div class="flex-1">
|
|
{% set picker_id = "-detail-slm" %}
|
|
{% set input_name = "deployed_with_modem_id" %}
|
|
{% include "partials/modem_picker.html" with context %}
|
|
</div>
|
|
<button type="button" onclick="openPairDeviceModal('slm')"
|
|
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
|
|
title="Pair with modem">
|
|
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem that provides network connectivity for this SLM</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status Checkboxes -->
|
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
|
|
<div class="flex items-center gap-6 flex-wrap">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" name="deployed" id="deployed" value="true"
|
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" name="out_for_calibration" id="outForCalibration" value="true"
|
|
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" name="allocated" id="allocated" value="true"
|
|
onchange="document.getElementById('allocatedProjectRow').style.display = this.checked ? '' : 'none'"
|
|
class="w-4 h-4 text-orange-500 focus:ring-orange-400 rounded">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Allocated</span>
|
|
</label>
|
|
</div>
|
|
<div id="allocatedProjectRow" style="display:none">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Allocated to Project</label>
|
|
<input type="text" name="allocated_to_project_id" id="allocatedToProjectId"
|
|
placeholder="Project name or ID"
|
|
class="w-full px-3 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-orange-400 text-sm">
|
|
</div>
|
|
<!-- Hidden field for retired — controlled by the Retire button below -->
|
|
<input type="hidden" name="retired" id="retired" value="">
|
|
<div id="retireButtonSection">
|
|
<button type="button" id="retireBtn"
|
|
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
|
|
onclick="toggleRetired()">
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
|
<textarea name="note" id="note" rows="4"
|
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
|
</div>
|
|
|
|
<!-- Cascade to Paired Device Section -->
|
|
<div id="detailCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
|
</svg>
|
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
Also update paired device: <span id="detailPairedDeviceName" class="text-seismo-orange"></span>
|
|
</span>
|
|
</div>
|
|
<input type="hidden" name="cascade_to_unit_id" id="detailCascadeToUnitId" value="">
|
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" name="cascade_deployed" id="detailCascadeDeployed" value="true"
|
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" name="cascade_retired" id="detailCascadeRetired" value="true"
|
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" name="cascade_project" id="detailCascadeProject" value="true"
|
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
|
|
</label>
|
|
</div>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
|
Check the fields you want to sync to the paired device
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Save/Cancel Buttons -->
|
|
<div class="flex gap-3">
|
|
<button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
|
Save Changes
|
|
</button>
|
|
<button type="button" onclick="cancelEdit()" class="px-6 py-3 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>
|
|
const unitId = "{{ unit_id }}";
|
|
let currentUnit = null;
|
|
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 '';
|
|
try {
|
|
const response = await fetch(`/api/projects/${projectId}`);
|
|
if (response.ok) {
|
|
const project = await response.json();
|
|
const parts = [
|
|
project.project_number,
|
|
project.client_name,
|
|
project.name
|
|
].filter(Boolean);
|
|
return parts.join(' - ') || projectId;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch project:', e);
|
|
}
|
|
return projectId;
|
|
}
|
|
|
|
// Fetch modem display name (combines id, ip_address, hardware_model)
|
|
// Also returns the actual modem ID if found (for updating picker value)
|
|
async function fetchModemDisplay(modemIdOrIp) {
|
|
if (!modemIdOrIp) return { display: '', modemId: '' };
|
|
try {
|
|
// First try direct lookup by ID
|
|
let response = await fetch(`/api/roster/${encodeURIComponent(modemIdOrIp)}`);
|
|
if (response.ok) {
|
|
const modem = await response.json();
|
|
const parts = [modem.id];
|
|
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
|
|
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
|
|
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
|
|
}
|
|
|
|
// If not found, maybe it's an IP address - search for it
|
|
response = await fetch(`/api/roster/search/modems?q=${encodeURIComponent(modemIdOrIp)}`);
|
|
if (response.ok) {
|
|
// The search returns HTML, so we need to look up differently
|
|
// Try fetching all modems and find by IP
|
|
const modemsResponse = await fetch('/api/roster/modems');
|
|
if (modemsResponse.ok) {
|
|
const modems = await modemsResponse.json();
|
|
const modem = modems.find(m => m.ip_address === modemIdOrIp);
|
|
if (modem) {
|
|
const parts = [modem.id];
|
|
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
|
|
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
|
|
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch modem:', e);
|
|
}
|
|
return { display: modemIdOrIp, modemId: modemIdOrIp };
|
|
}
|
|
|
|
// Format weighting values for display
|
|
function formatWeighting(value, type) {
|
|
if (!value) return null;
|
|
if (type === 'frequency') {
|
|
const labels = { 'A': 'A-weighting', 'C': 'C-weighting', 'Z': 'Z-weighting (Flat)' };
|
|
return labels[value] || value;
|
|
} else if (type === 'time') {
|
|
const labels = { 'F': 'Fast (125ms)', 'S': 'Slow (1s)', 'I': 'Impulse (35ms)' };
|
|
return labels[value] || value;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// Load unit data on page load
|
|
async function loadUnitData() {
|
|
try {
|
|
// Fetch unit roster data
|
|
const rosterResponse = await fetch(`/api/roster/${unitId}`);
|
|
if (!rosterResponse.ok) {
|
|
throw new Error('Unit not found');
|
|
}
|
|
currentUnit = await rosterResponse.json();
|
|
|
|
// Fetch snapshot data for status info
|
|
const snapshotResponse = await fetch('/api/status-snapshot');
|
|
if (snapshotResponse.ok) {
|
|
currentSnapshot = await snapshotResponse.json();
|
|
}
|
|
|
|
// Load modems list for dropdown
|
|
await loadModemsList();
|
|
|
|
// Populate views
|
|
populateViewMode();
|
|
populateEditForm();
|
|
|
|
// Hide loading, show content
|
|
document.getElementById('loadingState').classList.add('hidden');
|
|
document.getElementById('mainContent').classList.remove('hidden');
|
|
|
|
// Initialize map after content is visible
|
|
setTimeout(() => {
|
|
initUnitMap();
|
|
}, 100);
|
|
} catch (error) {
|
|
alert(`Error loading unit: ${error.message}`);
|
|
window.location.href = '/roster';
|
|
}
|
|
}
|
|
|
|
// Load list of modems for dropdown
|
|
async function loadModemsList() {
|
|
try {
|
|
const response = await fetch('/api/roster/modems');
|
|
if (response.ok) {
|
|
const modems = await response.json();
|
|
|
|
// Populate both seismograph and SLM modem dropdowns
|
|
const seismoDropdown = document.getElementById('deployedWithModemId');
|
|
const slmDropdown = document.getElementById('slmDeployedWithModemId');
|
|
|
|
// Clear existing options (except the first "No modem" option)
|
|
[seismoDropdown, slmDropdown].forEach(dropdown => {
|
|
if (!dropdown) return;
|
|
while (dropdown.options.length > 1) {
|
|
dropdown.remove(1);
|
|
}
|
|
|
|
// Add modem options
|
|
modems.forEach(modem => {
|
|
const option = document.createElement('option');
|
|
option.value = modem.id;
|
|
option.textContent = `${modem.id}${modem.ip_address ? ' (' + modem.ip_address + ')' : ''}${modem.hardware_model ? ' - ' + modem.hardware_model : ''}`;
|
|
dropdown.appendChild(option);
|
|
});
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load modems list:', error);
|
|
}
|
|
}
|
|
|
|
// Populate view mode (read-only display)
|
|
function populateViewMode() {
|
|
// Update page title and store unit ID for copy function
|
|
window.currentUnitId = currentUnit.id;
|
|
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
|
|
document.getElementById('breadcrumb-unit').textContent = currentUnit.id;
|
|
|
|
// Get status info from snapshot
|
|
let unitStatus = null;
|
|
if (currentSnapshot && currentSnapshot.units) {
|
|
unitStatus = currentSnapshot.units[unitId];
|
|
}
|
|
|
|
// Status card
|
|
if (unitStatus) {
|
|
const statusColors = {
|
|
'OK': 'bg-green-500',
|
|
'Pending': 'bg-yellow-500',
|
|
'Missing': 'bg-red-500'
|
|
};
|
|
const statusTextColors = {
|
|
'OK': 'text-green-600 dark:text-green-400',
|
|
'Pending': 'text-yellow-600 dark:text-yellow-400',
|
|
'Missing': 'text-red-600 dark:text-red-400'
|
|
};
|
|
|
|
// If unit is not deployed (benched), show gray "Benched" status instead of health status
|
|
if (!currentUnit.deployed) {
|
|
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500';
|
|
document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400';
|
|
document.getElementById('statusText').textContent = 'Benched';
|
|
} else {
|
|
document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`;
|
|
document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`;
|
|
document.getElementById('statusText').textContent = unitStatus.status || 'Unknown';
|
|
}
|
|
|
|
// Format "Last Seen" with timezone-aware formatting
|
|
if (unitStatus.last && typeof formatFullTimestamp === 'function') {
|
|
document.getElementById('lastSeen').textContent = formatFullTimestamp(unitStatus.last);
|
|
} else {
|
|
document.getElementById('lastSeen').textContent = unitStatus.last || '--';
|
|
}
|
|
|
|
document.getElementById('age').textContent = unitStatus.age || '--';
|
|
} else {
|
|
const isAllocated = currentUnit.allocated && !currentUnit.deployed;
|
|
document.getElementById('statusIndicator').className = isAllocated
|
|
? 'w-3 h-3 rounded-full bg-orange-400'
|
|
: 'w-3 h-3 rounded-full bg-gray-400';
|
|
document.getElementById('statusText').className = isAllocated
|
|
? 'font-semibold text-orange-500 dark:text-orange-400'
|
|
: 'font-semibold text-gray-600 dark:text-gray-400';
|
|
document.getElementById('statusText').textContent = isAllocated ? 'Allocated' : (!currentUnit.deployed ? 'Benched' : 'No status data');
|
|
document.getElementById('lastSeen').textContent = '--';
|
|
document.getElementById('age').textContent = '--';
|
|
}
|
|
|
|
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
|
|
if (currentUnit.retired) {
|
|
document.getElementById('retiredStatus').textContent = 'Retired';
|
|
document.getElementById('retiredStatus').className = 'font-medium text-red-600 dark:text-red-400';
|
|
} else if (currentUnit.out_for_calibration) {
|
|
document.getElementById('retiredStatus').textContent = 'Out for Calibration';
|
|
document.getElementById('retiredStatus').className = 'font-medium text-purple-600 dark:text-purple-400';
|
|
} else if (currentUnit.allocated && !currentUnit.deployed) {
|
|
document.getElementById('retiredStatus').textContent = currentUnit.allocated_to_project_id
|
|
? `Allocated — ${currentUnit.allocated_to_project_id}`
|
|
: 'Allocated';
|
|
document.getElementById('retiredStatus').className = 'font-medium text-orange-500 dark:text-orange-400';
|
|
} else {
|
|
document.getElementById('retiredStatus').textContent = 'Active';
|
|
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
|
|
}
|
|
|
|
// Basic info
|
|
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
|
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
|
|
|
// Project display with clickable link
|
|
const projectId = currentUnit.project_id;
|
|
const projectLink = document.getElementById('viewProjectLink');
|
|
const projectNoLink = document.getElementById('viewProjectNoLink');
|
|
const projectText = document.getElementById('viewProjectText');
|
|
|
|
if (projectId) {
|
|
// Fetch project display name and show link
|
|
fetchProjectDisplay(projectId).then(displayText => {
|
|
if (projectText) projectText.textContent = displayText;
|
|
if (projectLink) {
|
|
projectLink.href = `/projects/${projectId}`;
|
|
projectLink.classList.remove('hidden');
|
|
}
|
|
if (projectNoLink) projectNoLink.classList.add('hidden');
|
|
});
|
|
} else {
|
|
if (projectNoLink) {
|
|
projectNoLink.textContent = 'Not assigned';
|
|
projectNoLink.classList.remove('hidden');
|
|
}
|
|
if (projectLink) projectLink.classList.add('hidden');
|
|
}
|
|
|
|
// Deployment Location — comes from the active UnitAssignment →
|
|
// MonitoringLocation. Show project link if present, otherwise
|
|
// "Not deployed" placeholder.
|
|
const locLink = document.getElementById('viewLocationLink');
|
|
const locText = document.getElementById('viewLocationText');
|
|
const locNoLink = document.getElementById('viewLocationNoLink');
|
|
const activeLoc = currentUnit.active_location;
|
|
if (activeLoc && activeLoc.location_id) {
|
|
if (locText) locText.textContent = activeLoc.name || activeLoc.address || 'Active location';
|
|
if (locLink) {
|
|
locLink.href = `/projects/${activeLoc.project_id}`;
|
|
locLink.classList.remove('hidden');
|
|
}
|
|
if (locNoLink) locNoLink.classList.add('hidden');
|
|
} else {
|
|
if (locLink) locLink.classList.add('hidden');
|
|
if (locNoLink) locNoLink.classList.remove('hidden');
|
|
}
|
|
|
|
// Address / coordinates also come from the active assignment.
|
|
document.getElementById('viewAddress').textContent = (activeLoc && activeLoc.address) || '--';
|
|
document.getElementById('viewCoordinates').textContent = (activeLoc && activeLoc.coordinates) || '--';
|
|
|
|
// Seismograph fields
|
|
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
|
|
|
// 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');
|
|
const modemNoLink = document.getElementById('viewDeployedWithModemNoLink');
|
|
const modemText = document.getElementById('viewDeployedWithModemText');
|
|
|
|
if (currentUnit.deployed_with_modem_id) {
|
|
// Fetch modem info to get the actual ID (in case stored as IP) and display text
|
|
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
|
if (modemText) modemText.textContent = result.display;
|
|
if (modemLink) {
|
|
modemLink.href = `/unit/${encodeURIComponent(result.modemId)}`;
|
|
modemLink.classList.remove('hidden');
|
|
}
|
|
if (modemNoLink) modemNoLink.classList.add('hidden');
|
|
});
|
|
} else {
|
|
if (modemNoLink) {
|
|
modemNoLink.textContent = '--';
|
|
modemNoLink.classList.remove('hidden');
|
|
}
|
|
if (modemLink) modemLink.classList.add('hidden');
|
|
}
|
|
|
|
// Modem fields
|
|
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
|
|
document.getElementById('viewPhoneNumber').textContent = currentUnit.phone_number || '--';
|
|
document.getElementById('viewHardwareModel').textContent = currentUnit.hardware_model || '--';
|
|
|
|
// Modem management interface link
|
|
const modemLoginSection = document.getElementById('viewModemLoginSection');
|
|
const modemLoginLink = document.getElementById('viewModemLoginLink');
|
|
const modemLoginText = document.getElementById('viewModemLoginText');
|
|
|
|
if (currentUnit.ip_address && currentUnit.hardware_model) {
|
|
let loginUrl = '';
|
|
let loginLabel = '';
|
|
|
|
if (currentUnit.hardware_model === 'RV50' || currentUnit.hardware_model === 'RV55') {
|
|
// ACEmanager uses port 9191
|
|
loginUrl = `http://${currentUnit.ip_address}:9191`;
|
|
loginLabel = 'ACEmanager';
|
|
} else if (currentUnit.hardware_model === 'RX55') {
|
|
// AirLink uses HTTPS on port 443
|
|
loginUrl = `https://${currentUnit.ip_address}:443`;
|
|
loginLabel = 'AirLink';
|
|
}
|
|
|
|
if (loginUrl) {
|
|
modemLoginLink.href = loginUrl;
|
|
modemLoginText.textContent = loginLabel;
|
|
modemLoginSection.classList.remove('hidden');
|
|
} else {
|
|
modemLoginSection.classList.add('hidden');
|
|
}
|
|
} else {
|
|
modemLoginSection.classList.add('hidden');
|
|
}
|
|
|
|
// Notes
|
|
document.getElementById('viewNote').textContent = currentUnit.note || '--';
|
|
|
|
// Show/hide fields based on device type
|
|
// Hide all device-specific sections first
|
|
document.getElementById('viewSeismographFields').classList.add('hidden');
|
|
document.getElementById('viewModemFields').classList.add('hidden');
|
|
document.getElementById('viewSlmFields').classList.add('hidden');
|
|
document.getElementById('viewPairedDeviceSection').classList.add('hidden');
|
|
document.getElementById('viewConnectivitySection').classList.add('hidden');
|
|
|
|
if (currentUnit.device_type === 'modem') {
|
|
document.getElementById('viewModemFields').classList.remove('hidden');
|
|
document.getElementById('viewPairedDeviceSection').classList.remove('hidden');
|
|
document.getElementById('viewConnectivitySection').classList.remove('hidden');
|
|
// Load paired device info
|
|
loadPairedDevice();
|
|
} else if (currentUnit.device_type === 'slm') {
|
|
document.getElementById('viewSlmFields').classList.remove('hidden');
|
|
// Populate SLM view fields
|
|
document.getElementById('viewSlmModel').textContent = currentUnit.slm_model || '--';
|
|
document.getElementById('viewSlmSerialNumber').textContent = currentUnit.slm_serial_number || '--';
|
|
document.getElementById('viewSlmFrequencyWeighting').textContent = formatWeighting(currentUnit.slm_frequency_weighting, 'frequency') || '--';
|
|
document.getElementById('viewSlmTimeWeighting').textContent = formatWeighting(currentUnit.slm_time_weighting, 'time') || '--';
|
|
document.getElementById('viewSlmMeasurementRange').textContent = currentUnit.slm_measurement_range || '--';
|
|
|
|
// Handle SLM modem link
|
|
const slmModemLink = document.getElementById('viewSlmDeployedWithModemLink');
|
|
const slmModemNoLink = document.getElementById('viewSlmDeployedWithModemNoLink');
|
|
const slmModemText = document.getElementById('viewSlmDeployedWithModemText');
|
|
|
|
if (currentUnit.deployed_with_modem_id) {
|
|
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
|
if (slmModemText) slmModemText.textContent = result.display;
|
|
if (slmModemLink) {
|
|
slmModemLink.href = `/unit/${encodeURIComponent(result.modemId)}`;
|
|
slmModemLink.classList.remove('hidden');
|
|
}
|
|
if (slmModemNoLink) slmModemNoLink.classList.add('hidden');
|
|
});
|
|
} else {
|
|
if (slmModemNoLink) {
|
|
slmModemNoLink.textContent = '--';
|
|
slmModemNoLink.classList.remove('hidden');
|
|
}
|
|
if (slmModemLink) slmModemLink.classList.add('hidden');
|
|
}
|
|
} else {
|
|
// Seismograph (default)
|
|
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// Populate edit form
|
|
function populateEditForm() {
|
|
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
|
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
|
|
|
// Populate project picker (uses -detail suffix)
|
|
const projectPickerValue = document.getElementById('project-picker-value-detail');
|
|
const projectPickerSearch = document.getElementById('project-picker-search-detail');
|
|
const projectPickerClear = document.getElementById('project-picker-clear-detail');
|
|
if (projectPickerValue) projectPickerValue.value = currentUnit.project_id || '';
|
|
if (currentUnit.project_id) {
|
|
fetchProjectDisplay(currentUnit.project_id).then(displayText => {
|
|
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
|
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
|
});
|
|
} else {
|
|
if (projectPickerSearch) projectPickerSearch.value = '';
|
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
|
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
|
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
|
updateRetireButton(currentUnit.retired);
|
|
document.getElementById('note').value = currentUnit.note || '';
|
|
const allocatedChecked = currentUnit.allocated || false;
|
|
document.getElementById('allocated').checked = allocatedChecked;
|
|
document.getElementById('allocatedToProjectId').value = currentUnit.allocated_to_project_id || '';
|
|
document.getElementById('allocatedProjectRow').style.display = allocatedChecked ? '' : 'none';
|
|
|
|
// Seismograph fields
|
|
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
|
// 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');
|
|
const modemPickerSearch = document.getElementById('modem-picker-search-detail-seismo');
|
|
const modemPickerClear = document.getElementById('modem-picker-clear-detail-seismo');
|
|
if (currentUnit.deployed_with_modem_id) {
|
|
// Fetch modem display info (handles both modem ID and IP address lookups)
|
|
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
|
// Update the hidden value with the actual modem ID (in case it was stored as IP)
|
|
if (modemPickerValue) modemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
|
|
if (modemPickerSearch) modemPickerSearch.value = result.display;
|
|
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
|
|
});
|
|
} else {
|
|
if (modemPickerValue) modemPickerValue.value = '';
|
|
if (modemPickerSearch) modemPickerSearch.value = '';
|
|
if (modemPickerClear) modemPickerClear.classList.add('hidden');
|
|
}
|
|
|
|
// Modem fields
|
|
document.getElementById('ipAddress').value = currentUnit.ip_address || '';
|
|
document.getElementById('phoneNumber').value = currentUnit.phone_number || '';
|
|
document.getElementById('hardwareModel').value = currentUnit.hardware_model || '';
|
|
|
|
// Sound Level Meter fields
|
|
document.getElementById('slmTcpPort').value = currentUnit.slm_tcp_port || '';
|
|
document.getElementById('slmModel').value = currentUnit.slm_model || '';
|
|
document.getElementById('slmSerialNumber').value = currentUnit.slm_serial_number || '';
|
|
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
|
|
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
|
|
document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || '';
|
|
|
|
// Populate modem picker for SLM (uses -detail-slm suffix)
|
|
const slmModemPickerValue = document.getElementById('modem-picker-value-detail-slm');
|
|
const slmModemPickerSearch = document.getElementById('modem-picker-search-detail-slm');
|
|
const slmModemPickerClear = document.getElementById('modem-picker-clear-detail-slm');
|
|
if (currentUnit.deployed_with_modem_id) {
|
|
// Fetch modem display info (handles both modem ID and IP address lookups)
|
|
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
|
// Update the hidden value with the actual modem ID (in case it was stored as IP)
|
|
if (slmModemPickerValue) slmModemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
|
|
if (slmModemPickerSearch) slmModemPickerSearch.value = result.display;
|
|
if (slmModemPickerClear) slmModemPickerClear.classList.remove('hidden');
|
|
});
|
|
} else {
|
|
if (slmModemPickerValue) slmModemPickerValue.value = '';
|
|
if (slmModemPickerSearch) slmModemPickerSearch.value = '';
|
|
if (slmModemPickerClear) slmModemPickerClear.classList.add('hidden');
|
|
}
|
|
|
|
// Show/hide fields based on device type
|
|
toggleDetailFields();
|
|
|
|
// Check for paired device and show cascade section if applicable
|
|
checkAndShowCascadeSection();
|
|
}
|
|
|
|
// Check for paired device and show/hide cascade section
|
|
async function checkAndShowCascadeSection() {
|
|
const cascadeSection = document.getElementById('detailCascadeSection');
|
|
const cascadeToUnitId = document.getElementById('detailCascadeToUnitId');
|
|
const pairedDeviceName = document.getElementById('detailPairedDeviceName');
|
|
|
|
if (!cascadeSection) return;
|
|
|
|
// Reset cascade section
|
|
cascadeSection.classList.add('hidden');
|
|
if (cascadeToUnitId) cascadeToUnitId.value = '';
|
|
if (pairedDeviceName) pairedDeviceName.textContent = '';
|
|
|
|
// Reset checkboxes
|
|
['detailCascadeDeployed', 'detailCascadeRetired', 'detailCascadeProject',
|
|
'detailCascadeLocation', 'detailCascadeCoordinates', 'detailCascadeNote'].forEach(id => {
|
|
const checkbox = document.getElementById(id);
|
|
if (checkbox) checkbox.checked = false;
|
|
});
|
|
|
|
let pairedUnitId = null;
|
|
|
|
// Check based on device type
|
|
if (currentUnit.device_type === 'modem' && currentUnit.deployed_with_unit_id) {
|
|
// Modem is paired with a seismograph or SLM
|
|
pairedUnitId = currentUnit.deployed_with_unit_id;
|
|
} else if ((currentUnit.device_type === 'seismograph' || currentUnit.device_type === 'slm') && currentUnit.deployed_with_modem_id) {
|
|
// Seismograph or SLM is paired with a modem
|
|
pairedUnitId = currentUnit.deployed_with_modem_id;
|
|
}
|
|
|
|
if (pairedUnitId) {
|
|
// Show cascade section
|
|
cascadeSection.classList.remove('hidden');
|
|
if (cascadeToUnitId) cascadeToUnitId.value = pairedUnitId;
|
|
if (pairedDeviceName) pairedDeviceName.textContent = pairedUnitId;
|
|
}
|
|
}
|
|
|
|
// Toggle device-specific fields
|
|
function toggleDetailFields() {
|
|
const deviceType = document.getElementById('deviceType').value;
|
|
const seismoFields = document.getElementById('seismographFields');
|
|
const modemFields = document.getElementById('modemFields');
|
|
const slmFields = document.getElementById('slmFields');
|
|
|
|
// Hide all device-specific fields first
|
|
seismoFields.classList.add('hidden');
|
|
modemFields.classList.add('hidden');
|
|
slmFields.classList.add('hidden');
|
|
|
|
// Show the relevant fields
|
|
if (deviceType === 'seismograph') {
|
|
seismoFields.classList.remove('hidden');
|
|
} else if (deviceType === 'modem') {
|
|
modemFields.classList.remove('hidden');
|
|
} else if (deviceType === 'slm') {
|
|
slmFields.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// Enter edit mode
|
|
function enterEditMode() {
|
|
document.getElementById('viewMode').classList.add('hidden');
|
|
document.getElementById('editMode').classList.remove('hidden');
|
|
document.getElementById('editButton').classList.add('hidden');
|
|
}
|
|
|
|
// Cancel edit mode
|
|
function cancelEdit() {
|
|
document.getElementById('editMode').classList.add('hidden');
|
|
document.getElementById('viewMode').classList.remove('hidden');
|
|
document.getElementById('editButton').classList.remove('hidden');
|
|
|
|
// Reset form to current values
|
|
populateEditForm();
|
|
}
|
|
|
|
function updateRetireButton(isRetired) {
|
|
const btn = document.getElementById('retireBtn');
|
|
if (!btn) return;
|
|
if (isRetired) {
|
|
btn.textContent = 'Un-Retire Unit';
|
|
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600';
|
|
} else {
|
|
btn.textContent = 'Retire Unit';
|
|
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40';
|
|
}
|
|
}
|
|
|
|
function toggleRetired() {
|
|
const hiddenInput = document.getElementById('retired');
|
|
const isCurrentlyRetired = hiddenInput.value === 'true';
|
|
hiddenInput.value = isCurrentlyRetired ? '' : 'true';
|
|
updateRetireButton(!isCurrentlyRetired);
|
|
}
|
|
|
|
// Handle form submission
|
|
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(this);
|
|
const deviceType = formData.get('device_type');
|
|
|
|
// Fix: FormData contains both modem picker hidden inputs (seismo and slm).
|
|
// We need to ensure only the correct one is submitted based on device type.
|
|
// Delete all deployed_with_modem_id entries and re-add the correct one.
|
|
const modemId = getCorrectModemPickerValue(deviceType);
|
|
formData.delete('deployed_with_modem_id');
|
|
if (modemId) {
|
|
formData.append('deployed_with_modem_id', modemId);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('Unit updated successfully!');
|
|
// Reload data and return to view mode
|
|
await loadUnitData();
|
|
cancelEdit();
|
|
} else {
|
|
const result = await response.json();
|
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
});
|
|
|
|
// Get the correct modem picker value based on device type
|
|
function getCorrectModemPickerValue(deviceType) {
|
|
if (deviceType === 'seismograph') {
|
|
const picker = document.getElementById('modem-picker-value-detail-seismo');
|
|
return picker ? picker.value : '';
|
|
} else if (deviceType === 'slm') {
|
|
const picker = document.getElementById('modem-picker-value-detail-slm');
|
|
return picker ? picker.value : '';
|
|
}
|
|
// Modems don't have a deployed_with_modem_id
|
|
return '';
|
|
}
|
|
|
|
// Delete unit
|
|
async function deleteUnit() {
|
|
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/roster/${unitId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('Unit deleted successfully');
|
|
window.location.href = '/roster';
|
|
} else {
|
|
const result = await response.json();
|
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Initialize unit location map (only called once)
|
|
function initUnitMap() {
|
|
if (!currentUnit.coordinates) {
|
|
document.getElementById('mapCard').classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
const coords = parseLocation(currentUnit.coordinates);
|
|
if (!coords) {
|
|
document.getElementById('mapCard').classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
const [lat, lon] = coords;
|
|
|
|
// Show the map card
|
|
document.getElementById('mapCard').classList.remove('hidden');
|
|
|
|
// Only initialize map if it doesn't exist
|
|
if (!unitMap) {
|
|
// Initialize map
|
|
unitMap = L.map('unit-map').setView([lat, lon], 13);
|
|
|
|
// Add OpenStreetMap tiles
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 18
|
|
}).addTo(unitMap);
|
|
|
|
// Force map to update its size
|
|
setTimeout(() => {
|
|
unitMap.invalidateSize();
|
|
}, 100);
|
|
}
|
|
|
|
// Update marker (can be called multiple times)
|
|
updateMapMarker(lat, lon);
|
|
|
|
// Update location text — prefer the assignment's location name, fall
|
|
// back to address, then coordinates.
|
|
const locationParts = [];
|
|
const loc = currentUnit.active_location;
|
|
if (loc && loc.name) {
|
|
locationParts.push(loc.name);
|
|
}
|
|
if (currentUnit.address) {
|
|
locationParts.push(currentUnit.address);
|
|
}
|
|
locationParts.push(`Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}`);
|
|
document.getElementById('locationText').textContent = locationParts.join(' • ');
|
|
}
|
|
|
|
// Update map marker with current status
|
|
function updateMapMarker(lat, lon) {
|
|
// Remove old marker if it exists
|
|
if (mapMarker) {
|
|
mapMarker.remove();
|
|
}
|
|
|
|
// Get status color
|
|
let statusColor = 'gray';
|
|
let status = 'Unknown';
|
|
if (currentSnapshot && currentSnapshot.units && currentSnapshot.units[unitId]) {
|
|
const unitStatus = currentSnapshot.units[unitId];
|
|
status = unitStatus.status || 'Unknown';
|
|
statusColor = status === 'OK' ? 'green' : status === 'Pending' ? 'orange' : status === 'Missing' ? 'red' : 'gray';
|
|
}
|
|
|
|
// Add new marker
|
|
mapMarker = L.circleMarker([lat, lon], {
|
|
radius: 10,
|
|
fillColor: statusColor,
|
|
color: '#fff',
|
|
weight: 3,
|
|
opacity: 1,
|
|
fillOpacity: 0.8
|
|
}).addTo(unitMap).bindPopup(`
|
|
<div class="p-2">
|
|
<h3 class="font-bold text-lg">${currentUnit.id}</h3>
|
|
<p class="text-sm">Status: <span style="color: ${statusColor}">${status}</span></p>
|
|
<p class="text-sm">Type: ${currentUnit.device_type}</p>
|
|
</div>
|
|
`).openPopup();
|
|
}
|
|
|
|
function parseLocation(location) {
|
|
if (!location) return null;
|
|
|
|
// Try to parse as "lat,lon" format
|
|
const parts = location.split(',').map(s => s.trim());
|
|
if (parts.length === 2) {
|
|
const lat = parseFloat(parts[0]);
|
|
const lon = parseFloat(parts[1]);
|
|
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
|
return [lat, lon];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Load and display photos
|
|
async function loadPhotos() {
|
|
try {
|
|
const response = await fetch(`/api/unit/${unitId}/photos`);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load photos');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const gallery = document.getElementById('photoGallery');
|
|
|
|
if (data.photos && data.photos.length > 0) {
|
|
gallery.innerHTML = '';
|
|
data.photo_urls.forEach((url, index) => {
|
|
const photoDiv = document.createElement('div');
|
|
photoDiv.className = 'relative group';
|
|
photoDiv.innerHTML = `
|
|
<img src="${url}" alt="Unit photo ${index + 1}"
|
|
class="w-full h-48 object-cover rounded-lg shadow cursor-pointer hover:shadow-lg transition-shadow"
|
|
onclick="window.open('${url}', '_blank')">
|
|
${index === 0 ? '<span class="absolute top-2 left-2 bg-seismo-orange text-white text-xs px-2 py-1 rounded">Primary</span>' : ''}
|
|
`;
|
|
gallery.appendChild(photoDiv);
|
|
});
|
|
} else {
|
|
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos yet. Add a photo to get started.</p>';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading photos:', error);
|
|
document.getElementById('photoGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load photos</p>';
|
|
}
|
|
}
|
|
|
|
// Upload photo with EXIF metadata extraction
|
|
async function uploadPhoto(file) {
|
|
if (!file) return;
|
|
|
|
const statusDiv = document.getElementById('uploadStatus');
|
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200';
|
|
statusDiv.textContent = 'Uploading photo and extracting metadata...';
|
|
statusDiv.classList.remove('hidden');
|
|
|
|
const formData = new FormData();
|
|
formData.append('photo', file);
|
|
|
|
try {
|
|
const response = await fetch(`/api/unit/${unitId}/upload-photo`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Upload failed');
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Show success message with metadata info. Location is on the
|
|
// assignment's MonitoringLocation now, so we just surface what GPS
|
|
// came in — the backend no longer mutates the unit row.
|
|
let message = 'Photo uploaded successfully!';
|
|
if (result.metadata && result.metadata.coordinates) {
|
|
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
|
} else {
|
|
message += ' No GPS data found in photo.';
|
|
}
|
|
|
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
|
statusDiv.textContent = message;
|
|
|
|
// Reload photos
|
|
await loadPhotos();
|
|
|
|
// Hide status after 5 seconds
|
|
setTimeout(() => {
|
|
statusDiv.classList.add('hidden');
|
|
}, 5000);
|
|
|
|
// Reset both file inputs
|
|
document.getElementById('photoCameraUpload').value = '';
|
|
document.getElementById('photoLibraryUpload').value = '';
|
|
|
|
} catch (error) {
|
|
console.error('Error uploading photo:', error);
|
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
|
statusDiv.textContent = `Error uploading photo: ${error.message}`;
|
|
|
|
// Hide error after 5 seconds
|
|
setTimeout(() => {
|
|
statusDiv.classList.add('hidden');
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
// Legacy timeline loader — Phase 4 unified the timeline view. Now a shim
|
|
// that delegates to loadDeploymentTimeline() so existing callers from modal
|
|
// save handlers still trigger a refresh of the visible section.
|
|
async function loadUnitHistory() {
|
|
if (typeof loadDeploymentTimeline === 'function') {
|
|
return loadDeploymentTimeline();
|
|
}
|
|
}
|
|
async function _legacy_loadUnitHistory_unused() {
|
|
try {
|
|
const response = await fetch(`/api/roster/history/${unitId}`);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load history');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const timeline = document.getElementById('historyTimeline');
|
|
|
|
if (data.history && data.history.length > 0) {
|
|
timeline.innerHTML = '';
|
|
data.history.forEach(entry => {
|
|
const timelineEntry = createTimelineEntry(entry);
|
|
timeline.appendChild(timelineEntry);
|
|
});
|
|
} else {
|
|
timeline.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No history yet. Changes will appear here.</p>';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading history:', error);
|
|
document.getElementById('historyTimeline').innerHTML = '<p class="text-sm text-red-500">Failed to load history</p>';
|
|
}
|
|
}
|
|
|
|
// Create a timeline entry element
|
|
function createTimelineEntry(entry) {
|
|
const div = document.createElement('div');
|
|
div.className = 'flex gap-3 p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50';
|
|
|
|
// Icon based on change type
|
|
const icons = {
|
|
'note_change': `<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
|
</svg>`,
|
|
'deployed_change': `<svg class="w-5 h-5 text-green-500" 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>`,
|
|
'retired_change': `<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>`
|
|
};
|
|
|
|
const icon = icons[entry.change_type] || `<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>`;
|
|
|
|
// Format change description
|
|
let description = '';
|
|
if (entry.change_type === 'note_change') {
|
|
description = `<strong>Note changed</strong>`;
|
|
if (entry.old_value) {
|
|
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">From: "${entry.old_value}"</span>`;
|
|
}
|
|
if (entry.new_value) {
|
|
description += `<br><span class="text-xs text-gray-600 dark:text-gray-300">To: "${entry.new_value}"</span>`;
|
|
}
|
|
} else if (entry.change_type === 'deployed_change') {
|
|
description = `<strong>Status changed to ${entry.new_value}</strong>`;
|
|
} else if (entry.change_type === 'retired_change') {
|
|
description = `<strong>Marked as ${entry.new_value}</strong>`;
|
|
} else {
|
|
description = `<strong>${entry.field_name} changed</strong>`;
|
|
if (entry.old_value && entry.new_value) {
|
|
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">${entry.old_value} → ${entry.new_value}</span>`;
|
|
}
|
|
}
|
|
|
|
// Format timestamp
|
|
const timestamp = new Date(entry.changed_at).toLocaleString();
|
|
|
|
div.innerHTML = `
|
|
<div class="flex-shrink-0">
|
|
${icon}
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm text-gray-900 dark:text-white">
|
|
${description}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
${timestamp}
|
|
${entry.source !== 'manual' ? `<span class="ml-2 px-2 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">${entry.source}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="flex-shrink-0">
|
|
<button onclick="deleteHistoryEntry(${entry.id})" class="text-gray-400 hover:text-red-500 transition-colors" title="Delete this history entry">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
return div;
|
|
}
|
|
|
|
// Delete a history entry
|
|
async function deleteHistoryEntry(historyId) {
|
|
if (!confirm('Are you sure you want to delete this history entry?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/roster/history/${historyId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Reload history
|
|
await loadUnitHistory();
|
|
} else {
|
|
const result = await response.json();
|
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Load paired device info for modems
|
|
async function loadPairedDevice() {
|
|
try {
|
|
const response = await fetch(`/api/modem-dashboard/${unitId}/paired-device-html`);
|
|
if (response.ok) {
|
|
const html = await response.text();
|
|
document.getElementById('pairedDeviceInfo').innerHTML = html;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading paired device:', error);
|
|
document.getElementById('pairedDeviceInfo').innerHTML = '<p class="text-red-500 text-sm">Failed to load paired device info</p>';
|
|
}
|
|
}
|
|
|
|
// Ping modem and show result
|
|
async function pingModem() {
|
|
const btn = document.getElementById('modemPingBtn');
|
|
const resultSpan = document.getElementById('modemPingResult');
|
|
|
|
// Show loading state
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = `
|
|
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
</svg>
|
|
Pinging...
|
|
`;
|
|
btn.disabled = true;
|
|
resultSpan.textContent = 'Testing connection...';
|
|
resultSpan.className = 'text-sm text-gray-500 dark:text-gray-400';
|
|
|
|
try {
|
|
const response = await fetch(`/api/modem-dashboard/${unitId}/ping`);
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
|
|
resultSpan.className = 'text-sm text-green-600 dark:text-green-400';
|
|
} else {
|
|
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
|
|
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
|
}
|
|
} catch (error) {
|
|
resultSpan.textContent = 'Error: ' + error.message;
|
|
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
|
}
|
|
|
|
// Restore button
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
|
|
// ============================================================
|
|
// Deployment History (legacy — Phase 4 superseded by deployment_timeline)
|
|
// ============================================================
|
|
|
|
// Phase 4 shim: delegate to the unified timeline loader so existing modal
|
|
// save handlers (legacy "Log Deployment" form, edit-save callbacks) still
|
|
// trigger a refresh of the visible Deployment Timeline section.
|
|
async function loadDeploymentHistory() {
|
|
if (typeof loadDeploymentTimeline === 'function') {
|
|
return loadDeploymentTimeline();
|
|
}
|
|
}
|
|
async function _legacy_loadDeploymentHistory_unused() {
|
|
try {
|
|
const res = await fetch(`/api/deployments/${unitId}`);
|
|
const data = await res.json();
|
|
const container = document.getElementById('deploymentHistory');
|
|
const deployments = data.deployments || [];
|
|
|
|
if (deployments.length === 0) {
|
|
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment records yet.</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '';
|
|
deployments.forEach(d => {
|
|
container.appendChild(createDeploymentRow(d));
|
|
});
|
|
} catch (e) {
|
|
document.getElementById('deploymentHistory').innerHTML =
|
|
'<p class="text-sm text-red-500">Failed to load deployment history.</p>';
|
|
}
|
|
}
|
|
|
|
function formatDateDisplay(iso) {
|
|
if (!iso) return '—';
|
|
const [y, m, d] = iso.split('-');
|
|
return `${parseInt(m)}/${parseInt(d)}/${y}`;
|
|
}
|
|
|
|
function createDeploymentRow(d) {
|
|
const div = document.createElement('div');
|
|
div.className = 'flex items-start gap-3 p-3 rounded-lg ' +
|
|
(d.is_active
|
|
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
|
: 'bg-gray-50 dark:bg-slate-700/50');
|
|
|
|
const statusDot = d.is_active
|
|
? '<span class="mt-1 flex-shrink-0 w-2.5 h-2.5 rounded-full bg-green-500"></span>'
|
|
: '<span class="mt-1 flex-shrink-0 w-2.5 h-2.5 rounded-full bg-gray-400 dark:bg-gray-500"></span>';
|
|
|
|
const jobLabel = d.project_ref || d.project_id || 'Unspecified job';
|
|
const locLabel = d.location_name ? `<span class="text-gray-500 dark:text-gray-400"> · ${d.location_name}</span>` : '';
|
|
|
|
const deployedStr = formatDateDisplay(d.deployed_date);
|
|
const estStr = d.estimated_removal_date ? formatDateDisplay(d.estimated_removal_date) : 'TBD';
|
|
const actualStr = d.actual_removal_date ? formatDateDisplay(d.actual_removal_date) : null;
|
|
|
|
const dateRange = actualStr
|
|
? `${deployedStr} → ${actualStr}`
|
|
: `${deployedStr} → <span class="font-medium ${d.is_active ? 'text-green-700 dark:text-green-400' : 'text-gray-600 dark:text-gray-300'}">Est. ${estStr}</span>`;
|
|
|
|
const activeTag = d.is_active
|
|
? '<span class="ml-2 px-1.5 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-400 rounded">In Field</span>'
|
|
: '';
|
|
|
|
div.innerHTML = `
|
|
${statusDot}
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
|
${jobLabel}${activeTag}${locLabel}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${dateRange}</div>
|
|
${d.notes ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 italic">${d.notes}</div>` : ''}
|
|
</div>
|
|
<div class="flex gap-1 flex-shrink-0">
|
|
<button onclick="openEditDeploymentModal(${JSON.stringify(d).replace(/"/g, '"')})"
|
|
class="p-1.5 text-gray-400 hover:text-seismo-orange rounded transition-colors" title="Edit">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
|
</svg>
|
|
</button>
|
|
<button onclick="deleteDeployment('${d.id}')"
|
|
class="p-1.5 text-gray-400 hover:text-red-500 rounded transition-colors" title="Delete">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
function openNewDeploymentModal() {
|
|
document.getElementById('deploymentModalTitle').textContent = 'Log Deployment';
|
|
document.getElementById('deploymentModalId').value = '';
|
|
document.getElementById('deploymentDeployedDate').value = '';
|
|
document.getElementById('deploymentEstRemovalDate').value = '';
|
|
document.getElementById('deploymentActualRemovalDate').value = '';
|
|
document.getElementById('deploymentProjectRef').value = '';
|
|
document.getElementById('deploymentLocationName').value = '';
|
|
document.getElementById('deploymentNotes').value = '';
|
|
document.getElementById('deploymentModal').classList.remove('hidden');
|
|
}
|
|
|
|
function openEditDeploymentModal(d) {
|
|
document.getElementById('deploymentModalTitle').textContent = 'Edit Deployment';
|
|
document.getElementById('deploymentModalId').value = d.id;
|
|
document.getElementById('deploymentDeployedDate').value = d.deployed_date || '';
|
|
document.getElementById('deploymentEstRemovalDate').value = d.estimated_removal_date || '';
|
|
document.getElementById('deploymentActualRemovalDate').value = d.actual_removal_date || '';
|
|
document.getElementById('deploymentProjectRef').value = d.project_ref || '';
|
|
document.getElementById('deploymentLocationName').value = d.location_name || '';
|
|
document.getElementById('deploymentNotes').value = d.notes || '';
|
|
document.getElementById('deploymentModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeDeploymentModal() {
|
|
document.getElementById('deploymentModal').classList.add('hidden');
|
|
}
|
|
|
|
async function saveDeployment() {
|
|
const id = document.getElementById('deploymentModalId').value;
|
|
const payload = {
|
|
deployed_date: document.getElementById('deploymentDeployedDate').value || null,
|
|
estimated_removal_date: document.getElementById('deploymentEstRemovalDate').value || null,
|
|
actual_removal_date: document.getElementById('deploymentActualRemovalDate').value || null,
|
|
project_ref: document.getElementById('deploymentProjectRef').value || null,
|
|
location_name: document.getElementById('deploymentLocationName').value || null,
|
|
notes: document.getElementById('deploymentNotes').value || null,
|
|
};
|
|
|
|
try {
|
|
let res;
|
|
if (id) {
|
|
res = await fetch(`/api/deployments/${unitId}/${id}`, {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
} else {
|
|
res = await fetch(`/api/deployments/${unitId}`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
if (!res.ok) throw new Error(await res.text());
|
|
closeDeploymentModal();
|
|
loadDeploymentHistory();
|
|
} catch (e) {
|
|
alert('Failed to save deployment: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function deleteDeployment(deploymentId) {
|
|
if (!confirm('Delete this deployment record?')) return;
|
|
try {
|
|
const res = await fetch(`/api/deployments/${unitId}/${deploymentId}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
loadDeploymentHistory();
|
|
} catch (e) {
|
|
alert('Failed to delete: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Load data when page loads
|
|
loadCalibrationInterval();
|
|
setupCalibrationAutoCalc();
|
|
loadUnitData().then(() => {
|
|
loadPhotos();
|
|
loadDeploymentTimeline();
|
|
if (currentUnit && currentUnit.device_type === 'seismograph') {
|
|
document.getElementById('sfmEventsSection').classList.remove('hidden');
|
|
loadUnitEvents();
|
|
}
|
|
});
|
|
|
|
// ── Unified Deployment Timeline (Phase 4) ────────────────────────────────────
|
|
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
|
|
// Derives entries from unit_assignments + unit_history + SFM event overlay.
|
|
|
|
// Cache the most recent timeline payload so the merge action can look up
|
|
// which assignment_ids belong together in a mergeable group.
|
|
let _dtCurrentTimeline = { entries: [], merge_groups: [] };
|
|
|
|
async function loadDeploymentTimeline() {
|
|
const container = document.getElementById('deploymentTimeline');
|
|
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
|
|
|
|
try {
|
|
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const d = await r.json();
|
|
_dtCurrentTimeline = {
|
|
entries: d.entries || [],
|
|
merge_groups: d.merge_groups || [],
|
|
};
|
|
renderDeploymentTimeline(_dtCurrentTimeline.entries, container, _dtCurrentTimeline.merge_groups);
|
|
} catch (e) {
|
|
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
|
|
}
|
|
}
|
|
|
|
// Returns the merge_group (list of assignment_ids) that this assignment is
|
|
// part of, or null if it isn't in any mergeable group.
|
|
function _dtFindMergeGroup(assignmentId) {
|
|
for (const group of _dtCurrentTimeline.merge_groups || []) {
|
|
if (group.includes(assignmentId)) return group;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function mergeAssignmentGroup(assignmentIds) {
|
|
if (!Array.isArray(assignmentIds) || assignmentIds.length < 2) return;
|
|
const msg = `Merge ${assignmentIds.length} consecutive assignment records into one?\n\n`
|
|
+ `The earliest record is kept and its window extended to span all `
|
|
+ `of them. The other ${assignmentIds.length - 1} record(s) are deleted.\n\n`
|
|
+ `Original metadata (notes + ingest source) is preserved. This is `
|
|
+ `logged to the unit's history as "assignment_merged".`;
|
|
if (!confirm(msg)) return;
|
|
|
|
try {
|
|
// All assignments share the same project_id (validated server-side).
|
|
// Pick the first entry's project_id from the cache.
|
|
const first = (_dtCurrentTimeline.entries || []).find(e =>
|
|
e.kind === 'assignment' && assignmentIds.includes(e.assignment_id)
|
|
);
|
|
const projectId = first ? first.project_id : null;
|
|
if (!projectId) throw new Error('Could not resolve project id for this group');
|
|
|
|
const r = await fetch(`/api/projects/${projectId}/assignments/merge`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ assignment_ids: assignmentIds }),
|
|
});
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
|
throw new Error(err.detail || 'HTTP ' + r.status);
|
|
}
|
|
await loadDeploymentTimeline();
|
|
} catch (e) {
|
|
alert(e.message || 'Failed to merge assignments.');
|
|
}
|
|
}
|
|
|
|
function _dtFmtDate(iso) {
|
|
if (!iso) return '—';
|
|
return iso.slice(0, 10);
|
|
}
|
|
|
|
function _dtFmtDateTime(iso) {
|
|
if (!iso) return '—';
|
|
return iso.slice(0, 19).replace('T', ' ');
|
|
}
|
|
|
|
function _dtEsc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function _dtPpvClass(v) {
|
|
if (v == null) return 'text-gray-400';
|
|
if (v < 0.5) return 'text-green-600 dark:text-green-400';
|
|
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
|
|
return 'text-red-600 dark:text-red-400 font-semibold';
|
|
}
|
|
|
|
function _dtRenderAssignment(e) {
|
|
const start = _dtFmtDate(e.starts_at);
|
|
const end = e.is_active ? 'present' : _dtFmtDate(e.ends_at);
|
|
const dur = (e.duration_days != null)
|
|
? `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">(${e.duration_days.toFixed(1)} day${e.duration_days === 1 ? '' : 's'})</span>`
|
|
: '';
|
|
const ov = e.event_overlay || {};
|
|
const evCount = ov.event_count ?? 0;
|
|
const peak = ov.peak_pvs;
|
|
|
|
const locLink = e.location_id
|
|
? `<a href="/projects/${_dtEsc(e.project_id)}/nrl/${_dtEsc(e.location_id)}" class="text-seismo-orange hover:text-seismo-navy font-medium">📍 ${_dtEsc(e.location_name || 'unnamed location')}</a>`
|
|
: `<span class="text-gray-500 dark:text-gray-400 italic">📍 (no location FK — synthesized from legacy deployment_records)</span>`;
|
|
|
|
const projLine = e.project_name
|
|
? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${_dtEsc(e.project_name)}</div>`
|
|
: '';
|
|
|
|
const activeBadge = e.is_active
|
|
? '<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
|
: '';
|
|
|
|
// If this assignment belongs to a mergeable group, show a small
|
|
// indicator badge — the group-level "Merge" action lives in the
|
|
// banner at the top of the section to avoid N redundant buttons.
|
|
const mergeGroup = _dtFindMergeGroup(e.assignment_id);
|
|
const mergeableBadge = mergeGroup
|
|
? `<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
|
title="This row is part of a ${mergeGroup.length}-record consecutive group at the same location — see the Merge banner above to combine them.">
|
|
mergeable
|
|
</span>`
|
|
: '';
|
|
|
|
const overlay = evCount > 0
|
|
? `<div class="mt-2 flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
|
|
<span><strong class="text-gray-900 dark:text-white">${evCount.toLocaleString()}</strong> event${evCount === 1 ? '' : 's'}</span>
|
|
${peak != null ? `<span>peak <strong class="${_dtPpvClass(peak)}">${peak.toFixed(4)} in/s</strong></span>` : ''}
|
|
${ov.last_event ? `<span>last ${_dtFmtDateTime(ov.last_event)}</span>` : ''}
|
|
</div>`
|
|
: `<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">No events recorded during this window.</div>`;
|
|
|
|
const notes = e.notes
|
|
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
|
: '';
|
|
|
|
// Edit/delete actions live on the right of the date row. Only shown
|
|
// for assignment entries with a real assignment_id (synthesized legacy
|
|
// entries without one are read-only).
|
|
const actionButtons = e.assignment_id
|
|
? `<button type="button" onclick='openEditAssignmentModal(${JSON.stringify(e.assignment_id)})'
|
|
class="text-xs text-gray-500 hover:text-seismo-orange p-1 rounded" title="Edit dates / notes">
|
|
✏️
|
|
</button>`
|
|
: '';
|
|
|
|
return `<div class="flex gap-3 transition-shadow rounded-lg" data-assignment-row="${_dtEsc(e.assignment_id)}">
|
|
<div class="flex flex-col items-center pt-1">
|
|
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
|
|
</div>
|
|
<div class="flex-1 bg-gray-50 dark:bg-slate-900/40 rounded-lg p-3">
|
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
|
<div class="text-sm text-gray-700 dark:text-gray-300">
|
|
<strong>${start}</strong> → <strong>${end}</strong>${dur}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
${mergeableBadge}
|
|
${activeBadge}
|
|
${actionButtons}
|
|
</div>
|
|
</div>
|
|
<div class="mt-1">${locLink}</div>
|
|
${projLine}
|
|
${overlay}
|
|
${notes}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function _dtRenderGap(e) {
|
|
return `<div class="flex gap-3">
|
|
<div class="flex flex-col items-center pt-1">
|
|
<span class="w-3 h-3 rounded-full border-2 border-gray-400 dark:border-gray-500"></span>
|
|
</div>
|
|
<div class="flex-1 bg-gray-50/40 dark:bg-slate-900/20 rounded-lg p-3 border border-dashed border-gray-300 dark:border-gray-700">
|
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
<strong>${_dtFmtDate(e.starts_at)}</strong> → <strong>${_dtFmtDate(e.ends_at)}</strong>
|
|
<span class="text-xs ml-2">(${e.duration_days.toFixed(1)} day${e.duration_days === 1 ? '' : 's'} idle)</span>
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No active assignment</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function _dtRenderStateChange(e) {
|
|
// Friendly labels for known change_types.
|
|
const labels = {
|
|
deployed_change: 'Deployed status changed',
|
|
retired_change: 'Retired status changed',
|
|
calibration_status_change: 'Calibration status changed',
|
|
last_calibrated_change: 'Last calibrated updated',
|
|
next_calibration_due_change: 'Next calibration due updated',
|
|
allocation_change: 'Allocation changed',
|
|
};
|
|
const label = labels[e.change_type] || e.change_type;
|
|
|
|
return `<div class="flex gap-3">
|
|
<div class="flex flex-col items-center pt-1">
|
|
<span class="w-3 h-3 rounded-full bg-seismo-navy"></span>
|
|
</div>
|
|
<div class="flex-1 bg-gray-50 dark:bg-slate-900/30 rounded-lg p-3">
|
|
<div class="text-sm text-gray-700 dark:text-gray-300">
|
|
📅 <strong>${_dtFmtDateTime(e.starts_at)}</strong> — ${_dtEsc(label)}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
${_dtEsc(e.old_value || '—')} → <strong>${_dtEsc(e.new_value || '—')}</strong>
|
|
</div>
|
|
${e.history_notes ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">${_dtEsc(e.history_notes)}</div>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Gantt chart ─────────────────────────────────────────────────────────────
|
|
// Renders all assignment windows as colored horizontal bars on an SVG
|
|
// timeline. Click a bar to scroll its detail row into view in the list
|
|
// below. Color per location, opacity reduced for closed assignments.
|
|
// "Mergeable" groups get a unifying outline overlay so they're visible at
|
|
// a glance as one logical deployment.
|
|
const _ganttColorPalette = [
|
|
'#f48b1c', '#142a66', '#7d234d', '#0e7490', '#15803d', '#a16207',
|
|
'#9333ea', '#dc2626', '#0d9488', '#1d4ed8', '#be185d', '#65a30d',
|
|
];
|
|
function _ganttColorFor(locId, locColorMap) {
|
|
if (locColorMap[locId]) return locColorMap[locId];
|
|
const idx = Object.keys(locColorMap).length % _ganttColorPalette.length;
|
|
locColorMap[locId] = _ganttColorPalette[idx];
|
|
return locColorMap[locId];
|
|
}
|
|
|
|
function _ganttParseDate(iso) {
|
|
if (!iso) return null;
|
|
const d = new Date(iso.replace(' ', 'T'));
|
|
return isNaN(d.getTime()) ? null : d;
|
|
}
|
|
|
|
function _ganttFmtMonth(d) {
|
|
return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
|
}
|
|
|
|
function renderDeploymentGantt(entries, mergeGroups) {
|
|
const wrapper = document.getElementById('deploymentGantt');
|
|
const svg = document.getElementById('deploymentGanttSvg');
|
|
const legend = document.getElementById('deploymentGanttLegend');
|
|
if (!wrapper || !svg) return;
|
|
|
|
const assignments = (entries || []).filter(e => e.kind === 'assignment' && e.starts_at);
|
|
if (assignments.length === 0) {
|
|
wrapper.classList.add('hidden');
|
|
return;
|
|
}
|
|
wrapper.classList.remove('hidden');
|
|
|
|
// Compute time domain. Pad the end by a few days when an active
|
|
// assignment is present so the "active" bar doesn't reach the very
|
|
// edge of the chart.
|
|
const now = new Date();
|
|
let minDate = null, maxDate = null;
|
|
for (const a of assignments) {
|
|
const start = _ganttParseDate(a.starts_at);
|
|
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
|
|
if (start && (!minDate || start < minDate)) minDate = start;
|
|
if (end && (!maxDate || end > maxDate)) maxDate = end;
|
|
}
|
|
if (!minDate || !maxDate) { wrapper.classList.add('hidden'); return; }
|
|
// Tiny padding at both ends (3% of total span).
|
|
const span = maxDate - minDate;
|
|
const pad = Math.max(span * 0.03, 24 * 3600 * 1000); // at least 1 day
|
|
minDate = new Date(minDate.getTime() - pad);
|
|
maxDate = new Date(maxDate.getTime() + pad);
|
|
|
|
// Build a quick "which mergeGroup is this id in?" map.
|
|
const idToGroup = {};
|
|
(mergeGroups || []).forEach((g, idx) => g.forEach(id => { idToGroup[id] = idx; }));
|
|
|
|
// Compute SVG geometry.
|
|
const width = Math.max(svg.clientWidth || svg.parentElement.clientWidth || 800, 400);
|
|
const height = 140;
|
|
const padLeft = 8;
|
|
const padRight = 8;
|
|
const padTop = 32; // room for month labels above the bars
|
|
const padBottom = 18; // room for assignment-count axis below
|
|
const usableW = width - padLeft - padRight;
|
|
const usableH = height - padTop - padBottom;
|
|
const totalRange = maxDate - minDate;
|
|
const xFor = (d) => padLeft + (d - minDate) / totalRange * usableW;
|
|
|
|
// Choose one-row-per-bar OR stack overlapping bars. Since same-unit
|
|
// assignments rarely overlap (only via the brief unassign/reassign
|
|
// race), a single row is usually fine. But just in case, stack with
|
|
// simple top-down packing.
|
|
const lanes = []; // each lane = [{x1, x2, ...}, ...]
|
|
function placeInLane(start, end) {
|
|
for (let i = 0; i < lanes.length; i++) {
|
|
const last = lanes[i][lanes[i].length - 1];
|
|
if (last.x2 + 2 < start) {
|
|
lanes[i].push({ x1: start, x2: end });
|
|
return i;
|
|
}
|
|
}
|
|
lanes.push([{ x1: start, x2: end }]);
|
|
return lanes.length - 1;
|
|
}
|
|
const placed = assignments.map(a => {
|
|
const start = _ganttParseDate(a.starts_at);
|
|
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
|
|
const x1 = xFor(start);
|
|
const x2 = xFor(end);
|
|
const lane = placeInLane(x1, x2);
|
|
return { a, x1, x2, lane };
|
|
});
|
|
const laneCount = Math.max(lanes.length, 1);
|
|
const barH = Math.max(14, Math.min(28, Math.floor(usableH / laneCount) - 4));
|
|
const laneSpacing = barH + 4;
|
|
|
|
// Month gridlines + labels. Tick on the 1st of each month inside the
|
|
// domain. If span > 24mo, tick every 3 months instead.
|
|
const months = [];
|
|
let monthCursor = new Date(minDate.getFullYear(), minDate.getMonth(), 1);
|
|
const tickEveryMonths = (totalRange > 24 * 30 * 86400 * 1000) ? 3 : 1;
|
|
while (monthCursor <= maxDate) {
|
|
if (monthCursor >= minDate) months.push(new Date(monthCursor));
|
|
monthCursor.setMonth(monthCursor.getMonth() + tickEveryMonths);
|
|
}
|
|
|
|
// Build the SVG string.
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
|
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
|
const todayColor = '#f48b1c';
|
|
|
|
const locColorMap = {};
|
|
const usedLocs = {};
|
|
|
|
let parts = [];
|
|
// Month gridlines.
|
|
months.forEach(m => {
|
|
const x = xFor(m);
|
|
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
parts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${_ganttFmtMonth(m)}</text>`);
|
|
});
|
|
// Today marker.
|
|
if (now >= minDate && now <= maxDate) {
|
|
const x = xFor(now);
|
|
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.8"/>`);
|
|
parts.push(`<text x="${x + 3}" y="${height - padBottom + 12}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`);
|
|
}
|
|
|
|
// Bars.
|
|
placed.forEach(p => {
|
|
const a = p.a;
|
|
const color = _ganttColorFor(a.location_id || '_', locColorMap);
|
|
usedLocs[a.location_name || '(no location)'] = color;
|
|
const y = padTop + p.lane * laneSpacing;
|
|
const opacity = a.is_active ? 1.0 : 0.85;
|
|
const stroke = (a.source === 'metadata_backfill') ? '#3b82f6' : 'none';
|
|
const strokeWidth = (a.source === 'metadata_backfill') ? 2 : 0;
|
|
|
|
const barW = Math.max(p.x2 - p.x1, 3);
|
|
const tipDates = `${(a.starts_at || '').slice(0,10)} → ${a.is_active ? 'active' : (a.ends_at || '').slice(0,10)}`;
|
|
const tip = `${(a.location_name || '?').replace(/"/g, '"')} (${tipDates})${a.event_overlay && a.event_overlay.event_count ? ' • ' + a.event_overlay.event_count + ' events' : ''}`;
|
|
|
|
parts.push(`<g style="cursor: pointer;" onclick="_ganttScrollTo('${a.assignment_id}')">
|
|
<title>${tip}</title>
|
|
<rect x="${p.x1}" y="${y}" width="${barW}" height="${barH}" rx="3"
|
|
fill="${color}" opacity="${opacity}" stroke="${stroke}" stroke-width="${strokeWidth}"/>
|
|
${a.is_active ? `<circle cx="${p.x2 - 4}" cy="${y + barH / 2}" r="2.5" fill="#fff" opacity="0.9"/>` : ''}
|
|
</g>`);
|
|
|
|
// Mergeable highlight — thin dashed underline below the bar.
|
|
if (idToGroup[a.assignment_id] !== undefined) {
|
|
const uy = y + barH + 1;
|
|
parts.push(`<line x1="${p.x1}" y1="${uy}" x2="${p.x2}" y2="${uy}" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="2 2" opacity="0.7"/>`);
|
|
}
|
|
});
|
|
|
|
svg.innerHTML = parts.join('');
|
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
|
|
// Build legend (one swatch per distinct location).
|
|
const legendItems = Object.entries(usedLocs).map(([name, color]) =>
|
|
`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded" style="background:${color}"></span>${_dtEsc(name)}</span>`
|
|
);
|
|
if (mergeGroups && mergeGroups.length > 0) {
|
|
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 border-b-2 border-dashed border-blue-500"></span>mergeable group</span>`);
|
|
}
|
|
if (placed.some(p => p.a.source === 'metadata_backfill')) {
|
|
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded border-2 border-blue-500"></span>auto-backfilled</span>`);
|
|
}
|
|
legend.innerHTML = legendItems.join('');
|
|
}
|
|
|
|
// Click-on-bar handler. Just scroll the matching list row into view +
|
|
// briefly flash it so the eye finds it.
|
|
function _ganttScrollTo(assignmentId) {
|
|
const target = document.querySelector(`[data-assignment-row="${assignmentId}"]`);
|
|
if (!target) return;
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
target.classList.add('ring-2', 'ring-seismo-orange');
|
|
setTimeout(() => target.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
|
}
|
|
|
|
function renderDeploymentTimeline(entries, container, mergeGroups) {
|
|
if (!entries.length) {
|
|
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
|
|
// Hide the Gantt block too.
|
|
const g = document.getElementById('deploymentGantt');
|
|
if (g) g.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
// Render the Gantt chart first (above the list).
|
|
renderDeploymentGantt(entries, mergeGroups);
|
|
|
|
// Build the mergeable-groups banner. Each group offers one "Merge into
|
|
// one" button. Skipped when no groups exist.
|
|
let bannerHtml = '';
|
|
if (mergeGroups && mergeGroups.length > 0) {
|
|
const rows = mergeGroups.map(group => {
|
|
// Look up the entries to describe what we're merging.
|
|
const groupEntries = (entries || []).filter(e =>
|
|
e.kind === 'assignment' && group.includes(e.assignment_id)
|
|
);
|
|
if (groupEntries.length === 0) return '';
|
|
const locName = groupEntries[0].location_name || 'unnamed location';
|
|
const earliest = groupEntries.map(e => e.starts_at).filter(Boolean).sort()[0] || '';
|
|
const latest = groupEntries.map(e => e.ends_at).filter(Boolean).sort().reverse()[0] || 'present';
|
|
const idsJson = JSON.stringify(group).replace(/"/g, '"');
|
|
return `<div class="flex items-center justify-between gap-3 py-1.5">
|
|
<div class="text-sm text-blue-900 dark:text-blue-200 min-w-0 flex-1">
|
|
<strong>${group.length} consecutive records</strong> at <strong>${_dtEsc(locName)}</strong>
|
|
<span class="text-xs text-blue-700 dark:text-blue-300 ml-2">${_dtFmtDate(earliest)} → ${_dtFmtDate(latest)}</span>
|
|
</div>
|
|
<button onclick='mergeAssignmentGroup(${JSON.stringify(group)})'
|
|
class="px-3 py-1 text-xs rounded-full bg-blue-600 hover:bg-blue-700 text-white font-medium whitespace-nowrap">
|
|
Merge into one
|
|
</button>
|
|
</div>`;
|
|
}).join('');
|
|
bannerHtml = `<div class="mb-4 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
|
<div class="flex items-start gap-2 mb-2">
|
|
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<div class="text-xs text-blue-800 dark:text-blue-300">
|
|
Consecutive deployments at the same location detected. Combine them into a single record to clean up the view (notes + ingest sources are preserved).
|
|
</div>
|
|
</div>
|
|
${rows}
|
|
</div>`;
|
|
}
|
|
|
|
const html = entries.map(e => {
|
|
if (e.kind === 'assignment') return _dtRenderAssignment(e);
|
|
if (e.kind === 'gap') return _dtRenderGap(e);
|
|
if (e.kind === 'state_change') return _dtRenderStateChange(e);
|
|
return '';
|
|
}).join('');
|
|
|
|
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
|
|
}
|
|
|
|
// ── Deployment-timeline editor ──────────────────────────────────────────────
|
|
// Edit / delete an existing UnitAssignment row, or create a historical one
|
|
// (backfill orphan-event windows). All three operations hit endpoints that
|
|
// already exist on the project-locations router; after a save we just
|
|
// reload the timeline + events.
|
|
|
|
let _editAssignmentCtx = null; // { assignment_id, project_id, location_name, project_name }
|
|
|
|
function _iso_to_local_input(iso) {
|
|
// The PATCH/POST endpoints accept "YYYY-MM-DDTHH:MM[:SS]" strings
|
|
// (datetime.fromisoformat). datetime-local inputs emit the same shape
|
|
// without timezone. We just strip the trailing "Z" if present and
|
|
// truncate to minutes.
|
|
if (!iso) return '';
|
|
let s = String(iso).replace('Z', '');
|
|
// Slice down to YYYY-MM-DDTHH:MM (16 chars).
|
|
return s.slice(0, 16);
|
|
}
|
|
|
|
function openEditAssignmentModal(assignmentId) {
|
|
const entry = (_dtCurrentTimeline.entries || []).find(
|
|
e => e.kind === 'assignment' && e.assignment_id === assignmentId
|
|
);
|
|
if (!entry) {
|
|
alert('Could not find this assignment in the loaded timeline.');
|
|
return;
|
|
}
|
|
_editAssignmentCtx = {
|
|
assignment_id: entry.assignment_id,
|
|
project_id: entry.project_id,
|
|
location_name: entry.location_name || 'unnamed location',
|
|
project_name: entry.project_name || '',
|
|
};
|
|
|
|
document.getElementById('editAssignmentLocation').textContent = _editAssignmentCtx.location_name;
|
|
document.getElementById('editAssignmentProject').textContent = _editAssignmentCtx.project_name;
|
|
|
|
document.getElementById('editAssignedAt').value = _iso_to_local_input(entry.starts_at);
|
|
const endsAtInput = document.getElementById('editAssignedUntil');
|
|
const openCheckbox = document.getElementById('editAssignedUntilOpen');
|
|
if (entry.is_active) {
|
|
endsAtInput.value = '';
|
|
endsAtInput.disabled = true;
|
|
openCheckbox.checked = true;
|
|
} else {
|
|
endsAtInput.value = _iso_to_local_input(entry.ends_at);
|
|
endsAtInput.disabled = false;
|
|
openCheckbox.checked = false;
|
|
}
|
|
document.getElementById('editAssignmentNotes').value = entry.notes || '';
|
|
document.getElementById('editAssignmentError').classList.add('hidden');
|
|
document.getElementById('editAssignmentModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeEditAssignmentModal() {
|
|
document.getElementById('editAssignmentModal').classList.add('hidden');
|
|
_editAssignmentCtx = null;
|
|
}
|
|
|
|
function _toggleEditOpenEnded() {
|
|
const open = document.getElementById('editAssignedUntilOpen').checked;
|
|
const input = document.getElementById('editAssignedUntil');
|
|
input.disabled = open;
|
|
if (open) input.value = '';
|
|
}
|
|
|
|
async function saveEditAssignment() {
|
|
if (!_editAssignmentCtx) return;
|
|
const err = document.getElementById('editAssignmentError');
|
|
err.classList.add('hidden');
|
|
|
|
const assignedAt = document.getElementById('editAssignedAt').value;
|
|
if (!assignedAt) {
|
|
err.textContent = 'Assigned-at is required.';
|
|
err.classList.remove('hidden');
|
|
return;
|
|
}
|
|
const open = document.getElementById('editAssignedUntilOpen').checked;
|
|
const assignedUntil = open ? null : (document.getElementById('editAssignedUntil').value || null);
|
|
const notes = document.getElementById('editAssignmentNotes').value;
|
|
|
|
const btn = document.getElementById('editAssignmentSaveBtn');
|
|
btn.disabled = true; btn.textContent = 'Saving…';
|
|
|
|
try {
|
|
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
|
|
const r = await fetch(url, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
assigned_at: assignedAt,
|
|
assigned_until: assignedUntil,
|
|
notes: notes,
|
|
}),
|
|
});
|
|
if (!r.ok) {
|
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
|
}
|
|
closeEditAssignmentModal();
|
|
await loadDeploymentTimeline();
|
|
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
|
|
loadUnitEvents();
|
|
}
|
|
} catch (e) {
|
|
err.textContent = e.message;
|
|
err.classList.remove('hidden');
|
|
} finally {
|
|
btn.disabled = false; btn.textContent = 'Save';
|
|
}
|
|
}
|
|
|
|
async function deleteAssignmentFromModal() {
|
|
if (!_editAssignmentCtx) return;
|
|
if (!confirm('Hard-delete this deployment record?\n\nThe assignment row and its history audit entry are removed. Use this only for misclicks — to end a real deployment, edit the "assigned until" date instead.')) return;
|
|
const err = document.getElementById('editAssignmentError');
|
|
err.classList.add('hidden');
|
|
try {
|
|
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
|
|
const r = await fetch(url, { method: 'DELETE' });
|
|
if (!r.ok) {
|
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
|
}
|
|
closeEditAssignmentModal();
|
|
await loadDeploymentTimeline();
|
|
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
|
|
loadUnitEvents();
|
|
}
|
|
} catch (e) {
|
|
err.textContent = e.message;
|
|
err.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// ── Add-historical-assignment modal ─────────────────────────────────────────
|
|
let _addAssignmentProjectsCache = null;
|
|
|
|
async function openAddAssignmentModal() {
|
|
if (!currentUnit) return;
|
|
document.getElementById('addAssignmentError').classList.add('hidden');
|
|
document.getElementById('addAssignedAt').value = '';
|
|
document.getElementById('addAssignedUntil').value = '';
|
|
document.getElementById('addAssignedUntilOpen').checked = false;
|
|
document.getElementById('addAssignedUntil').disabled = false;
|
|
document.getElementById('addAssignmentNotes').value = '';
|
|
|
|
const locSel = document.getElementById('addAssignmentLocation');
|
|
locSel.disabled = true;
|
|
locSel.innerHTML = '<option value="">Pick a project first</option>';
|
|
|
|
const projSel = document.getElementById('addAssignmentProject');
|
|
projSel.innerHTML = '<option value="">Loading…</option>';
|
|
|
|
document.getElementById('addAssignmentModal').classList.remove('hidden');
|
|
|
|
try {
|
|
if (!_addAssignmentProjectsCache) {
|
|
const r = await fetch('/api/projects/search-json?limit=50');
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
_addAssignmentProjectsCache = await r.json();
|
|
}
|
|
projSel.innerHTML = '<option value="">— pick project —</option>'
|
|
+ _addAssignmentProjectsCache.map(p =>
|
|
`<option value="${_dtEsc(p.id)}">${_dtEsc(p.name)}${p.project_number ? ' (' + _dtEsc(p.project_number) + ')' : ''}</option>`
|
|
).join('');
|
|
} catch (e) {
|
|
projSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
|
|
}
|
|
}
|
|
|
|
function closeAddAssignmentModal() {
|
|
document.getElementById('addAssignmentModal').classList.add('hidden');
|
|
}
|
|
|
|
function _toggleAddOpenEnded() {
|
|
const open = document.getElementById('addAssignedUntilOpen').checked;
|
|
const input = document.getElementById('addAssignedUntil');
|
|
input.disabled = open;
|
|
if (open) input.value = '';
|
|
}
|
|
|
|
async function _addAssignmentProjectChanged() {
|
|
const projectId = document.getElementById('addAssignmentProject').value;
|
|
const locSel = document.getElementById('addAssignmentLocation');
|
|
if (!projectId) {
|
|
locSel.disabled = true;
|
|
locSel.innerHTML = '<option value="">Pick a project first</option>';
|
|
return;
|
|
}
|
|
locSel.disabled = true;
|
|
locSel.innerHTML = '<option value="">Loading locations…</option>';
|
|
// Match the device type to the location_type filter.
|
|
const wantType = (currentUnit && currentUnit.device_type === 'slm') ? 'sound' : 'vibration';
|
|
try {
|
|
const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/locations-json?location_type=${wantType}`);
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const locs = await r.json();
|
|
if (!locs.length) {
|
|
locSel.innerHTML = '<option value="">No matching locations in this project</option>';
|
|
return;
|
|
}
|
|
locSel.disabled = false;
|
|
locSel.innerHTML = '<option value="">— pick location —</option>'
|
|
+ locs.map(l => `<option value="${_dtEsc(l.id)}">${_dtEsc(l.name)}</option>`).join('');
|
|
} catch (e) {
|
|
locSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
|
|
}
|
|
}
|
|
|
|
async function saveAddAssignment() {
|
|
if (!currentUnit) return;
|
|
const err = document.getElementById('addAssignmentError');
|
|
err.classList.add('hidden');
|
|
|
|
const projectId = document.getElementById('addAssignmentProject').value;
|
|
const locationId = document.getElementById('addAssignmentLocation').value;
|
|
const assignedAt = document.getElementById('addAssignedAt').value;
|
|
const open = document.getElementById('addAssignedUntilOpen').checked;
|
|
const assignedUntil = open ? '' : document.getElementById('addAssignedUntil').value;
|
|
const notes = document.getElementById('addAssignmentNotes').value;
|
|
|
|
if (!projectId) { err.textContent = 'Pick a project.'; err.classList.remove('hidden'); return; }
|
|
if (!locationId) { err.textContent = 'Pick a location.'; err.classList.remove('hidden'); return; }
|
|
if (!assignedAt) { err.textContent = 'Assigned-at is required.'; err.classList.remove('hidden'); return; }
|
|
|
|
const btn = document.getElementById('addAssignmentSaveBtn');
|
|
btn.disabled = true; btn.textContent = 'Creating…';
|
|
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('unit_id', currentUnit.id);
|
|
fd.append('assigned_at', assignedAt);
|
|
if (assignedUntil) fd.append('assigned_until', assignedUntil);
|
|
if (notes) fd.append('notes', notes);
|
|
|
|
const url = `/api/projects/${encodeURIComponent(projectId)}/locations/${encodeURIComponent(locationId)}/assign`;
|
|
const r = await fetch(url, { method: 'POST', body: fd });
|
|
if (!r.ok) {
|
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
|
}
|
|
closeAddAssignmentModal();
|
|
await loadDeploymentTimeline();
|
|
if (typeof loadUnitEvents === 'function' && currentUnit.device_type === 'seismograph') {
|
|
loadUnitEvents();
|
|
}
|
|
} catch (e) {
|
|
err.textContent = e.message;
|
|
err.classList.remove('hidden');
|
|
} finally {
|
|
btn.disabled = false; btn.textContent = 'Create';
|
|
}
|
|
}
|
|
|
|
// ── SFM Events section ──────────────────────────────────────────────────────
|
|
function clearUnitEventFilters() {
|
|
document.getElementById('ue-filter-bucket').value = 'all';
|
|
document.getElementById('ue-filter-from').value = '';
|
|
document.getElementById('ue-filter-to').value = '';
|
|
document.getElementById('ue-filter-ft').value = '';
|
|
document.getElementById('ue-filter-limit').value = '500';
|
|
loadUnitEvents();
|
|
}
|
|
|
|
// Module-level state for the unit-events table sort. Cache lets us re-sort
|
|
// without a refetch when the user clicks a column header.
|
|
let _ueEventsCache = [];
|
|
let _ueEventsTotal = 0;
|
|
let _ueEventsBucket = 'all';
|
|
let _ueAssignmentsTotal = 0;
|
|
let _ueSortKey = 'timestamp';
|
|
let _ueSortDir = 'desc';
|
|
|
|
async function loadUnitEvents() {
|
|
if (!currentUnit || currentUnit.device_type !== 'seismograph') return;
|
|
const container = document.getElementById('ue-events-container');
|
|
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading events…</div>';
|
|
|
|
const params = new URLSearchParams();
|
|
const bucket = document.getElementById('ue-filter-bucket').value;
|
|
const from = document.getElementById('ue-filter-from').value;
|
|
const to = document.getElementById('ue-filter-to').value;
|
|
const ft = document.getElementById('ue-filter-ft').value;
|
|
const limit = document.getElementById('ue-filter-limit').value;
|
|
params.set('bucket', bucket);
|
|
if (from) params.set('from_dt', from.replace('T', ' '));
|
|
if (to) params.set('to_dt', to.replace('T', ' '));
|
|
if (ft) params.set('false_trigger', ft);
|
|
params.set('limit', limit);
|
|
|
|
try {
|
|
const r = await fetch(`/api/units/${currentUnit.id}/events?${params.toString()}`);
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
|
throw new Error(err.detail || 'HTTP ' + r.status);
|
|
}
|
|
const d = await r.json();
|
|
_ueEventsCache = d.events || [];
|
|
_ueEventsTotal = d.count || 0;
|
|
_ueEventsBucket = bucket;
|
|
_ueAssignmentsTotal = d.assignments_total || 0;
|
|
renderUnitEventStats(d.stats);
|
|
renderUnitEventTable(_ueEventsCache, _ueEventsTotal, container, bucket, _ueAssignmentsTotal);
|
|
} catch (e) {
|
|
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function sortUnitEvents(key) {
|
|
if (_ueSortKey === key) {
|
|
_ueSortDir = _ueSortDir === 'desc' ? 'asc' : 'desc';
|
|
} else {
|
|
_ueSortKey = key;
|
|
_ueSortDir = 'desc';
|
|
}
|
|
renderUnitEventTable(_ueEventsCache, _ueEventsTotal,
|
|
document.getElementById('ue-events-container'), _ueEventsBucket, _ueAssignmentsTotal);
|
|
}
|
|
|
|
function _ueApplySort(events) {
|
|
const key = _ueSortKey;
|
|
const dir = _ueSortDir === 'asc' ? 1 : -1;
|
|
return [...events].sort((a, b) => {
|
|
let av, bv;
|
|
if (key === 'attribution') {
|
|
// Sort by location name so attributed rows group together.
|
|
av = a.attribution ? (a.attribution.location_name || '') : '';
|
|
bv = b.attribution ? (b.attribution.location_name || '') : '';
|
|
} else {
|
|
av = a[key]; bv = b[key];
|
|
}
|
|
if (av == null && bv == null) return 0;
|
|
if (av == null) return 1;
|
|
if (bv == null) return -1;
|
|
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
|
return String(av).localeCompare(String(bv)) * dir;
|
|
});
|
|
}
|
|
|
|
function _ueSortIndicator(key) {
|
|
if (_ueSortKey !== key) return '<span class="text-gray-400 opacity-50 ml-1">↕</span>';
|
|
return _ueSortDir === 'desc'
|
|
? '<span class="text-seismo-orange ml-1">↓</span>'
|
|
: '<span class="text-seismo-orange ml-1">↑</span>';
|
|
}
|
|
|
|
function _ueSortableTh(label, key) {
|
|
return `<th onclick="sortUnitEvents('${key}')"
|
|
class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
|
${label}${_ueSortIndicator(key)}
|
|
</th>`;
|
|
}
|
|
|
|
function renderUnitEventStats(stats) {
|
|
const s = stats || {};
|
|
document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString();
|
|
const unattrEl = document.getElementById('ue-stat-unattr');
|
|
unattrEl.textContent = (s.unattributed_count ?? 0).toLocaleString();
|
|
// Highlight unattributed in amber/red if non-zero — visual signal that
|
|
// the operator has assignment-window cleanup to do.
|
|
unattrEl.className = 'text-2xl font-bold mt-1 ' + (
|
|
(s.unattributed_count ?? 0) > 0
|
|
? 'text-amber-600 dark:text-amber-400'
|
|
: 'text-gray-900 dark:text-white'
|
|
);
|
|
|
|
if (s.peak_pvs != null) {
|
|
document.getElementById('ue-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
|
|
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
|
|
document.getElementById('ue-stat-peak-when').textContent = when || '—';
|
|
} else {
|
|
document.getElementById('ue-stat-peak').textContent = '—';
|
|
document.getElementById('ue-stat-peak-when').textContent = '—';
|
|
}
|
|
|
|
if (s.last_event) {
|
|
const dt = s.last_event.slice(0, 19).replace('T', ' ');
|
|
document.getElementById('ue-stat-last').textContent = dt;
|
|
} else {
|
|
document.getElementById('ue-stat-last').textContent = '—';
|
|
}
|
|
}
|
|
|
|
function _ueFmtPPV(v) {
|
|
if (v == null) return '—';
|
|
return v.toFixed(4);
|
|
}
|
|
|
|
function _uePpvClass(v) {
|
|
if (v == null) return 'text-gray-400';
|
|
if (v < 0.5) return 'text-green-600 dark:text-green-400';
|
|
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
|
|
return 'text-red-600 dark:text-red-400 font-semibold';
|
|
}
|
|
|
|
function _ueEsc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function _ueAttrCell(ev) {
|
|
// Inline links use onclick="event.stopPropagation()" so clicking the
|
|
// project/location link navigates instead of opening the event-detail
|
|
// modal (which fires from the row-level onclick).
|
|
const a = ev.attribution;
|
|
if (a) {
|
|
const projLabel = _ueEsc(a.project_name || '—');
|
|
const locLabel = _ueEsc(a.location_name || '—');
|
|
// If the attributed location has since been soft-removed, badge
|
|
// it so operators see at a glance this is historical attribution.
|
|
const removedBadge = a.location_removed_at
|
|
? '<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold" title="Location no longer actively monitored">removed</span>'
|
|
: '';
|
|
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
|
onclick="event.stopPropagation()"
|
|
class="text-seismo-orange hover:text-seismo-navy"
|
|
title="${projLabel} → ${locLabel}">
|
|
📍 ${locLabel}
|
|
</a>${removedBadge}
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
|
}
|
|
const n = ev.nearest_assignment;
|
|
if (n) {
|
|
const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary');
|
|
const days = Math.abs(n.delta_days);
|
|
const daysLabel = days < 1
|
|
? `<${(days * 24).toFixed(1)}h`
|
|
: `${days.toFixed(1)}d`;
|
|
return `<span class="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">⚠ Unattributed</span>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
${daysLabel} ${_ueEsc(sign)} <a href="/projects/${_ueEsc(n.project_id)}/nrl/${_ueEsc(n.location_id)}" onclick="event.stopPropagation()" class="text-seismo-orange hover:text-seismo-navy">${_ueEsc(n.location_name || '?')}</a>
|
|
</div>`;
|
|
}
|
|
return `<span class="px-2 py-0.5 rounded text-xs bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">⚠ No assignments</span>`;
|
|
}
|
|
|
|
function renderUnitEventTable(events, total, container, bucket, assignmentsTotal) {
|
|
if (!events || events.length === 0) {
|
|
let msg;
|
|
if (bucket === 'unattributed') {
|
|
msg = assignmentsTotal === 0
|
|
? 'No assignments yet — every event from this unit is unattributed. Assign it to a project location to start attributing events.'
|
|
: '✅ All events for this unit are attributed to a project/location.';
|
|
} else if (bucket === 'attributed') {
|
|
msg = assignmentsTotal === 0
|
|
? 'No assignments yet for this unit.'
|
|
: 'No events recorded inside any assignment window with the current filter.';
|
|
} else {
|
|
msg = 'No events found for this unit with the current filter.';
|
|
}
|
|
container.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${msg}</div>`;
|
|
return;
|
|
}
|
|
|
|
const sorted = _ueApplySort(events);
|
|
const rows = sorted.map(ev => {
|
|
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
|
const tran = _ueFmtPPV(ev.tran_ppv);
|
|
const vert = _ueFmtPPV(ev.vert_ppv);
|
|
const lng = _ueFmtPPV(ev.long_ppv);
|
|
const pvs = _ueFmtPPV(ev.peak_vector_sum);
|
|
const ft = ev.false_trigger
|
|
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
|
: '';
|
|
|
|
const checked = _ueSelectedEventIds.has(ev.id) ? 'checked' : '';
|
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}" onclick="showEventDetail('${_dtEsc(ev.id)}')">
|
|
<td class="px-3 py-2.5 text-sm" onclick="event.stopPropagation()">
|
|
<input type="checkbox" class="ue-row-check rounded border-gray-300 dark:border-gray-600"
|
|
data-event-id="${_dtEsc(ev.id)}" ${checked}
|
|
onchange="onUnitEventRowCheck(this)">
|
|
</td>
|
|
<td class="px-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
|
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
|
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
|
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.long_ppv)}">${lng}</td>
|
|
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${_uePpvClass(ev.peak_vector_sum)}">${pvs}</td>
|
|
<td class="px-4 py-2.5 text-sm">${ft}</td>
|
|
<td class="px-4 py-2.5 text-sm">${_ueAttrCell(ev)}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
container.innerHTML = `
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3 pb-1">Showing ${events.length} of ${total.toLocaleString()} event${total === 1 ? '' : 's'}</div>
|
|
<table class="w-full text-left">
|
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
|
<tr>
|
|
<th class="px-3 py-2">
|
|
<input type="checkbox" id="ue-check-all"
|
|
class="rounded border-gray-300 dark:border-gray-600"
|
|
onchange="toggleAllUnitEventRows(this.checked)">
|
|
</th>
|
|
${_ueSortableTh('Timestamp', 'timestamp')}
|
|
${_ueSortableTh('Tran', 'tran_ppv')}
|
|
${_ueSortableTh('Vert', 'vert_ppv')}
|
|
${_ueSortableTh('Long', 'long_ppv')}
|
|
${_ueSortableTh('PVS', 'peak_vector_sum')}
|
|
${_ueSortableTh('Flags', 'false_trigger')}
|
|
${_ueSortableTh('Attribution', 'attribution')}
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
|
</table>`;
|
|
_ueRefreshBulkButton();
|
|
}
|
|
|
|
// ===== Bulk false-trigger flagging =====
|
|
// Selection is keyed by event ID and persists across table re-renders, so
|
|
// users can paginate / re-sort without losing their selection.
|
|
const _ueSelectedEventIds = new Set();
|
|
|
|
function _ueRefreshBulkButton() {
|
|
const n = _ueSelectedEventIds.size;
|
|
const lbl = document.getElementById('ue-bulk-selected');
|
|
const flag = document.getElementById('ue-bulk-flag-ft');
|
|
const clr = document.getElementById('ue-bulk-clear-ft');
|
|
if (lbl) lbl.textContent = `${n} selected`;
|
|
if (flag) flag.disabled = (n === 0);
|
|
if (clr) clr.disabled = (n === 0);
|
|
}
|
|
|
|
function onUnitEventRowCheck(input) {
|
|
const id = input.getAttribute('data-event-id');
|
|
if (input.checked) {
|
|
_ueSelectedEventIds.add(id);
|
|
} else {
|
|
_ueSelectedEventIds.delete(id);
|
|
// If we just unchecked a row, the master "all" checkbox shouldn't stay checked.
|
|
const master = document.getElementById('ue-check-all');
|
|
if (master) master.checked = false;
|
|
}
|
|
_ueRefreshBulkButton();
|
|
}
|
|
|
|
function toggleAllUnitEventRows(checked) {
|
|
document.querySelectorAll('.ue-row-check').forEach(cb => {
|
|
const id = cb.getAttribute('data-event-id');
|
|
cb.checked = checked;
|
|
if (checked) _ueSelectedEventIds.add(id);
|
|
else _ueSelectedEventIds.delete(id);
|
|
});
|
|
_ueRefreshBulkButton();
|
|
}
|
|
|
|
async function flagSelectedUnitEvents(value) {
|
|
// value = true → flag as false trigger
|
|
// value = false → clear false-trigger flag
|
|
if (_ueSelectedEventIds.size === 0) return;
|
|
const ids = Array.from(_ueSelectedEventIds);
|
|
const verb = value ? 'flag as false trigger' : 'clear false-trigger flag on';
|
|
if (!confirm(`${verb} ${ids.length} event${ids.length === 1 ? '' : 's'}?`)) {
|
|
return;
|
|
}
|
|
|
|
// SFM exposes single-row PATCH only. Fan out concurrently with a
|
|
// modest cap so we don't open hundreds of sockets at once.
|
|
const concurrency = 8;
|
|
let ok = 0, failed = 0;
|
|
let cursor = 0;
|
|
async function worker() {
|
|
while (cursor < ids.length) {
|
|
const i = cursor++;
|
|
const id = ids[i];
|
|
try {
|
|
const resp = await fetch(
|
|
`/api/sfm/db/events/${encodeURIComponent(id)}/false_trigger?value=${value ? 'true' : 'false'}`,
|
|
{ method: 'PATCH' }
|
|
);
|
|
if (resp.ok) ok++;
|
|
else failed++;
|
|
} catch (_) {
|
|
failed++;
|
|
}
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
|
|
if (failed) {
|
|
alert(`${ok} updated, ${failed} failed. Refreshing table.`);
|
|
}
|
|
_ueSelectedEventIds.clear();
|
|
loadUnitEvents();
|
|
}
|
|
|
|
// ===== Pair Device Modal Functions =====
|
|
let pairModalModems = []; // Cache loaded modems
|
|
let pairModalDeviceType = ''; // Current device type
|
|
|
|
function openPairDeviceModal(deviceType) {
|
|
const modal = document.getElementById('pairDeviceModal');
|
|
const searchInput = document.getElementById('pairModemSearch');
|
|
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
|
|
|
|
if (!modal) return;
|
|
|
|
pairModalDeviceType = deviceType;
|
|
|
|
// Reset search and filter
|
|
if (searchInput) searchInput.value = '';
|
|
if (hideBenchedCheckbox) hideBenchedCheckbox.checked = false;
|
|
|
|
// Show modal
|
|
modal.classList.remove('hidden');
|
|
|
|
// Focus search input
|
|
setTimeout(() => {
|
|
if (searchInput) searchInput.focus();
|
|
}, 100);
|
|
|
|
// Load available modems
|
|
loadAvailableModems();
|
|
}
|
|
|
|
function closePairDeviceModal() {
|
|
const modal = document.getElementById('pairDeviceModal');
|
|
if (modal) modal.classList.add('hidden');
|
|
pairModalModems = [];
|
|
}
|
|
|
|
async function loadAvailableModems() {
|
|
const listContainer = document.getElementById('pairModemList');
|
|
listContainer.innerHTML = '<div class="text-center py-4"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Loading modems...</p></div>';
|
|
|
|
try {
|
|
const response = await fetch('/api/roster/modems');
|
|
if (!response.ok) throw new Error('Failed to load modems');
|
|
|
|
pairModalModems = await response.json();
|
|
|
|
if (pairModalModems.length === 0) {
|
|
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems found in roster</p>';
|
|
return;
|
|
}
|
|
|
|
// Render the list
|
|
renderModemList();
|
|
} catch (error) {
|
|
listContainer.innerHTML = `<p class="text-center py-4 text-red-500">Error: ${error.message}</p>`;
|
|
}
|
|
}
|
|
|
|
function filterPairModemList() {
|
|
renderModemList();
|
|
}
|
|
|
|
function renderModemList() {
|
|
const listContainer = document.getElementById('pairModemList');
|
|
const searchInput = document.getElementById('pairModemSearch');
|
|
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
|
|
|
|
const searchTerm = (searchInput?.value || '').toLowerCase().trim();
|
|
const hideBenched = hideBenchedCheckbox?.checked || false;
|
|
|
|
// Filter modems
|
|
let filteredModems = pairModalModems.filter(modem => {
|
|
// Filter by benched status
|
|
if (hideBenched && !modem.deployed) return false;
|
|
|
|
// Filter by search term
|
|
if (searchTerm) {
|
|
const searchFields = [
|
|
modem.id,
|
|
modem.ip_address || '',
|
|
modem.phone_number || '',
|
|
modem.note || ''
|
|
].join(' ').toLowerCase();
|
|
if (!searchFields.includes(searchTerm)) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (filteredModems.length === 0) {
|
|
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems match your criteria</p>';
|
|
return;
|
|
}
|
|
|
|
// Build modem list
|
|
let html = '';
|
|
for (const modem of filteredModems) {
|
|
const displayParts = [modem.id];
|
|
if (modem.ip_address) displayParts.push(`(${modem.ip_address})`);
|
|
if (modem.note) displayParts.push(`- ${modem.note.substring(0, 30)}${modem.note.length > 30 ? '...' : ''}`);
|
|
|
|
const deployedBadge = modem.deployed
|
|
? '<span class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 text-xs rounded">Deployed</span>'
|
|
: '<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded">Benched</span>';
|
|
|
|
const pairedBadge = modem.deployed_with_unit_id
|
|
? `<span class="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs rounded">Paired: ${modem.deployed_with_unit_id}</span>`
|
|
: '';
|
|
|
|
html += `
|
|
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0"
|
|
onclick="selectModemForPairing('${modem.id}', '${displayParts.join(' ').replace(/'/g, "\\'")}')">
|
|
<div class="flex-1">
|
|
<div class="font-medium text-gray-900 dark:text-white">
|
|
<span class="text-seismo-orange">${modem.id}</span>
|
|
${modem.ip_address ? `<span class="text-gray-400 ml-2 font-mono text-sm">${modem.ip_address}</span>` : ''}
|
|
</div>
|
|
${modem.note ? `<div class="text-sm text-gray-500 dark:text-gray-400 truncate">${modem.note}</div>` : ''}
|
|
</div>
|
|
<div class="flex gap-2 ml-3">
|
|
${deployedBadge}
|
|
${pairedBadge}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
listContainer.innerHTML = html;
|
|
}
|
|
|
|
function selectModemForPairing(modemId, displayText) {
|
|
// Update the correct picker based on device type
|
|
let pickerId = '';
|
|
if (pairModalDeviceType === 'seismograph') {
|
|
pickerId = '-detail-seismo';
|
|
} else if (pairModalDeviceType === 'slm') {
|
|
pickerId = '-detail-slm';
|
|
}
|
|
|
|
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 = modemId;
|
|
if (searchInput) searchInput.value = displayText;
|
|
if (clearBtn) clearBtn.classList.remove('hidden');
|
|
|
|
// Close modal
|
|
closePairDeviceModal();
|
|
}
|
|
|
|
// Clear pairing (unpair device from modem)
|
|
function clearPairing(deviceType) {
|
|
let pickerId = '';
|
|
if (deviceType === 'seismograph') {
|
|
pickerId = '-detail-seismo';
|
|
} else if (deviceType === 'slm') {
|
|
pickerId = '-detail-slm';
|
|
}
|
|
|
|
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 = '';
|
|
if (clearBtn) clearBtn.classList.add('hidden');
|
|
|
|
closePairDeviceModal();
|
|
}
|
|
|
|
// ===== Modem Pair Device Modal Functions (for modems to pick a device) =====
|
|
let modemPairDevices = []; // Cache loaded devices
|
|
let modemHasCurrentPairing = false;
|
|
|
|
function openModemPairDeviceModal() {
|
|
const modal = document.getElementById('modemPairDeviceModal');
|
|
const searchInput = document.getElementById('modemPairDeviceSearch');
|
|
|
|
if (!modal) return;
|
|
|
|
modal.classList.remove('hidden');
|
|
if (searchInput) {
|
|
searchInput.value = '';
|
|
searchInput.focus();
|
|
}
|
|
|
|
// Reset checkboxes
|
|
document.getElementById('modemPairHidePaired').checked = true;
|
|
document.getElementById('modemPairShowSeismo').checked = true;
|
|
document.getElementById('modemPairShowSLM').checked = true;
|
|
|
|
// Load available devices
|
|
loadPairableDevices();
|
|
}
|
|
|
|
function closeModemPairDeviceModal() {
|
|
const modal = document.getElementById('modemPairDeviceModal');
|
|
if (modal) modal.classList.add('hidden');
|
|
modemPairDevices = [];
|
|
}
|
|
|
|
async function loadPairableDevices() {
|
|
const listContainer = document.getElementById('modemPairDeviceList');
|
|
listContainer.innerHTML = '<div class="text-center py-8"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Loading devices...</p></div>';
|
|
|
|
try {
|
|
const response = await fetch(`/api/modem-dashboard/${unitId}/pairable-devices?hide_paired=false`);
|
|
if (!response.ok) throw new Error('Failed to load devices');
|
|
|
|
const data = await response.json();
|
|
modemPairDevices = data.devices || [];
|
|
|
|
// Check if modem has a current pairing
|
|
modemHasCurrentPairing = modemPairDevices.some(d => d.is_paired_to_this);
|
|
const unpairBtn = document.getElementById('modemUnpairBtn');
|
|
if (unpairBtn) {
|
|
unpairBtn.classList.toggle('hidden', !modemHasCurrentPairing);
|
|
}
|
|
|
|
if (modemPairDevices.length === 0) {
|
|
listContainer.innerHTML = '<p class="text-center py-8 text-gray-500">No devices found in roster</p>';
|
|
return;
|
|
}
|
|
|
|
renderModemPairDeviceList();
|
|
} catch (error) {
|
|
console.error('Failed to load pairable devices:', error);
|
|
listContainer.innerHTML = '<p class="text-center py-8 text-red-500">Failed to load devices</p>';
|
|
}
|
|
}
|
|
|
|
function filterModemPairDeviceList() {
|
|
renderModemPairDeviceList();
|
|
}
|
|
|
|
function renderModemPairDeviceList() {
|
|
const listContainer = document.getElementById('modemPairDeviceList');
|
|
const searchInput = document.getElementById('modemPairDeviceSearch');
|
|
const hidePairedCheckbox = document.getElementById('modemPairHidePaired');
|
|
const showSeismoCheckbox = document.getElementById('modemPairShowSeismo');
|
|
const showSLMCheckbox = document.getElementById('modemPairShowSLM');
|
|
|
|
const searchTerm = searchInput?.value?.toLowerCase() || '';
|
|
const hidePaired = hidePairedCheckbox?.checked ?? true;
|
|
const showSeismo = showSeismoCheckbox?.checked ?? true;
|
|
const showSLM = showSLMCheckbox?.checked ?? true;
|
|
|
|
// Filter devices
|
|
let filteredDevices = modemPairDevices.filter(device => {
|
|
// Filter by device type
|
|
if (device.device_type === 'seismograph' && !showSeismo) return false;
|
|
if (device.device_type === 'slm' && !showSLM) return false;
|
|
|
|
// Hide devices paired to OTHER modems (but show unpaired and paired-to-this)
|
|
if (hidePaired && device.is_paired_to_other) return false;
|
|
|
|
// Search filter
|
|
if (searchTerm) {
|
|
const searchFields = [
|
|
device.id,
|
|
device.project_id || '',
|
|
device.location || '',
|
|
device.note || ''
|
|
].join(' ').toLowerCase();
|
|
if (!searchFields.includes(searchTerm)) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (filteredDevices.length === 0) {
|
|
listContainer.innerHTML = '<p class="text-center py-8 text-gray-500">No devices match your criteria</p>';
|
|
return;
|
|
}
|
|
|
|
// Build device list HTML
|
|
let html = '<div class="divide-y divide-gray-200 dark:divide-gray-700">';
|
|
for (const device of filteredDevices) {
|
|
const deviceTypeLabel = device.device_type === 'slm' ? 'SLM' : 'Seismograph';
|
|
const deviceTypeClass = device.device_type === 'slm'
|
|
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300'
|
|
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
|
|
|
|
const deployedBadge = device.deployed
|
|
? '<span class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 text-xs rounded">Deployed</span>'
|
|
: '<span class="px-2 py-0.5 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 text-xs rounded">Benched</span>';
|
|
|
|
let pairingBadge = '';
|
|
if (device.is_paired_to_this) {
|
|
pairingBadge = '<span class="px-2 py-0.5 bg-seismo-orange/20 text-seismo-orange text-xs rounded font-medium">Current</span>';
|
|
} else if (device.is_paired_to_other) {
|
|
pairingBadge = `<span class="px-2 py-0.5 bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 text-xs rounded">Paired: ${device.paired_modem_id}</span>`;
|
|
}
|
|
|
|
const isCurrentlyPaired = device.is_paired_to_this;
|
|
|
|
html += `
|
|
<div class="px-6 py-3 hover:bg-gray-50 dark:hover:bg-slate-700 cursor-pointer transition-colors ${isCurrentlyPaired ? 'bg-seismo-orange/5' : ''}"
|
|
onclick="selectDeviceForModem('${device.id}')">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium text-gray-900 dark:text-white">${device.id}</span>
|
|
<span class="px-2 py-0.5 ${deviceTypeClass} text-xs rounded">${deviceTypeLabel}</span>
|
|
</div>
|
|
${device.project_id ? `<div class="text-sm text-gray-500 dark:text-gray-400">${device.project_id}</div>` : ''}
|
|
${device.location ? `<div class="text-sm text-gray-500 dark:text-gray-400 truncate">${device.location}</div>` : ''}
|
|
</div>
|
|
<div class="flex items-center gap-2 ml-4">
|
|
${deployedBadge}
|
|
${pairingBadge}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
html += '</div>';
|
|
|
|
listContainer.innerHTML = html;
|
|
}
|
|
|
|
async function selectDeviceForModem(deviceId) {
|
|
const listContainer = document.getElementById('modemPairDeviceList');
|
|
|
|
// Show loading state
|
|
listContainer.innerHTML = '<div class="text-center py-8"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Pairing device...</p></div>';
|
|
|
|
try {
|
|
const response = await fetch(`/api/modem-dashboard/${unitId}/pair?device_id=${encodeURIComponent(deviceId)}`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'success') {
|
|
closeModemPairDeviceModal();
|
|
// Reload the paired device section
|
|
loadPairedDevice();
|
|
// Show success message (optional toast)
|
|
showToast(`Paired with ${deviceId}`, 'success');
|
|
} else {
|
|
listContainer.innerHTML = `<p class="text-center py-8 text-red-500">${result.detail || 'Failed to pair device'}</p>`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to pair device:', error);
|
|
listContainer.innerHTML = '<p class="text-center py-8 text-red-500">Failed to pair device</p>';
|
|
}
|
|
}
|
|
|
|
async function unpairDeviceFromModem() {
|
|
const listContainer = document.getElementById('modemPairDeviceList');
|
|
|
|
// Show loading state
|
|
listContainer.innerHTML = '<div class="text-center py-8"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Unpairing device...</p></div>';
|
|
|
|
try {
|
|
const response = await fetch(`/api/modem-dashboard/${unitId}/unpair`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'success') {
|
|
closeModemPairDeviceModal();
|
|
// Reload the paired device section
|
|
loadPairedDevice();
|
|
// Show success message
|
|
if (result.unpaired_device_id) {
|
|
showToast(`Unpaired ${result.unpaired_device_id}`, 'success');
|
|
} else {
|
|
showToast('No device was paired', 'info');
|
|
}
|
|
} else {
|
|
listContainer.innerHTML = `<p class="text-center py-8 text-red-500">${result.detail || 'Failed to unpair device'}</p>`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to unpair device:', error);
|
|
listContainer.innerHTML = '<p class="text-center py-8 text-red-500">Failed to unpair device</p>';
|
|
}
|
|
}
|
|
|
|
// Simple toast function (if not already defined)
|
|
function showToast(message, type = 'info') {
|
|
// Check if there's already a toast container
|
|
let toastContainer = document.getElementById('toast-container');
|
|
if (!toastContainer) {
|
|
toastContainer = document.createElement('div');
|
|
toastContainer.id = 'toast-container';
|
|
toastContainer.className = 'fixed bottom-4 right-4 z-50 space-y-2';
|
|
document.body.appendChild(toastContainer);
|
|
}
|
|
|
|
const toast = document.createElement('div');
|
|
const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-gray-700';
|
|
toast.className = `${bgColor} text-white px-4 py-2 rounded-lg shadow-lg transform transition-all duration-300 translate-x-0`;
|
|
toast.textContent = message;
|
|
|
|
toastContainer.appendChild(toast);
|
|
|
|
// Auto-remove after 3 seconds
|
|
setTimeout(() => {
|
|
toast.classList.add('opacity-0', 'translate-x-full');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
</script>
|
|
|
|
<!-- Pair Device Modal -->
|
|
<div id="pairDeviceModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
|
<div class="flex items-center justify-center min-h-screen px-4">
|
|
<!-- Backdrop -->
|
|
<div class="fixed inset-0 bg-black/50" onclick="closePairDeviceModal()"></div>
|
|
|
|
<!-- Modal Content -->
|
|
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Pair with Modem</h3>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Select a modem to pair with this device</p>
|
|
</div>
|
|
<button onclick="closePairDeviceModal()" 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>
|
|
|
|
<!-- Search and Filter -->
|
|
<div class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900/50">
|
|
<div class="flex gap-3 items-center">
|
|
<!-- Search Input -->
|
|
<div class="flex-1 relative">
|
|
<input type="text"
|
|
id="pairModemSearch"
|
|
placeholder="Search by ID, IP, or note..."
|
|
oninput="filterPairModemList()"
|
|
class="w-full px-4 py-2 pl-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 text-sm">
|
|
<svg class="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" 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>
|
|
<!-- Hide Benched Toggle -->
|
|
<label class="flex items-center gap-2 cursor-pointer whitespace-nowrap">
|
|
<input type="checkbox"
|
|
id="pairHideBenched"
|
|
onchange="filterPairModemList()"
|
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">Deployed only</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modem List -->
|
|
<div id="pairModemList" class="max-h-80 overflow-y-auto">
|
|
<!-- Populated by JavaScript -->
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900">
|
|
<button onclick="closePairDeviceModal()" class="w-full px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Include Project Create Modal for inline project creation -->
|
|
{% include "partials/project_create_modal.html" %}
|
|
|
|
<!-- Modem Pair Device Modal (for modems to pick a device) -->
|
|
<div id="modemPairDeviceModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
|
<div class="flex items-center justify-center min-h-screen px-4">
|
|
<!-- Backdrop -->
|
|
<div class="fixed inset-0 bg-black/50" onclick="closeModemPairDeviceModal()"></div>
|
|
|
|
<!-- Modal Content -->
|
|
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Pair Device to Modem</h3>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Select a seismograph or SLM to pair</p>
|
|
</div>
|
|
<button onclick="closeModemPairDeviceModal()" 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>
|
|
|
|
<!-- Search and Filters -->
|
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
|
|
<input type="text"
|
|
placeholder="Search by ID, project, location..."
|
|
class="w-full 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-seismo-orange focus:border-transparent"
|
|
id="modemPairDeviceSearch"
|
|
autocomplete="off"
|
|
oninput="filterModemPairDeviceList()">
|
|
<div class="flex items-center gap-4">
|
|
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
|
<input type="checkbox"
|
|
id="modemPairHidePaired"
|
|
onchange="filterModemPairDeviceList()"
|
|
checked
|
|
class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
|
Hide paired devices
|
|
</label>
|
|
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
|
<input type="checkbox"
|
|
id="modemPairShowSeismo"
|
|
onchange="filterModemPairDeviceList()"
|
|
checked
|
|
class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
|
Seismographs
|
|
</label>
|
|
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
|
<input type="checkbox"
|
|
id="modemPairShowSLM"
|
|
onchange="filterModemPairDeviceList()"
|
|
checked
|
|
class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
|
|
SLMs
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Device List -->
|
|
<div id="modemPairDeviceList" class="flex-1 overflow-y-auto max-h-80">
|
|
<!-- Populated by JavaScript -->
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900 flex gap-3">
|
|
<button onclick="unpairDeviceFromModem()"
|
|
id="modemUnpairBtn"
|
|
class="hidden px-4 py-2 bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg transition-colors">
|
|
Unpair Current
|
|
</button>
|
|
<button onclick="closeModemPairDeviceModal()" class="flex-1 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
|
{% include 'partials/event_detail_modal.html' %}
|
|
<script src="/static/event-modal.js"></script>
|
|
<script>
|
|
// Refresh the unit's events table when the modal's review form saves.
|
|
window.addEventListener('sfm-event-review-saved', () => {
|
|
if (typeof loadUnitEvents === 'function') loadUnitEvents();
|
|
});
|
|
</script>
|
|
|
|
{% endblock %}
|