update to 0.16.0 #72

Merged
serversdown merged 32 commits from dev into main 2026-06-23 00:59:46 -04:00
4 changed files with 293 additions and 129 deletions
Showing only changes of commit f54c62b332 - Show all commits
@@ -3,13 +3,14 @@
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div> <div>
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ project.name }}</h2> <h2 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ project.name }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> {# Identity line — project number / client, not a module name. The
{% if project_type %} enabled modules are already shown as chips in the page header. #}
{{ project_type.name }} {% set _idbits = [] %}
{% else %} {% if project.project_number %}{% set _ = _idbits.append(project.project_number) %}{% endif %}
Project {% if project.client_name %}{% set _ = _idbits.append(project.client_name) %}{% endif %}
{% if _idbits %}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _idbits | join(' · ') }}</p>
{% endif %} {% endif %}
</p>
</div> </div>
{% if project.status == 'upcoming' %} {% if project.status == 'upcoming' %}
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span> <span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
@@ -37,6 +37,12 @@
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Vibration Monitoring Vibration Monitoring
{% else %}{{ m }}{% endif %} {% else %}{{ m }}{% endif %}
{% set mstatus = (module_status or {}).get(m, 'active') %}
{% if mstatus == 'completed' %}
<span class="ml-0.5 inline-flex items-center px-1.5 py-0 rounded-full text-[10px] font-semibold bg-blue-200 text-blue-900 dark:bg-blue-800/70 dark:text-blue-100" title="This module is marked completed">✓ Done</span>
{% elif mstatus == 'on_hold' %}
<span class="ml-0.5 inline-flex items-center px-1.5 py-0 rounded-full text-[10px] font-semibold bg-amber-200 text-amber-900 dark:bg-amber-800/70 dark:text-amber-100" title="This module is on hold">On hold</span>
{% endif %}
<button onclick="removeModule('{{ m }}')" class="ml-0.5 hover:text-red-500 transition-colors" title="Remove module"> <button onclick="removeModule('{{ m }}')" class="ml-0.5 hover:text-red-500 transition-colors" title="Remove module">
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg> <svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
</button> </button>
@@ -47,50 +53,11 @@
Add Module Add Module
</button> </button>
</div> </div>
{% if project.data_collection_mode == 'remote' %}
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
Remote
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
</svg>
Manual
</span>
{% endif %}
</div> </div>
</div> </div>
<!-- Project Actions --> <!-- Project Actions — project-level only. Sound-specific actions
(Combined Report, Night Report, Report Settings) live in the Sound tab. -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{% if 'sound_monitoring' in modules %}
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Generate Combined Report
</a>
<button onclick="openNightReportModal()"
title="Last night's noise vs baseline, per location (FTP report pipeline)"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 text-sm">
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
Night Report
</button>
<button onclick="openReportSettings('{{ project.id }}')"
title="Nightly report settings — schedule, baseline range, recipients"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
{% endif %}
<button onclick="openMergeModal()" <button onclick="openMergeModal()"
title="Merge this project into another (consolidates duplicates)" title="Merge this project into another (consolidates duplicates)"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm"> class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm">
+86 -70
View File
@@ -1,92 +1,108 @@
<!-- Project List Grid --> <!-- Project List Grid -->
{% if projects %} {% if projects %}
{% for item in projects %} {% for item in projects %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg hover:shadow-xl transition-shadow"> {% set p = item.project %}
<a href="/projects/{{ item.project.id }}" class="block p-6"> {% set mods = item.modules or [] %}
<!-- Project Header --> {% set mstatus = item.module_status or {} %}
<div class="flex items-start justify-between mb-4"> {% set has_sound = 'sound_monitoring' in mods %}
<div class="flex-1"> {% set has_vib = 'vibration_monitoring' in mods %}
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1"> <div class="group relative flex flex-col bg-white dark:bg-slate-800 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 overflow-hidden">
{{ item.project.name }} <!-- Accent strip — reflects the project's module mix -->
</h3> <div class="absolute inset-x-0 top-0 h-1
<p class="text-sm text-gray-500 dark:text-gray-400 flex items-center"> {% if has_sound and has_vib %}bg-gradient-to-r from-seismo-orange to-blue-500
{% if item.project_type %} {% elif has_sound %}bg-seismo-orange
{% if item.project_type.id == 'sound_monitoring' %} {% elif has_vib %}bg-blue-500
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {% else %}bg-gray-300 dark:bg-gray-600{% endif %}"></div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
{% elif item.project_type.id == 'vibration_monitoring' %}
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
{% else %}
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"></path>
</svg>
{% endif %}
{{ item.project_type.name }}
{% endif %}
</p>
</div>
<!-- Status Badge --> <a href="/projects/{{ p.id }}" class="block flex-1 p-6 pt-7">
{% if item.project.status == 'active' %} <!-- Header: name + identity + status -->
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full"> <div class="flex items-start justify-between gap-3 mb-3">
Active <div class="min-w-0">
</span> <h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ p.name }}</h3>
{% elif item.project.status == 'on_hold' %} {% set idbits = [] %}
<span class="px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full"> {% if p.project_number %}{% set _ = idbits.append(p.project_number) %}{% endif %}
On Hold {% if p.client_name %}{% set _ = idbits.append(p.client_name) %}{% endif %}
</span> {% if idbits %}
{% elif item.project.status == 'completed' %} <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">{{ idbits | join(' · ') }}</p>
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full"> {% endif %}
Completed </div>
</span> {% if p.status == 'active' %}
{% elif item.project.status == 'archived' %} <span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">Active</span>
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400 rounded-full"> {% elif p.status == 'upcoming' %}
Archived <span class="shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
</span> {% elif p.status == 'on_hold' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
{% elif p.status == 'completed' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">Completed</span>
{% elif p.status == 'archived' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400 rounded-full">Archived</span>
{% endif %} {% endif %}
</div> </div>
<!-- Project Description --> <!-- Module chips (with per-module status) -->
{% if item.project.description %} {% if mods %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2"> <div class="flex flex-wrap items-center gap-1.5 mb-3">
{{ item.project.description }} {% for m in mods %}
</p> {% set st = mstatus.get(m, 'active') %}
<span class="inline-flex items-center gap-1 pl-2 pr-2 py-0.5 rounded-full text-[11px] font-medium
{% if m == 'sound_monitoring' %}bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300
{% elif m == 'vibration_monitoring' %}bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{% if m == 'sound_monitoring' %}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
Sound
{% elif m == 'vibration_monitoring' %}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Vibration
{% else %}{{ m }}{% endif %}
{% if st == 'completed' %}<span class="text-blue-600 dark:text-blue-300" title="Completed"></span>
{% elif st == 'on_hold' %}<span class="opacity-70" title="On hold"></span>{% endif %}
</span>
{% endfor %}
</div>
{% endif %} {% endif %}
<!-- Project Stats --> <!-- Description -->
<div class="grid grid-cols-3 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700"> {% if p.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">{{ p.description }}</p>
{% endif %}
<!-- Stats -->
<div class="grid grid-cols-3 gap-3 pt-4 border-t border-gray-100 dark:border-gray-700/60">
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">Locations</p> <p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Locations</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.location_count }}</p> <p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.location_count }}</p>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">Units</p> <p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Units</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.unit_count }}</p> <p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.unit_count }}</p>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">Active</p> <p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Active</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white"> <p class="text-lg font-semibold {% if item.active_session_count > 0 %}text-green-600 dark:text-green-400{% else %}text-gray-900 dark:text-white{% endif %}">{{ item.active_session_count }}</p>
{% if item.active_session_count > 0 %}
<span class="text-green-600 dark:text-green-400">{{ item.active_session_count }}</span>
{% else %}
{{ item.active_session_count }}
{% endif %}
</p>
</div> </div>
</div> </div>
<!-- Client Info -->
{% if item.project.client_name %}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-500 dark:text-gray-400">
Client: <span class="font-medium text-gray-700 dark:text-gray-300">{{ item.project.client_name }}</span>
</p>
</div>
{% endif %}
</a> </a>
<!-- Per-module quick-open footer — jumps straight into that module's tab -->
{% if has_sound or has_vib %}
<div class="flex items-stretch border-t border-gray-100 dark:border-gray-700/60 divide-x divide-gray-100 dark:divide-gray-700/60">
{% if has_sound %}
<a href="/projects/{{ p.id }}#sound"
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-semibold text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors">
<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="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
Sound
</a>
{% endif %}
{% if has_vib %}
<a href="/projects/{{ p.id }}#vibration"
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors">
<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="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Vibration
</a>
{% endif %}
</div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
+191 -11
View File
@@ -131,6 +131,19 @@
<!-- Vibration Tab --> <!-- Vibration Tab -->
<div id="vibration-tab" class="tab-panel hidden"> <div id="vibration-tab" class="tab-panel hidden">
<!-- Vibration module toolbar — per-module status, scoped to this module -->
<div class="flex flex-wrap items-center gap-3 mb-5">
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-gray-700">
<span class="text-xs text-gray-500 dark:text-gray-400">Module status</span>
<select id="vibration-module-status" onchange="setModuleStatus('vibration_monitoring', this.value, this)"
class="text-sm font-medium bg-transparent border-0 focus:ring-0 text-gray-900 dark:text-white cursor-pointer py-0 pr-6">
<option value="active">Active</option>
<option value="on_hold">On hold</option>
<option value="completed">Completed</option>
</select>
</div>
</div>
<!-- Vibration sub-nav --> <!-- Vibration sub-nav -->
<div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700"> <div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700">
<button id="vib-sub-locations-btn" onclick="switchVibSubTab('locations')" <button id="vib-sub-locations-btn" onclick="switchVibSubTab('locations')"
@@ -202,6 +215,13 @@
<input type="date" id="pve-to" onchange="loadProjectVibrationEvents()" <input type="date" id="pve-to" onchange="loadProjectVibrationEvents()"
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"> 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>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Location</label>
<select id="pve-loc" onchange="_pveApplyAndRender()"
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 locations</option>
</select>
</div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Events</label> <label class="text-xs text-gray-500 dark:text-gray-400">Events</label>
<select id="pve-ft" onchange="loadProjectVibrationEvents()" <select id="pve-ft" onchange="loadProjectVibrationEvents()"
@@ -234,6 +254,46 @@
<!-- Sound Tab --> <!-- Sound Tab -->
<div id="sound-tab" class="tab-panel hidden"> <div id="sound-tab" class="tab-panel hidden">
<!-- Sound module toolbar — per-module status + sound-only actions
(relocated here from the global project header). -->
<div class="flex flex-wrap items-center gap-3 mb-5">
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-gray-700">
<span class="text-xs text-gray-500 dark:text-gray-400">Module status</span>
<select id="sound-module-status" onchange="setModuleStatus('sound_monitoring', this.value, this)"
class="text-sm font-medium bg-transparent border-0 focus:ring-0 text-gray-900 dark:text-white cursor-pointer py-0 pr-6">
<option value="active">Active</option>
<option value="on_hold">On hold</option>
<option value="completed">Completed</option>
</select>
</div>
<span id="sound-mode-chip" class="hidden items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium"></span>
<div class="ml-auto flex flex-wrap items-center gap-2">
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
class="px-3.5 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Generate Combined Report
</a>
<button onclick="openNightReportModal()"
title="Last night's noise vs baseline, per location (FTP report pipeline)"
class="px-3.5 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 text-sm">
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
Night Report
</button>
<button onclick="openReportSettings('{{ project_id }}')"
title="Nightly report settings — schedule, baseline range, recipients"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
</div>
</div>
<!-- Sound sub-nav --> <!-- Sound sub-nav -->
<div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700"> <div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700">
<button id="sound-sub-locations-btn" onclick="switchSoundSubTab('locations')" <button id="sound-sub-locations-btn" onclick="switchSoundSubTab('locations')"
@@ -1041,11 +1101,15 @@ function switchVibSubTab(name) {
// ── Vibration Events sub-tab ───────────────────────────────────────────── // ── Vibration Events sub-tab ─────────────────────────────────────────────
let _projectEventsLoaded = false; let _projectEventsLoaded = false;
let _pveAllEvents = []; // full fetched set (before client-side location filter)
let _pveTotal = 0; // project-wide count reported by the API
let _pveSort = { key: 'timestamp', dir: 'desc' };
function clearProjectEventFilters() { function clearProjectEventFilters() {
document.getElementById('pve-from').value = ''; document.getElementById('pve-from').value = '';
document.getElementById('pve-to').value = ''; document.getElementById('pve-to').value = '';
document.getElementById('pve-ft').value = ''; document.getElementById('pve-ft').value = '';
const loc = document.getElementById('pve-loc'); if (loc) loc.value = '';
loadProjectVibrationEvents(); loadProjectVibrationEvents();
} }
@@ -1057,6 +1121,8 @@ function _pvePPVClass(v) {
return 'text-green-600 dark:text-green-400'; return 'text-green-600 dark:text-green-400';
} }
// Date range / FT / limit are server-side filters → re-fetch. Location and
// column sorting are applied client-side over the cached set so they're instant.
async function loadProjectVibrationEvents() { async function loadProjectVibrationEvents() {
const container = document.getElementById('pve-container'); const container = document.getElementById('pve-container');
if (!container) return; if (!container) return;
@@ -1076,13 +1142,81 @@ async function loadProjectVibrationEvents() {
const r = await fetch(`/api/projects/${projectId}/vibration-events?${params.toString()}`); const r = await fetch(`/api/projects/${projectId}/vibration-events?${params.toString()}`);
if (!r.ok) throw new Error('HTTP ' + r.status); if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json(); const d = await r.json();
_renderProjectEvents(d.events || [], d.count || 0, container); _pveAllEvents = d.events || [];
_pveTotal = d.count || 0;
_pvePopulateLocations();
_pveApplyAndRender();
} catch (e) { } catch (e) {
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${lsEsc(e.message)}</div>`; container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${lsEsc(e.message)}</div>`;
} }
} }
function _renderProjectEvents(events, total, container) { // Rebuild the Location dropdown from whatever locations actually have events in
// the current fetch, preserving the operator's current selection if still valid.
function _pvePopulateLocations() {
const sel = document.getElementById('pve-loc');
if (!sel) return;
const prev = sel.value;
const seen = new Map();
_pveAllEvents.forEach(ev => {
if (ev.location_id && !seen.has(ev.location_id)) seen.set(ev.location_id, ev.location_name || ev.location_id);
});
const opts = ['<option value="">All locations</option>'];
[...seen.entries()]
.sort((a, b) => String(a[1]).localeCompare(String(b[1])))
.forEach(([id, name]) => opts.push(`<option value="${lsEsc(id)}">${lsEsc(name)}</option>`));
sel.innerHTML = opts.join('');
if (prev && seen.has(prev)) sel.value = prev;
}
function _pveSortBy(key) {
if (_pveSort.key === key) {
_pveSort.dir = (_pveSort.dir === 'asc') ? 'desc' : 'asc';
} else {
_pveSort.key = key;
_pveSort.dir = 'desc'; // numbers + dates most useful high→low first
}
_pveApplyAndRender();
}
const _PVE_NUM_KEYS = new Set(['tran_ppv', 'vert_ppv', 'long_ppv', 'peak_vector_sum', 'mic_ppv']);
const _PVE_STR_KEYS = new Set(['location_name', 'serial']);
function _pveApplyAndRender() {
const container = document.getElementById('pve-container');
if (!container) return;
const locId = document.getElementById('pve-loc')?.value || '';
let rows = locId ? _pveAllEvents.filter(ev => ev.location_id === locId) : _pveAllEvents.slice();
const { key, dir } = _pveSort;
const mul = dir === 'asc' ? 1 : -1;
rows.sort((a, b) => {
if (_PVE_NUM_KEYS.has(key)) {
const av = (a[key] == null) ? -Infinity : Number(a[key]);
const bv = (b[key] == null) ? -Infinity : Number(b[key]);
return (av - bv) * mul;
}
if (_PVE_STR_KEYS.has(key)) {
return String(a[key] || '').toLowerCase().localeCompare(String(b[key] || '').toLowerCase()) * mul;
}
// timestamp — ISO strings sort lexicographically
return String(a.timestamp || '').localeCompare(String(b.timestamp || '')) * mul;
});
_renderProjectEvents(rows, container, locId);
}
function _pveTh(label, key, align) {
const active = _pveSort.key === key;
const arrow = active ? (_pveSort.dir === 'asc' ? '▲' : '▼') : '<span class="opacity-0 group-hover:opacity-40">▼</span>';
const alignCls = align === 'right' ? 'text-right' : 'text-left';
return `<th onclick="_pveSortBy('${key}')"
class="group px-4 py-3 text-xs font-medium uppercase tracking-wider cursor-pointer select-none ${alignCls} ${active ? 'text-seismo-orange' : 'text-gray-700 dark:text-gray-300 hover:text-seismo-orange'}">
${label}<span class="ml-1 inline-block text-[10px] text-seismo-orange">${arrow}</span></th>`;
}
function _renderProjectEvents(events, container, locId) {
if (!events.length) { if (!events.length) {
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No events for the current filter.</div>'; container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No events for the current filter.</div>';
return; return;
@@ -1106,19 +1240,22 @@ function _renderProjectEvents(events, total, container) {
<td class="px-4 py-2.5 text-sm">${ft}</td> <td class="px-4 py-2.5 text-sm">${ft}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
const scope = locId
? `Showing ${events.length.toLocaleString()} event${events.length === 1 ? '' : 's'} at this location`
: `Showing ${events.length.toLocaleString()} of ${_pveTotal.toLocaleString()} events`;
container.innerHTML = ` container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 px-1 pb-2">Showing ${events.length} of ${total.toLocaleString()} events</div> <div class="text-xs text-gray-500 dark:text-gray-400 px-1 pb-2">${scope}</div>
<table class="w-full text-left"> <table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600"> <thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr> <tr>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th> ${_pveTh('Timestamp', 'timestamp')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th> ${_pveTh('Location', 'location_name')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Serial</th> ${_pveTh('Serial', 'serial')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th> ${_pveTh('Tran', 'tran_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th> ${_pveTh('Vert', 'vert_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th> ${_pveTh('Long', 'long_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th> ${_pveTh('PVS', 'peak_vector_sum')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Mic</th> ${_pveTh('Mic', 'mic_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th> <th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
</tr> </tr>
</thead> </thead>
@@ -1140,6 +1277,41 @@ function switchSoundSubTab(name) {
} }
} }
// ── Per-module status (active / on_hold / completed) ─────────────────────
// Each module has its own lifecycle independent of the parent project, so the
// sound side can be "completed" while vibration keeps running.
const _MODULE_STATUS_LABEL = { active: 'Active', on_hold: 'On hold', completed: 'Completed' };
async function setModuleStatus(moduleType, status, selectEl) {
const prev = selectEl ? selectEl.getAttribute('data-prev') : null;
try {
const r = await fetch(`/api/projects/${projectId}/modules/${moduleType}/status`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!r.ok) throw new Error('HTTP ' + r.status);
if (selectEl) selectEl.setAttribute('data-prev', status);
// Refresh the header so the module chip's status badge updates.
if (window.htmx) htmx.ajax('GET', `/api/projects/${projectId}/header`, { target: '#project-header', swap: 'innerHTML' });
const name = (moduleType === 'sound_monitoring') ? 'Sound' : 'Vibration';
if (window.showToast) showToast(`${name} module marked ${_MODULE_STATUS_LABEL[status] || status}.`, 'success');
} catch (e) {
if (selectEl && prev) selectEl.value = prev; // revert the dropdown on failure
if (window.showToast) showToast('Could not update module status.', 'error');
else alert('Could not update module status.');
}
}
function _renderSoundModeChip(mode) {
const chip = document.getElementById('sound-mode-chip');
if (!chip) return;
const remote = mode === 'remote';
chip.classList.remove('hidden');
chip.className = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ' +
(remote ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300');
chip.textContent = remote ? 'Remote — data via FTP' : 'Manual — SD card upload';
}
// Load project details // Load project details
async function loadProjectDetails() { async function loadProjectDetails() {
try { try {
@@ -1169,6 +1341,14 @@ async function loadProjectDetails() {
if (modeRadio) modeRadio.checked = true; if (modeRadio) modeRadio.checked = true;
settingsUpdateModeStyles(); settingsUpdateModeStyles();
// Per-module status selects + the (sound-scoped) data-collection chip.
const ms = data.module_status || {};
const ssel = document.getElementById('sound-module-status');
if (ssel) { ssel.value = ms.sound_monitoring || 'active'; ssel.setAttribute('data-prev', ssel.value); }
const vsel = document.getElementById('vibration-module-status');
if (vsel) { vsel.value = ms.vibration_monitoring || 'active'; vsel.setAttribute('data-prev', vsel.value); }
_renderSoundModeChip(mode);
// Show/hide module tabs based on active modules // Show/hide module tabs based on active modules
const hasSoundModule = projectModules.includes('sound_monitoring'); const hasSoundModule = projectModules.includes('sound_monitoring');
const hasVibrationModule = projectModules.includes('vibration_monitoring'); const hasVibrationModule = projectModules.includes('vibration_monitoring');