Files
terra-view/templates/projects/detail.html
T
serversdown 5b70dcf071 feat: make Overview live tiles link to NRL detail
Each live monitoring tile is now a clickable link to its NRL detail page
(/projects/{id}/nrl/{location_id}) — same target as the NRL card name — with
a hover border + lift affordance so it reads as clickable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:01:12 +00:00

2415 lines
123 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Project Dashboard - Terra-View{% endblock %}
{% block content %}
<!-- Breadcrumb Navigation -->
<div class="mb-6 flex items-center justify-between gap-3">
<nav class="flex items-center space-x-2 text-sm">
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
<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="M15 19l-7-7 7-7"></path>
</svg>
Projects
</a>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
</nav>
<!-- Client portal access for this project -->
<div class="shrink-0 flex items-center gap-2">
<button type="button" onclick="openPortalAccess()"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-600 bg-slate-700/40 text-gray-200 hover:bg-slate-700 transition-colors"
title="Manage this project's client portal access">
<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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
Portal access
</button>
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
title="Preview this project's client portal in a new tab">
<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 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
Preview
</a>
</div>
</div>
<!-- Header (loads dynamically) -->
<div id="project-header" hx-get="/api/projects/{{ project_id }}/header" hx-trigger="load" hx-swap="innerHTML">
<div class="mb-8 animate-pulse">
<div class="h-10 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4"></div>
</div>
</div>
<!-- Tab Navigation -->
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav class="flex space-x-6 overflow-x-auto">
<button onclick="switchTab('overview')"
data-tab="overview"
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange whitespace-nowrap">
Overview
</button>
<button id="vibration-tab-btn" onclick="switchTab('vibration')"
data-tab="vibration"
class="tab-button hidden px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
<svg class="w-4 h-4 inline mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
Vibration
</button>
<button id="sound-tab-btn" onclick="switchTab('sound')"
data-tab="sound"
class="tab-button hidden px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
<svg class="w-4 h-4 inline mr-1.5" 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 6a7 7 0 010 14M8.464 8.464a5 5 0 000 7.072"/>
</svg>
Sound
</button>
<button onclick="switchTab('settings')"
data-tab="settings"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
Settings
</button>
</nav>
</div>
<!-- Tab Content -->
<div id="tab-content">
<!-- Overview Tab -->
<div id="overview-tab" class="tab-panel">
<!-- Live monitoring (sound) — self-contained, refreshes itself every 15s.
Kept OUTSIDE #project-dashboard so the 30s htmx swap below never
clobbers its DOM/timer. Shown only for projects with sound NRLs. -->
<div id="live-stats-section" class="hidden mb-6">
<div class="flex flex-wrap items-center gap-2.5 mb-4">
<span class="text-[10px] uppercase tracking-[0.18em] text-seismo-orange/90 font-semibold">Live monitoring</span>
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-seismo-orange opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-seismo-orange"></span>
</span>
<b id="ls-live" class="text-lg font-semibold text-seismo-orange tabular-nums"></b>
<span class="text-xs text-gray-500 dark:text-gray-400">live</span>
</div>
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
<b id="ls-offline" class="text-lg font-semibold text-gray-700 dark:text-gray-200 tabular-nums"></b>
<span class="text-xs text-gray-500 dark:text-gray-400">offline</span>
</div>
<div id="ls-loudest-wrap" class="hidden inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
<span class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Loudest now</span>
<b id="ls-loudest" class="text-lg font-semibold text-seismo-orange tabular-nums"></b>
<span class="text-xs text-gray-500 dark:text-gray-400">dB</span>
<span id="ls-loudest-loc" class="text-xs text-gray-500 dark:text-gray-400"></span>
</div>
<span class="text-[11px] text-gray-400 dark:text-gray-500 ml-auto">auto-refresh 15s</span>
</div>
<div id="ls-tiles" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"></div>
</div>
<div id="project-dashboard"
hx-get="/api/projects/{{ project_id }}/dashboard"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="animate-pulse space-y-4">
<div class="h-24 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
</div>
</div>
</div>
</div>
<!-- Vibration Tab -->
<div id="vibration-tab" class="tab-panel hidden">
<!-- Vibration sub-nav -->
<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')"
class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap">
Locations
</button>
<!-- Future sub-tabs: Sessions, Data Files -->
</div>
<!-- Vibration Locations sub-panel -->
<div id="vib-sub-locations" class="vib-sub-panel">
<!-- Project-wide vibration events roll-up -->
<div id="vibration-summary"
hx-get="/api/projects/{{ project_id }}/vibration_summary"
hx-trigger="load"
hx-swap="innerHTML"
class="mb-5">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Loading project summary…</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 lg:col-span-2">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
<button onclick="openLocationModal('vibration')"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Location
</button>
</div>
<div id="vibration-locations"
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading locations...</div>
</div>
</div>
{# Reusable location map — fetches from /locations-json
on its own. Hovering any of the location cards on the
left highlights the matching pin on this map. #}
<div>
{% with project_id=project_id, location_type='vibration', map_height='450px' %}
{% include 'partials/projects/location_map.html' %}
{% endwith %}
</div>
</div>
</div>
</div>
<!-- Sound Tab -->
<div id="sound-tab" class="tab-panel hidden">
<!-- Sound sub-nav -->
<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')"
class="sound-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap">
NRLs
</button>
<button id="sound-sub-sessions-btn" onclick="switchSoundSubTab('sessions')"
class="sound-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
Sessions
</button>
<button id="sound-sub-data-btn" onclick="switchSoundSubTab('data')"
class="sound-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
Data Files
</button>
<button id="sound-sub-units-btn" onclick="switchSoundSubTab('units')"
class="sound-sub-tab hidden px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
Assigned Units
</button>
<button id="sound-sub-schedules-btn" onclick="switchSoundSubTab('schedules')"
class="sound-sub-tab hidden px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
Schedules
</button>
</div>
<!-- NRLs sub-panel -->
<div id="sound-sub-locations" class="sound-sub-panel">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Noise Recording Locations</h2>
<button onclick="openLocationModal('sound')"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add NRL
</button>
</div>
<div id="sound-locations"
hx-get="/api/projects/{{ project_id }}/locations?location_type=sound"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading locations...</div>
</div>
</div>
</div>
<!-- Sessions sub-panel -->
<div id="sound-sub-sessions" class="sound-sub-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
<select id="sessions-filter" onchange="filterSessions()"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
<option value="all">All Sessions</option>
<option value="recording">Recording</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
<div id="project-sessions"
hx-get="/api/projects/{{ project_id }}/sessions"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
</div>
</div>
<!-- Monthly Calendar -->
<div class="mt-6 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Calendar View</h3>
<div id="sessions-calendar"
hx-get="/api/projects/{{ project_id }}/sessions-calendar"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-6 text-gray-400 text-sm">Loading calendar…</div>
</div>
</div>
</div>
<!-- Data Files sub-panel -->
<div id="sound-sub-data" class="sound-sub-panel hidden">
<!-- FTP File Browser (remote projects only) -->
<div id="ftp-browser" class="mb-6"
hx-get="/api/projects/{{ project_id }}/ftp-browser"
hx-trigger="load"
hx-swap="innerHTML">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="text-center py-8 text-gray-500">Loading FTP browser...</div>
</div>
</div>
<!-- Unified Files View -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Project Files</h2>
<div class="flex items-center gap-3">
<button onclick="toggleUploadAll()"
class="px-3 py-2 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
Upload Data
</button>
<button onclick="htmx.trigger('#unified-files', 'refresh')"
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<svg class="w-4 h-4 inline mr-1" 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>
Refresh
</button>
</div>
</div>
</div>
<!-- Upload Data Panel -->
<div id="upload-all-panel" class="hidden border-b border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bulk Import — Select Folder</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Select your data folder directly — no zipping needed. Expected structure:
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">[date]/[NRL name]/[Auto_####]/</code>.
NRL folders are matched to locations by name. MP3s are stored; Excel exports are skipped.
</p>
<div class="flex flex-wrap items-center gap-3">
<input type="file" id="upload-all-input"
webkitdirectory directory multiple
class="block text-sm text-gray-500 dark:text-gray-400
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
hover:file:bg-seismo-navy file:cursor-pointer" />
<span id="upload-all-file-count" class="text-xs text-gray-500 dark:text-gray-400 hidden"></span>
<button id="upload-all-btn" onclick="submitUploadAll()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import
</button>
<button id="upload-all-cancel-btn" onclick="toggleUploadAll()"
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
Cancel
</button>
<span id="upload-all-status" class="text-sm hidden"></span>
</div>
<div id="upload-all-progress-wrap" class="hidden mt-3">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span id="upload-all-progress-label">Uploading…</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="upload-all-progress-bar"
class="bg-green-500 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
</div>
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
</div>
</div>
<div id="unified-files"
hx-get="/api/projects/{{ project_id }}/files-unified"
hx-trigger="load, refresh from:#unified-files"
hx-swap="innerHTML">
<div class="px-6 py-12 text-center text-gray-500">Loading files...</div>
</div>
</div>
</div>
<!-- Assigned Units sub-panel (remote only) -->
<div id="sound-sub-units" class="sound-sub-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Assigned Units</h2>
<div class="text-sm text-gray-500">Units currently assigned to this project's NRLs</div>
</div>
<div id="project-units"
hx-get="/api/projects/{{ project_id }}/units"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading units...</div>
</div>
</div>
</div>
<!-- Schedules sub-panel (remote only) -->
<div id="sound-sub-schedules" class="sound-sub-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recurring Schedules</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Automated patterns that generate scheduled actions</p>
</div>
<button onclick="openScheduleModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Create Schedule
</button>
</div>
<div id="recurring-schedule-list"
hx-get="/api/projects/{{ project_id }}/recurring-schedules/partials/list"
hx-trigger="load, refresh from:#recurring-schedule-list"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading recurring schedules...</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 id="schedules-title" class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
<p id="schedules-subtitle" class="text-sm text-gray-500 dark:text-gray-400 mt-1">Scheduled start/stop/download actions</p>
</div>
<select id="schedules-filter" onchange="filterScheduledActions()"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
<option value="pending">Pending</option>
<option value="all">All</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
<div id="project-schedules"
hx-get="/api/projects/{{ project_id }}/schedules?status=pending"
hx-trigger="load, every 30s, refresh from:#project-schedules"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading scheduled actions...</div>
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Project Settings</h2>
<form id="project-settings-form" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project Name</label>
<input type="text" name="name" id="settings-name"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<textarea name="description" id="settings-description" rows="3"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Name</label>
<input type="text" name="client_name" id="settings-client-name"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
<select name="status" id="settings-status"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="upcoming">Upcoming</option>
<option value="active">Active</option>
<option value="on_hold">On Hold</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Address</label>
<input type="text" name="site_address" id="settings-site-address"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Coordinates</label>
<input type="text" name="site_coordinates" id="settings-site-coordinates" placeholder="40.7128,-74.0060"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Start Date</label>
<input type="date" name="start_date" id="settings-start-date"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">End Date</label>
<input type="date" name="end_date" id="settings-end-date"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
</div>
<div id="sound-settings-section" class="hidden">
<div class="border-t border-gray-200 dark:border-gray-700 pt-5 mb-4">
<h3 class="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<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 6a7 7 0 010 14M8.464 8.464a5 5 0 000 7.072"/>
</svg>
Sound Monitoring
</h3>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Data Collection</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-manual-label">
<input type="radio" name="data_collection_mode" id="settings-mode-manual" value="manual"
onchange="settingsUpdateModeStyles()"
class="mt-0.5 accent-seismo-orange shrink-0">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Manual</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SD card retrieved daily</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-remote-label">
<input type="radio" name="data_collection_mode" id="settings-mode-remote" value="remote"
onchange="settingsUpdateModeStyles()"
class="mt-0.5 accent-seismo-orange shrink-0">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Remote</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Modem, data pulled via FTP</p>
</div>
</label>
</div>
</div>
</div>
<div id="settings-success" class="hidden text-sm text-green-600 dark:text-green-400"></div>
<div id="settings-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="loadProjectDetails()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Reset
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Save Changes
</button>
</div>
</form>
<!-- Danger Zone -->
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
<div class="space-y-3">
<!-- On Hold -->
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Put Project On Hold</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Pause this project without archiving. Assignments and schedules remain in place.</p>
</div>
<div id="hold-btn-container" class="shrink-0">
<!-- Rendered by updateDangerZone() based on current status -->
</div>
</div>
<!-- Archive -->
<div class="bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 rounded-lg p-4 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Archive Project</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Remove from active listings. All data is preserved and can be restored.</p>
</div>
<button onclick="archiveProject()"
class="shrink-0 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm">
Archive
</button>
</div>
<!-- Delete -->
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Delete Project</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Permanently removes all project data after a 60-day grace period. This action is difficult to undo.</p>
</div>
<button onclick="openDeleteModal()"
class="shrink-0 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
Delete
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Location Modal -->
<div id="location-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 id="location-modal-title" class="text-2xl font-bold text-gray-900 dark:text-white">Add Location</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Create or update a monitoring location</p>
</div>
<button onclick="closeLocationModal()" 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="location-form" class="p-6 space-y-4">
<input type="hidden" id="location-id">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
<input type="text" name="name" id="location-name"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<textarea name="description" id="location-description" rows="3"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label>
<select name="location_type" id="location-type" onchange="updateConnectionModeVisibility()"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="sound">Sound</option>
<option value="vibration">Vibration</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
<input type="text" name="coordinates" id="location-coordinates" placeholder="40.7128,-74.0060"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
</div>
<!-- Connection Mode — sound locations only -->
<div id="connection-mode-field">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connection Mode</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange rounded-lg cursor-pointer bg-orange-50 dark:bg-orange-900/10" id="mode-connected-label">
<input type="radio" name="connection_mode" value="connected" checked
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
<div>
<div class="font-medium text-gray-900 dark:text-white text-sm">Connected</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Remote unit accessible via modem. Supports live control and FTP download.</div>
</div>
</label>
<label class="flex items-start gap-3 p-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="mode-offline-label">
<input type="radio" name="connection_mode" value="offline"
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
<div>
<div class="font-medium text-gray-900 dark:text-white text-sm">Offline / Manual Upload</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No network access. Data collected from SD card and uploaded manually.</div>
</div>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="address" id="location-address"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div id="location-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeLocationModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Save Location
</button>
</div>
</form>
</div>
</div>
<!-- Schedule Modal -->
<div id="schedule-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create Recurring Schedule</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Set up automated monitoring schedules</p>
</div>
<button onclick="closeScheduleModal()" 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="schedule-form" class="p-6 space-y-6">
<!-- Schedule Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Schedule Name</label>
<input type="text" name="schedule_name" id="schedule-name"
placeholder="e.g., Weeknight Monitoring"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
</div>
<!-- Location Selection (Multiple) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Locations
<span class="text-xs font-normal text-gray-500 ml-2">(select one or more)</span>
</label>
<div id="schedule-locations-container"
class="max-h-48 overflow-y-auto border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2 space-y-1">
<div class="text-gray-500 text-sm py-2 text-center">Loading locations...</div>
</div>
<p id="schedule-location-empty" class="hidden text-xs text-gray-500 mt-2">
No locations available. Create a location first.
</p>
<p id="schedule-location-error" class="hidden text-xs text-red-500 mt-2">
Please select at least one location.
</p>
</div>
<!-- Schedule Type Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Schedule Type</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label class="relative cursor-pointer">
<input type="radio" name="schedule_type" value="weekly_calendar" class="peer sr-only" checked onchange="toggleScheduleType('weekly_calendar')">
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
<div class="flex items-center gap-3 mb-2">
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">Weekly Calendar</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Select specific days with start/end times. Ideal for weeknight monitoring (Mon-Fri 7pm-7am).
</p>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" name="schedule_type" value="simple_interval" class="peer sr-only" onchange="toggleScheduleType('simple_interval')">
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
<div class="flex items-center gap-3 mb-2">
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" 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"/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">24/7 Continuous</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Continuous monitoring with daily stop/download/restart cycle at a set time.
</p>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" name="schedule_type" value="one_off" class="peer sr-only" onchange="toggleScheduleType('one_off')">
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
<div class="flex items-center gap-3 mb-2">
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
</p>
</div>
</label>
</div>
</div>
<!-- Weekly Calendar Editor -->
<div id="schedule-weekly-wrapper">
{% include "partials/projects/schedule_calendar.html" %}
</div>
<!-- Simple Interval Editor -->
<div id="schedule-interval-wrapper" class="hidden">
{% include "partials/projects/schedule_interval.html" %}
</div>
<!-- One-Off Editor -->
<div id="schedule-oneoff-wrapper" class="hidden">
{% include "partials/projects/schedule_oneoff.html" %}
</div>
<!-- Timezone -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timezone</label>
<select name="timezone" id="schedule-timezone"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="America/New_York">Eastern (America/New_York)</option>
<option value="America/Chicago">Central (America/Chicago)</option>
<option value="America/Denver">Mountain (America/Denver)</option>
<option value="America/Los_Angeles">Pacific (America/Los_Angeles)</option>
<option value="UTC">UTC</option>
</select>
</div>
<div id="schedule-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeScheduleModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Create Schedule
</button>
</div>
</form>
</div>
</div>
<!-- Assign Unit Modal -->
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a device to this location</p>
</div>
<button onclick="closeAssignModal()" 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="assign-form" class="p-6 space-y-4">
<input type="hidden" id="assign-location-id">
<input type="hidden" id="assign-location-type">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
<select id="assign-unit-id" name="unit_id"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
<option value="">Select a unit</option>
</select>
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available units match this location type.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="assign-notes" name="notes" rows="2"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
</div>
<div id="assign-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeAssignModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Assign Unit
</button>
</div>
</form>
</div>
</div>
<!-- Remove Location Confirmation Modal —
Soft-removal: preserves historical events, closes active assignments,
cancels pending scheduled actions. Distinct from Delete (which is
permanent and only allowed when there's no history). -->
<div id="remove-location-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2a4 4 0 014-4h6m0 0l-3-3m3 3l-3 3M5 7h8a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove location</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Mark <span id="remove-location-name" class="font-semibold text-gray-900 dark:text-white"></span> as no longer actively monitored.
</p>
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-4 space-y-1 ml-4 list-disc">
<li>Closes any active unit assignment at this location</li>
<li>Cancels pending scheduled actions at this location</li>
<li>Historical events stay attributed (visible in reports + event lists)</li>
<li>Can be restored later if needed</li>
</ul>
<input type="hidden" id="remove-location-id">
<div class="mb-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Effective date</label>
<input type="datetime-local" id="remove-location-effective"
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Defaults to now. Backdate if the location was physically removed earlier.</p>
</div>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
<input type="text" id="remove-location-reason" maxlength="200"
placeholder="e.g. client dropped from scope"
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div id="remove-location-error" class="hidden text-sm text-red-600 mb-3"></div>
<div class="flex justify-end gap-2">
<button onclick="closeRemoveLocationModal()"
class="px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button onclick="confirmRemoveLocation()"
class="px-4 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
Remove
</button>
</div>
</div>
</div>
<!-- Delete Project Confirmation Modal -->
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-100 dark:bg-red-900/40 rounded-lg">
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
</svg>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Delete Project</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
This project will be soft-deleted and <strong class="text-gray-900 dark:text-white">permanently removed after 60 days</strong>. All associated locations, assignments, and sessions will be lost.
</p>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
Type <span class="font-mono font-bold text-red-600 dark:text-red-400">delete</span> to confirm:
</p>
<input type="text" id="delete-confirm-input"
placeholder="type delete"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4 focus:outline-none focus:ring-2 focus:ring-red-500"
autocomplete="off">
<div class="flex gap-3 justify-end">
<button onclick="closeDeleteModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
Cancel
</button>
<button id="confirm-delete-btn" disabled onclick="executeDeleteProject()"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm disabled:opacity-40 disabled:cursor-not-allowed">
Delete Project
</button>
</div>
</div>
</div>
<script>
const projectId = "{{ project_id }}";
let editingLocationId = null;
let projectModules = []; // list of enabled module_type strings, e.g. ['sound_monitoring']
async function quickUpdateStatus(newStatus) {
try {
const response = await fetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ status: newStatus })
});
if (response.ok) {
// Reload the page to reflect new badge color and any side effects
window.location.reload();
} else {
alert('Failed to update status');
}
} catch (e) {
alert('Error updating status');
}
}
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
});
const panel = document.getElementById(`${tabName}-tab`);
if (panel) panel.classList.remove('hidden');
const button = document.querySelector(`[data-tab="${tabName}"]`);
if (button) {
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
button.classList.add('border-seismo-orange', 'text-seismo-orange');
}
history.replaceState(null, '', `#${tabName}`);
}
function switchVibSubTab(name) {
document.querySelectorAll('.vib-sub-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.vib-sub-tab').forEach(b => {
b.classList.remove('border-seismo-orange', 'text-seismo-orange');
b.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
});
document.getElementById(`vib-sub-${name}`)?.classList.remove('hidden');
const btn = document.getElementById(`vib-sub-${name}-btn`);
if (btn) {
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
btn.classList.add('border-seismo-orange', 'text-seismo-orange');
}
}
function switchSoundSubTab(name) {
document.querySelectorAll('.sound-sub-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.sound-sub-tab').forEach(b => {
b.classList.remove('border-seismo-orange', 'text-seismo-orange');
b.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
});
document.getElementById(`sound-sub-${name}`)?.classList.remove('hidden');
const btn = document.getElementById(`sound-sub-${name}-btn`);
if (btn) {
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
btn.classList.add('border-seismo-orange', 'text-seismo-orange');
}
}
// Load project details
async function loadProjectDetails() {
try {
const response = await fetch(`/api/projects/${projectId}`);
if (!response.ok) {
throw new Error('Failed to load project details');
}
const data = await response.json();
projectModules = data.modules || [];
// Update breadcrumb
document.getElementById('project-name-breadcrumb').textContent = data.name || 'Project';
// Update settings form
document.getElementById('settings-name').value = data.name || '';
document.getElementById('settings-description').value = data.description || '';
document.getElementById('settings-client-name').value = data.client_name || '';
document.getElementById('settings-status').value = data.status || 'active';
document.getElementById('settings-site-address').value = data.site_address || '';
document.getElementById('settings-site-coordinates').value = data.site_coordinates || '';
document.getElementById('settings-start-date').value = formatDate(data.start_date);
document.getElementById('settings-end-date').value = formatDate(data.end_date);
// Update data collection mode radio
const mode = data.data_collection_mode || 'manual';
const modeRadio = document.getElementById('settings-mode-' + mode);
if (modeRadio) modeRadio.checked = true;
settingsUpdateModeStyles();
// Show/hide module tabs based on active modules
const hasSoundModule = projectModules.includes('sound_monitoring');
const hasVibrationModule = projectModules.includes('vibration_monitoring');
const isRemote = mode === 'remote';
document.getElementById('vibration-tab-btn').classList.toggle('hidden', !hasVibrationModule);
document.getElementById('sound-tab-btn').classList.toggle('hidden', !hasSoundModule);
document.getElementById('sound-settings-section')?.classList.toggle('hidden', !hasSoundModule);
// Live monitoring section: only for sound projects (idempotent).
if (hasSoundModule) startLiveStats();
else document.getElementById('live-stats-section')?.classList.add('hidden');
// Within Sound: show Assigned Units + Schedules sub-tabs only for remote projects
document.getElementById('sound-sub-units-btn')?.classList.toggle('hidden', !isRemote);
document.getElementById('sound-sub-schedules-btn')?.classList.toggle('hidden', !isRemote);
// FTP browser: only show for remote projects
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
document.getElementById('settings-error').classList.add('hidden');
updateDangerZone();
} catch (err) {
console.error('Failed to load project details:', err);
}
}
function formatDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString().slice(0, 10);
}
function settingsUpdateModeStyles() {
const manualChecked = document.getElementById('settings-mode-manual')?.checked;
const manualLabel = document.getElementById('settings-mode-manual-label');
const remoteLabel = document.getElementById('settings-mode-remote-label');
if (!manualLabel || !remoteLabel) return;
if (manualChecked) {
manualLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
remoteLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.add('border-gray-300', 'dark:border-gray-600');
} else {
remoteLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
manualLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.add('border-gray-300', 'dark:border-gray-600');
}
}
// Project settings form submission
document.getElementById('project-settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
const payload = {
name: document.getElementById('settings-name').value.trim(),
description: document.getElementById('settings-description').value.trim() || null,
client_name: document.getElementById('settings-client-name').value.trim() || null,
status: document.getElementById('settings-status').value,
site_address: document.getElementById('settings-site-address').value.trim() || null,
site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null,
start_date: document.getElementById('settings-start-date').value || null,
end_date: document.getElementById('settings-end-date').value || null,
data_collection_mode: document.querySelector('input[name="data_collection_mode"]:checked')?.value || 'manual'
};
try {
const response = await fetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error('Failed to update project');
}
// Refresh header and dashboard without full page reload
refreshProjectDashboard();
// Show success feedback
const successEl = document.getElementById('settings-success');
successEl.textContent = 'Settings saved.';
successEl.classList.remove('hidden');
document.getElementById('settings-error').classList.add('hidden');
setTimeout(() => successEl.classList.add('hidden'), 3000);
} catch (err) {
const errorEl = document.getElementById('settings-error');
errorEl.textContent = err.message || 'Failed to update project.';
errorEl.classList.remove('hidden');
document.getElementById('settings-success').classList.add('hidden');
}
});
function refreshProjectDashboard() {
htmx.ajax('GET', `/api/projects/${projectId}/dashboard`, {
target: '#project-dashboard',
swap: 'innerHTML'
});
htmx.ajax('GET', `/api/projects/${projectId}/header`, {
target: '#project-header',
swap: 'innerHTML'
});
}
// Location modal functions
function updateConnectionModeVisibility() {
const locType = document.getElementById('location-type').value;
const field = document.getElementById('connection-mode-field');
if (field) field.classList.toggle('hidden', locType !== 'sound');
}
function updateModeLabels() {
const connected = document.querySelector('input[name="connection_mode"][value="connected"]');
const offline = document.querySelector('input[name="connection_mode"][value="offline"]');
const connLabel = document.getElementById('mode-connected-label');
const offLabel = document.getElementById('mode-offline-label');
if (!connected || !connLabel || !offLabel) return;
const activeClasses = ['border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/10'];
const inactiveClasses = ['border-gray-300', 'dark:border-gray-600'];
if (connected.checked) {
connLabel.classList.add(...activeClasses);
connLabel.classList.remove(...inactiveClasses);
offLabel.classList.remove(...activeClasses);
offLabel.classList.add(...inactiveClasses);
} else {
offLabel.classList.add(...activeClasses);
offLabel.classList.remove(...inactiveClasses);
connLabel.classList.remove(...activeClasses);
connLabel.classList.add(...inactiveClasses);
}
}
// Tracks the active location type tab so "Add Location" opens with the right type
let _activeLocationType = null;
function setActiveLocationType(type) {
_activeLocationType = type;
}
function openLocationModal(defaultType) {
defaultType = defaultType || _activeLocationType || defaultType;
editingLocationId = null;
document.getElementById('location-modal-title').textContent = 'Add Location';
document.getElementById('location-id').value = '';
document.getElementById('location-name').value = '';
document.getElementById('location-description').value = '';
document.getElementById('location-address').value = '';
document.getElementById('location-coordinates').value = '';
// Reset connection mode to connected
const connectedRadio = document.querySelector('input[name="connection_mode"][value="connected"]');
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div');
const hasSoundMod = projectModules.includes('sound_monitoring');
const hasVibMod = projectModules.includes('vibration_monitoring');
if (hasSoundMod && !hasVibMod) {
locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (hasVibMod && !hasSoundMod) {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else {
locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
locationTypeSelect.value = defaultType || 'sound';
}
updateConnectionModeVisibility();
document.getElementById('location-error').classList.add('hidden');
document.getElementById('location-modal').classList.remove('hidden');
}
function openEditLocationModal(button) {
const data = JSON.parse(button.dataset.location);
editingLocationId = data.id;
document.getElementById('location-modal-title').textContent = 'Edit Location';
document.getElementById('location-id').value = data.id;
document.getElementById('location-name').value = data.name || '';
document.getElementById('location-description').value = data.description || '';
document.getElementById('location-address').value = data.address || '';
document.getElementById('location-coordinates').value = data.coordinates || '';
// Restore connection mode from metadata
let savedMode = 'connected';
try { savedMode = JSON.parse(data.location_metadata || '{}').connection_mode || 'connected'; } catch(e) {}
const modeRadio = document.querySelector(`input[name="connection_mode"][value="${savedMode}"]`);
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div');
const hasSoundModE = projectModules.includes('sound_monitoring');
const hasVibModE = projectModules.includes('vibration_monitoring');
if (hasSoundModE && !hasVibModE) {
locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (hasVibModE && !hasSoundModE) {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else {
locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
locationTypeSelect.value = data.location_type || 'sound';
}
updateConnectionModeVisibility();
document.getElementById('location-error').classList.add('hidden');
document.getElementById('location-modal').classList.remove('hidden');
}
function closeLocationModal() {
document.getElementById('location-modal').classList.add('hidden');
}
document.getElementById('location-form').addEventListener('submit', async function(e) {
e.preventDefault();
const name = document.getElementById('location-name').value.trim();
const description = document.getElementById('location-description').value.trim();
const address = document.getElementById('location-address').value.trim();
const coordinates = document.getElementById('location-coordinates').value.trim();
let locationType = document.getElementById('location-type').value;
if (projectModules.includes('sound_monitoring') && !projectModules.includes('vibration_monitoring')) {
locationType = 'sound';
} else if (projectModules.includes('vibration_monitoring') && !projectModules.includes('sound_monitoring')) {
locationType = 'vibration';
}
const connectionMode = document.querySelector('input[name="connection_mode"]:checked')?.value || 'connected';
try {
if (editingLocationId) {
const payload = {
name,
description: description || null,
address: address || null,
coordinates: coordinates || null,
location_type: locationType,
location_metadata: JSON.stringify({ connection_mode: connectionMode }),
};
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to update location');
}
} else {
const formData = new FormData();
formData.append('name', name);
formData.append('description', description);
formData.append('address', address);
formData.append('coordinates', coordinates);
formData.append('location_type', locationType);
formData.append('location_metadata', JSON.stringify({ connection_mode: connectionMode }));
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to create location');
}
}
closeLocationModal();
refreshLocationLists();
refreshProjectDashboard();
} catch (err) {
const errorEl = document.getElementById('location-error');
errorEl.textContent = err.message || 'Failed to save location.';
errorEl.classList.remove('hidden');
}
});
function refreshLocationLists() {
htmx.ajax('GET', `/api/projects/${projectId}/locations?location_type=sound`, {
target: '#sound-locations', swap: 'innerHTML'
});
htmx.ajax('GET', `/api/projects/${projectId}/locations?location_type=vibration`, {
target: '#vibration-locations', swap: 'innerHTML'
});
}
async function deleteLocation(locationId) {
if (!confirm('Delete this location?')) return;
try {
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
method: 'DELETE'
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to delete location');
}
refreshLocationLists();
refreshProjectDashboard();
} catch (err) {
alert(err.message || 'Failed to delete location.');
}
}
// ── Remove / Restore location ────────────────────────────────────────
// Soft-removal: marks a location as no longer actively monitored without
// destroying it. Historical events stay attributed; active assignments
// are auto-closed and pending scheduled actions are auto-cancelled.
function openRemoveLocationModal(locationId, locationName) {
document.getElementById('remove-location-id').value = locationId;
document.getElementById('remove-location-name').textContent = locationName;
document.getElementById('remove-location-reason').value = '';
// Default effective_date to "now" in local datetime-input format.
const now = new Date();
const tzOffsetMin = now.getTimezoneOffset();
const local = new Date(now.getTime() - tzOffsetMin * 60000);
document.getElementById('remove-location-effective').value =
local.toISOString().slice(0, 16);
document.getElementById('remove-location-error').classList.add('hidden');
document.getElementById('remove-location-modal').classList.remove('hidden');
}
function closeRemoveLocationModal() {
document.getElementById('remove-location-modal').classList.add('hidden');
}
async function confirmRemoveLocation() {
const locationId = document.getElementById('remove-location-id').value;
const reason = document.getElementById('remove-location-reason').value.trim();
const effective = document.getElementById('remove-location-effective').value;
const errBox = document.getElementById('remove-location-error');
errBox.classList.add('hidden');
try {
const response = await fetch(
`/api/projects/${projectId}/locations/${locationId}/remove`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reason: reason || null,
effective_date: effective || null,
}),
}
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to remove location');
}
const result = await response.json();
closeRemoveLocationModal();
refreshLocationLists();
refreshProjectDashboard();
// Lightweight feedback — the UI refresh already shows the location
// moving to the Removed section, but a toast confirms the cascade.
if (typeof showToast === 'function') {
const bits = [];
if (result.assignments_closed) bits.push(`${result.assignments_closed} assignment(s) closed`);
if (result.actions_cancelled) bits.push(`${result.actions_cancelled} action(s) cancelled`);
const tail = bits.length ? ` (${bits.join(', ')})` : '';
showToast(`Location removed${tail}`, 'success');
}
} catch (err) {
errBox.textContent = err.message || 'Failed to remove location.';
errBox.classList.remove('hidden');
}
}
async function restoreLocation(locationId, locationName) {
if (!confirm(`Restore "${locationName}" to active monitoring?\n\nNote: previously-closed assignments are NOT automatically re-opened — you'll need to re-assign units if you want to resume monitoring.`)) {
return;
}
try {
const response = await fetch(
`/api/projects/${projectId}/locations/${locationId}/restore`,
{ method: 'POST' }
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to restore location');
}
refreshLocationLists();
refreshProjectDashboard();
if (typeof showToast === 'function') {
showToast(`"${locationName}" restored to active`, 'success');
}
} catch (err) {
alert(err.message || 'Failed to restore location.');
}
}
// Assign modal functions
function openAssignModal(locationId, locationType) {
const safeType = locationType || 'sound';
document.getElementById('assign-location-id').value = locationId;
document.getElementById('assign-location-type').value = safeType;
document.getElementById('assign-unit-id').innerHTML = '<option value="">Loading units...</option>';
document.getElementById('assign-empty').classList.add('hidden');
document.getElementById('assign-error').classList.add('hidden');
document.getElementById('assign-modal').classList.remove('hidden');
loadAvailableUnits(safeType);
}
function closeAssignModal() {
document.getElementById('assign-modal').classList.add('hidden');
}
async function loadAvailableUnits(locationType) {
try {
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=${locationType}`);
if (!response.ok) {
throw new Error('Failed to load available units');
}
const data = await response.json();
const select = document.getElementById('assign-unit-id');
select.innerHTML = '<option value="">Select a unit</option>';
if (!data.length) {
document.getElementById('assign-empty').classList.remove('hidden');
return;
}
data.forEach(unit => {
const option = document.createElement('option');
option.value = unit.id;
option.textContent = `${unit.id}${unit.model || unit.device_type}`;
select.appendChild(option);
});
} catch (err) {
const errorEl = document.getElementById('assign-error');
errorEl.textContent = err.message || 'Failed to load units.';
errorEl.classList.remove('hidden');
}
}
document.getElementById('assign-form').addEventListener('submit', async function(e) {
e.preventDefault();
const locationId = document.getElementById('assign-location-id').value;
const unitId = document.getElementById('assign-unit-id').value;
const notes = document.getElementById('assign-notes').value.trim();
if (!unitId) {
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
document.getElementById('assign-error').classList.remove('hidden');
return;
}
try {
const formData = new FormData();
formData.append('unit_id', unitId);
formData.append('notes', notes);
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to assign unit');
}
closeAssignModal();
refreshLocationLists();
refreshProjectDashboard();
} catch (err) {
const errorEl = document.getElementById('assign-error');
errorEl.textContent = err.message || 'Failed to assign unit.';
errorEl.classList.remove('hidden');
}
});
async function unassignUnit(assignmentId) {
if (!confirm('Unassign this unit?')) return;
try {
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
method: 'POST'
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to unassign unit');
}
refreshLocationLists();
refreshProjectDashboard();
} catch (err) {
alert(err.message || 'Failed to unassign unit.');
}
}
// Filter functions
function filterSessions() {
const filter = document.getElementById('sessions-filter').value;
const url = filter === 'all'
? `/api/projects/${projectId}/sessions`
: `/api/projects/${projectId}/sessions?status=${filter}`;
htmx.ajax('GET', url, {
target: '#project-sessions',
swap: 'innerHTML'
});
}
function filterFiles() {
const filter = document.getElementById('files-filter').value;
const url = filter === 'all'
? `/api/projects/${projectId}/files`
: `/api/projects/${projectId}/files?type=${filter}`;
htmx.ajax('GET', url, {
target: '#project-files',
swap: 'innerHTML'
});
}
function filterScheduledActions() {
const filter = document.getElementById('schedules-filter').value;
const url = filter === 'all'
? `/api/projects/${projectId}/schedules`
: `/api/projects/${projectId}/schedules?status=${filter}`;
// Update section title based on filter
const titleEl = document.getElementById('schedules-title');
const subtitleEl = document.getElementById('schedules-subtitle');
const titles = {
'pending': { title: 'Upcoming Actions', subtitle: 'Scheduled start/stop/download actions' },
'completed': { title: 'Completed Actions', subtitle: 'Successfully executed actions' },
'failed': { title: 'Failed Actions', subtitle: 'Actions that encountered errors' },
'all': { title: 'All Actions', subtitle: 'Complete action history' }
};
const config = titles[filter] || titles['all'];
titleEl.textContent = config.title;
subtitleEl.textContent = config.subtitle;
htmx.ajax('GET', url, {
target: '#project-schedules',
swap: 'innerHTML'
});
}
// Utility functions
function exportProjectData() {
window.location.href = `/api/projects/${projectId}/export`;
}
function archiveProject() {
if (!confirm('Archive this project? You can restore it later from the archived projects list.')) return;
document.getElementById('settings-status').value = 'archived';
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
}
async function holdProject() {
try {
const response = await fetch(`/api/projects/${projectId}/hold`, { method: 'POST' });
if (!response.ok) throw new Error('Failed to put project on hold');
await loadProjectDetails();
updateDangerZone();
htmx.trigger('#project-header', 'load');
} catch (err) {
alert('Failed to put project on hold: ' + err.message);
}
}
async function unholdProject() {
try {
const response = await fetch(`/api/projects/${projectId}/unhold`, { method: 'POST' });
if (!response.ok) throw new Error('Failed to resume project');
await loadProjectDetails();
updateDangerZone();
htmx.trigger('#project-header', 'load');
} catch (err) {
alert('Failed to resume project: ' + err.message);
}
}
function updateDangerZone() {
const status = document.getElementById('settings-status').value;
const container = document.getElementById('hold-btn-container');
if (!container) return;
if (status === 'on_hold') {
container.innerHTML = `<button onclick="unholdProject()"
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors text-sm">
Resume Project
</button>`;
} else {
container.innerHTML = `<button onclick="holdProject()"
class="px-4 py-2 border border-amber-500 text-amber-600 dark:text-amber-400 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors text-sm">
Put On Hold
</button>`;
}
}
function openDeleteModal() {
document.getElementById('delete-confirm-input').value = '';
document.getElementById('confirm-delete-btn').disabled = true;
document.getElementById('delete-project-modal').classList.remove('hidden');
document.getElementById('delete-confirm-input').focus();
}
function closeDeleteModal() {
document.getElementById('delete-project-modal').classList.add('hidden');
}
async function executeDeleteProject() {
try {
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete project');
closeDeleteModal();
window.location.href = '/projects';
} catch (err) {
alert('Failed to delete project: ' + err.message);
}
}
document.addEventListener('DOMContentLoaded', function() {
const input = document.getElementById('delete-confirm-input');
if (input) {
input.addEventListener('input', function() {
document.getElementById('confirm-delete-btn').disabled = this.value !== 'delete';
});
}
});
// ============================================================================
// Schedule Modal Functions
// ============================================================================
async function openScheduleModal() {
// Reset form
document.getElementById('schedule-name').value = '';
document.getElementById('schedule-locations-container').innerHTML = '<div class="text-gray-500 text-sm py-2 text-center">Loading locations...</div>';
document.getElementById('schedule-location-empty').classList.add('hidden');
document.getElementById('schedule-location-error').classList.add('hidden');
document.getElementById('schedule-error').classList.add('hidden');
// Reset to weekly calendar type
document.querySelector('input[name="schedule_type"][value="weekly_calendar"]').checked = true;
toggleScheduleType('weekly_calendar');
// Reset calendar checkboxes
if (typeof clearAllDays === 'function') {
clearAllDays();
}
// Show modal
document.getElementById('schedule-modal').classList.remove('hidden');
// Load locations
await loadScheduleLocations();
}
function closeScheduleModal() {
document.getElementById('schedule-modal').classList.add('hidden');
}
async function loadScheduleLocations() {
const container = document.getElementById('schedule-locations-container');
const emptyMsg = document.getElementById('schedule-location-empty');
const errorMsg = document.getElementById('schedule-location-error');
// Reset state
emptyMsg.classList.add('hidden');
errorMsg.classList.add('hidden');
try {
const response = await fetch(`/api/projects/${projectId}/locations-json`);
if (!response.ok) {
throw new Error('Failed to load locations');
}
const locations = await response.json();
if (!locations.length) {
container.innerHTML = '<div class="text-gray-500 text-sm py-2 text-center">No locations available</div>';
emptyMsg.classList.remove('hidden');
return;
}
// Build checkboxes for each location
container.innerHTML = locations.map(loc => `
<label class="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer">
<input type="checkbox"
name="schedule_locations"
value="${loc.id}"
data-name="${loc.name}"
data-type="${loc.location_type}"
class="rounded text-seismo-orange focus:ring-seismo-orange">
<span class="text-sm text-gray-900 dark:text-white">${loc.name}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">(${loc.location_type})</span>
</label>
`).join('');
// Add select all / clear all buttons if more than one location
if (locations.length > 1) {
container.insertAdjacentHTML('afterbegin', `
<div class="flex gap-2 pb-2 mb-2 border-b border-gray-200 dark:border-gray-600">
<button type="button" onclick="selectAllLocations()" class="text-xs text-seismo-orange hover:underline">Select All</button>
<span class="text-gray-400">|</span>
<button type="button" onclick="clearAllLocations()" class="text-xs text-gray-500 hover:underline">Clear All</button>
</div>
`);
}
} catch (err) {
console.error('Failed to load locations:', err);
container.innerHTML = '<div class="text-red-500 text-sm py-2 text-center">Error loading locations</div>';
}
}
function selectAllLocations() {
document.querySelectorAll('input[name="schedule_locations"]').forEach(cb => cb.checked = true);
}
function clearAllLocations() {
document.querySelectorAll('input[name="schedule_locations"]').forEach(cb => cb.checked = false);
}
function getSelectedLocationIds() {
const checkboxes = document.querySelectorAll('input[name="schedule_locations"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
function toggleScheduleType(type) {
const weeklyEditor = document.getElementById('schedule-weekly-wrapper');
const intervalEditor = document.getElementById('schedule-interval-wrapper');
const oneoffEditor = document.getElementById('schedule-oneoff-wrapper');
weeklyEditor.classList.add('hidden');
intervalEditor.classList.add('hidden');
oneoffEditor.classList.add('hidden');
if (type === 'weekly_calendar') {
weeklyEditor.classList.remove('hidden');
} else if (type === 'simple_interval') {
intervalEditor.classList.remove('hidden');
} else if (type === 'one_off') {
oneoffEditor.classList.remove('hidden');
}
}
// Schedule form submission
document.getElementById('schedule-form').addEventListener('submit', async function(e) {
e.preventDefault();
const name = document.getElementById('schedule-name').value.trim();
const locationIds = getSelectedLocationIds();
const scheduleType = document.querySelector('input[name="schedule_type"]:checked').value;
const timezone = document.getElementById('schedule-timezone').value;
// Hide previous errors
document.getElementById('schedule-location-error').classList.add('hidden');
if (!name) {
showScheduleError('Please enter a schedule name.');
return;
}
if (!locationIds.length) {
document.getElementById('schedule-location-error').classList.remove('hidden');
showScheduleError('Please select at least one location.');
return;
}
// Build payload based on schedule type
const payload = {
name: name,
location_ids: locationIds, // Array of location IDs
schedule_type: scheduleType,
timezone: timezone,
};
if (scheduleType === 'weekly_calendar') {
// Get weekly pattern from the calendar editor
if (typeof getWeeklyPatternData === 'function') {
payload.weekly_pattern = getWeeklyPatternData();
} else {
showScheduleError('Calendar editor not loaded properly.');
return;
}
// Validate at least one day is selected
const hasEnabledDay = Object.values(payload.weekly_pattern).some(day => day.enabled);
if (!hasEnabledDay) {
showScheduleError('Please select at least one day for monitoring.');
return;
}
// Get auto-increment setting for calendar mode
if (typeof getCalendarAutoIncrement === 'function') {
payload.auto_increment_index = getCalendarAutoIncrement();
} else {
payload.auto_increment_index = true;
}
// Get include_download setting for calendar mode
if (typeof getCalendarIncludeDownload === 'function') {
payload.include_download = getCalendarIncludeDownload();
} else {
payload.include_download = true;
}
} else if (scheduleType === 'simple_interval') {
// Get interval data
if (typeof getIntervalData === 'function') {
const intervalData = getIntervalData();
payload.interval_type = intervalData.interval_type;
payload.cycle_time = intervalData.cycle_time;
payload.include_download = intervalData.include_download;
payload.auto_increment_index = intervalData.auto_increment_index;
} else {
showScheduleError('Interval editor not loaded properly.');
return;
}
} else if (scheduleType === 'one_off') {
// Get one-off data
if (typeof getOneOffData === 'function') {
const oneOffData = getOneOffData();
if (!oneOffData.start_datetime || !oneOffData.end_datetime) {
showScheduleError('Please select both start and end date/time.');
return;
}
const start = new Date(oneOffData.start_datetime);
const end = new Date(oneOffData.end_datetime);
const diffMinutes = (end - start) / (1000 * 60);
if (diffMinutes <= 0) {
showScheduleError('End time must be after start time.');
return;
}
if (diffMinutes < 15) {
showScheduleError('Duration must be at least 15 minutes.');
return;
}
if (diffMinutes > 1440) {
showScheduleError('Duration cannot exceed 24 hours.');
return;
}
if (start <= new Date()) {
showScheduleError('Start time must be in the future.');
return;
}
payload.start_datetime = oneOffData.start_datetime;
payload.end_datetime = oneOffData.end_datetime;
payload.include_download = oneOffData.include_download;
payload.auto_increment_index = oneOffData.auto_increment_index;
} else {
showScheduleError('One-off editor not loaded properly.');
return;
}
}
try {
const response = await fetch(`/api/projects/${projectId}/recurring-schedules/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to create schedule');
}
const result = await response.json();
// Close modal and refresh schedules list
closeScheduleModal();
// Refresh both the recurring schedules list and scheduled actions
htmx.ajax('GET', `/api/projects/${projectId}/recurring-schedules/partials/list`, {
target: '#recurring-schedule-list',
swap: 'innerHTML'
});
htmx.ajax('GET', `/api/projects/${projectId}/schedules?status=pending`, {
target: '#project-schedules',
swap: 'innerHTML'
});
// Show success message
console.log('Schedule(s) created:', result.message);
} catch (err) {
showScheduleError(err.message || 'Failed to create schedule.');
}
});
function showScheduleError(message) {
const errorEl = document.getElementById('schedule-error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
// ============================================================================
// Keyboard shortcuts
// ============================================================================
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeLocationModal();
closeAssignModal();
closeScheduleModal();
}
});
// Click outside to close modals
document.getElementById('location-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeLocationModal();
}
});
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeAssignModal();
}
});
document.getElementById('schedule-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeScheduleModal();
}
});
// ── Upload Data ───────────────────────────────────────────────────────────────
function toggleUploadAll() {
const panel = document.getElementById('upload-all-panel');
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) {
document.getElementById('upload-all-status').textContent = '';
document.getElementById('upload-all-status').className = 'text-sm hidden';
document.getElementById('upload-all-results').classList.add('hidden');
document.getElementById('upload-all-results').innerHTML = '';
document.getElementById('upload-all-input').value = '';
document.getElementById('upload-all-file-count').classList.add('hidden');
document.getElementById('upload-all-progress-wrap').classList.add('hidden');
document.getElementById('upload-all-progress-bar').style.width = '0%';
}
}
// Show file count and filter info when folder is selected
document.getElementById('upload-all-input').addEventListener('change', function() {
const countEl = document.getElementById('upload-all-file-count');
const total = this.files.length;
if (!total) { countEl.classList.add('hidden'); return; }
const wanted = Array.from(this.files).filter(_isWantedFile).length;
countEl.textContent = `${wanted} of ${total} files will be uploaded (Leq + .rnh only)`;
countEl.classList.remove('hidden');
});
function _isWantedFile(f) {
const n = (f.webkitRelativePath || f.name).toLowerCase();
const base = n.split('/').pop();
if (base.endsWith('.rnh')) return true;
if (base.endsWith('.rnd')) {
if (base.includes('_leq_')) return true; // NL-43 Leq
if (base.startsWith('au2_')) return true; // AU2/NL-23 format
if (!base.includes('_lp')) return true; // unknown format — keep
}
return false;
}
function submitUploadAll() {
const input = document.getElementById('upload-all-input');
const status = document.getElementById('upload-all-status');
const resultsEl = document.getElementById('upload-all-results');
const btn = document.getElementById('upload-all-btn');
const cancelBtn = document.getElementById('upload-all-cancel-btn');
const progressWrap = document.getElementById('upload-all-progress-wrap');
const progressBar = document.getElementById('upload-all-progress-bar');
const progressLabel = document.getElementById('upload-all-progress-label');
if (!input.files.length) {
alert('Please select a folder to upload.');
return;
}
// Filter client-side — only send Leq .rnd and .rnh files
const filesToSend = Array.from(input.files).filter(_isWantedFile);
if (!filesToSend.length) {
alert('No Leq .rnd or .rnh files found in selected folder.');
return;
}
const formData = new FormData();
for (const f of filesToSend) {
formData.append('files', f);
formData.append('paths', f.webkitRelativePath || f.name);
}
// Disable controls and show progress
btn.disabled = true;
btn.textContent = 'Uploading\u2026';
btn.classList.add('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = true;
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
status.className = 'text-sm hidden';
resultsEl.classList.add('hidden');
progressWrap.classList.remove('hidden');
progressBar.style.width = '0%';
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`;
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`;
}
});
xhr.upload.addEventListener('load', () => {
progressBar.style.width = '100%';
progressLabel.textContent = 'Processing files on server\u2026';
});
function _resetControls() {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
}
xhr.addEventListener('load', () => {
_resetControls();
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) {
const s = data.sessions_created;
const f = data.files_imported;
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = '';
document.getElementById('upload-all-file-count').classList.add('hidden');
let html = '';
if (data.sessions && data.sessions.length) {
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
html += '<ul class="space-y-0.5 ml-2">';
for (const sess of data.sessions) {
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> &mdash; ${sess.files} files`;
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`;
html += '</li>';
}
html += '</ul>';
}
if (data.unmatched_folders && data.unmatched_folders.length) {
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
}
if (html) {
resultsEl.innerHTML = html;
resultsEl.classList.remove('hidden');
}
htmx.trigger(document.getElementById('unified-files'), 'refresh');
} else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
status.className = 'text-sm text-red-600 dark:text-red-400';
}
} catch {
status.textContent = 'Error: Unexpected server response';
status.className = 'text-sm text-red-600 dark:text-red-400';
}
});
xhr.addEventListener('error', () => {
_resetControls();
status.textContent = 'Error: Network error during upload';
status.className = 'text-sm text-red-600 dark:text-red-400';
});
xhr.open('POST', `/api/projects/{{ project_id }}/upload-all`);
xhr.send(formData);
}
// Load project details on page load and restore active tab from URL hash
// ── Live monitoring section (sound) ──────────────────────────────────────
// Self-contained: fetches /live-stats every 15s and renders a rollup + a
// live tile per NRL. Started from loadProjectDetails() once we know the
// project has the sound module, so vibration-only projects don't poll.
let liveStatsTimer = null;
const LS_LEVEL_AMBER = 55, LS_LEVEL_RED = 70;
function lsEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function lsNum(v) { const f = parseFloat(v); return isNaN(f) ? null : f; }
function lsFmtAgo(iso) {
if (!iso) return '';
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
if (s < 60) return s + 's ago';
if (s < 3600) return Math.round(s / 60) + 'm ago';
if (s < 86400) return Math.round(s / 3600) + 'h ago';
return Math.round(s / 86400) + 'd ago';
}
// Headline Leq color, matched to the portal thresholds.
function lsLeqColor(leq, measuring) {
if (!measuring || leq == null) return 'text-gray-400 dark:text-gray-500';
if (leq >= LS_LEVEL_RED) return 'text-red-500';
if (leq >= LS_LEVEL_AMBER) return 'text-amber-500';
return 'text-green-500';
}
// Friendly labels for NL-43 battery / power-source codes (fall back to raw).
function lsBattery(code) {
return ({F:'Full', M:'Mid', L:'Low', D:'Dead', E:'Empty'})[code] || (code || '');
}
function lsPower(code) {
return ({I:'Battery', E:'External', U:'USB'})[code] || (code || '');
}
function lsRenderTile(loc) {
const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure';
const wedged = loc.connection_state === 'wedged';
const reachable = loc.is_reachable !== false; // null/absent → assume ok
const hasData = loc.measurement_state != null || loc.leq != null;
// Status badge
let badge;
if (!loc.unit_id) {
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">No unit</span>';
} else if (wedged) {
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-300">Wedged</span>';
} else if (!reachable || !hasData) {
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Offline</span>';
} else if (measuring) {
badge = '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] bg-orange-100 dark:bg-orange-900/30 text-seismo-orange"><span class="relative flex h-1.5 w-1.5"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-seismo-orange opacity-75"></span><span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-seismo-orange"></span></span>Live</span>';
} else {
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Stopped</span>';
}
const leqNum = lsNum(loc.leq);
const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq;
const leqColor = lsLeqColor(leqNum, measuring);
// Health line: unit · last-seen · battery/power
const bits = [];
if (loc.unit_id) bits.push(`<span class="font-mono text-seismo-orange">${lsEsc(loc.unit_id)}</span>`);
if (hasData && loc.last_seen) bits.push(lsEsc(lsFmtAgo(loc.last_seen)));
if (hasData && (loc.battery_level || loc.power_source)) {
const b = lsBattery(loc.battery_level), p = lsPower(loc.power_source);
const low = loc.battery_level === 'L' || loc.battery_level === 'D' || loc.battery_level === 'E';
bits.push(`<span class="${low ? 'text-red-500' : ''}">${lsEsc([p, b].filter(Boolean).join(' · '))}</span>`);
}
const levels = (hasData)
? `<div class="mt-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums">
Lp ${lsEsc(loc.lp ?? '--')} &nbsp; Lmax ${lsEsc(loc.lmax ?? '--')}
</div>`
: '';
return `
<a href="/projects/${projectId}/nrl/${encodeURIComponent(loc.id)}"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 border border-transparent hover:border-seismo-orange hover:shadow-xl transition-all">
<div class="flex items-start justify-between gap-2">
<div class="font-semibold text-gray-900 dark:text-white truncate">${lsEsc(loc.name)}</div>
${badge}
</div>
<div class="mt-3 flex items-baseline gap-1.5">
<span class="text-4xl leading-none font-semibold tabular-nums ${leqColor}">${lsEsc(leqStr)}</span>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">dB Leq</span>
</div>
${levels}
<div class="mt-2 text-[11px] text-gray-400 dark:text-gray-500 flex flex-wrap gap-x-2 gap-y-0.5">
${bits.join('<span class="opacity-40">·</span>')}
</div>
</a>`;
}
function lsRender(locations) {
const section = document.getElementById('live-stats-section');
if (!section) return;
if (!locations.length) { section.classList.add('hidden'); return; }
section.classList.remove('hidden');
// Rollup
let live = 0, off = 0, peak = null, peakStr = null, peakLoc = null;
for (const l of locations) {
const measuring = l.measurement_state === 'Start' || l.measurement_state === 'Measure';
const hasData = l.measurement_state != null || l.leq != null;
if (measuring) {
live++;
const n = lsNum(l.leq);
if (n != null && (peak == null || n > peak)) { peak = n; peakStr = l.leq; peakLoc = l.name; }
} else if (!l.unit_id || !hasData || l.is_reachable === false) {
off++;
}
}
document.getElementById('ls-live').textContent = live;
document.getElementById('ls-offline').textContent = off;
const pw = document.getElementById('ls-loudest-wrap');
if (peak != null) {
pw.classList.remove('hidden');
document.getElementById('ls-loudest').textContent = peakStr;
document.getElementById('ls-loudest-loc').textContent = peakLoc;
} else { pw.classList.add('hidden'); }
document.getElementById('ls-tiles').innerHTML = locations.map(lsRenderTile).join('');
}
// Compact level-tinted pill classes for the inline NRL-card chips.
function lsInlineLevelPill(leq) {
if (leq == null) return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300';
if (leq >= LS_LEVEL_RED) return 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-300';
if (leq >= LS_LEVEL_AMBER) return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300';
return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300';
}
function lsInlineChipHtml(loc) {
if (!loc.unit_id) return ''; // no unit assigned → no chip
const base = 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ';
const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure';
const hasData = loc.measurement_state != null || loc.leq != null;
const reachable = loc.is_reachable !== false;
if (loc.connection_state === 'wedged')
return `<span class="${base}bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-300">Wedged</span>`;
if (!reachable || !hasData)
return `<span class="${base}bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Offline</span>`;
if (measuring) {
const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq;
return `<span class="${base}${lsInlineLevelPill(lsNum(loc.leq))}">`
+ `<span class="w-1.5 h-1.5 rounded-full bg-current"></span>${lsEsc(leqStr)} dB</span>`;
}
return `<span class="${base}bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Stopped</span>`;
}
// Paint the inline chips on the NRL list cards (Overview + Sound tab).
function lsPaintInline(locations) {
const byId = {};
for (const l of locations) byId[l.id] = l;
document.querySelectorAll('[data-loc-live]').forEach(el => {
const loc = byId[el.getAttribute('data-loc-live')];
const html = loc ? lsInlineChipHtml(loc) : '';
el.innerHTML = html;
el.classList.toggle('hidden', !html);
});
}
let lsLastData = [];
async function loadLiveStats() {
// Skip work while the tab is hidden in the background.
if (document.hidden) return;
try {
const r = await fetch(`/api/projects/${projectId}/live-stats`);
if (!r.ok) return;
const j = await r.json();
lsLastData = j.locations || [];
lsRender(lsLastData);
lsPaintInline(lsLastData);
} catch (e) { /* keep last render */ }
}
// The NRL list partial reloads via htmx (e.g. the 30s dashboard swap), which
// wipes the painted chips — repaint from the last poll as soon as it settles.
document.body.addEventListener('htmx:afterSwap', (e) => {
const id = e.target && e.target.id;
if (id === 'project-locations' || id === 'sound-locations') lsPaintInline(lsLastData);
});
function startLiveStats() {
if (liveStatsTimer) return; // already running
loadLiveStats();
liveStatsTimer = setInterval(loadLiveStats, 15000);
}
document.addEventListener('DOMContentLoaded', function() {
loadProjectDetails();
// Restore tab from URL hash
const hash = window.location.hash.replace('#', '');
const validTabs = ['overview', 'vibration', 'sound', 'settings'];
// Backwards compat: map old tab names to new ones
const hashMap = { locations: 'sound', units: 'sound', schedules: 'sound', sessions: 'sound', data: 'sound' };
const subTabMap = { units: 'units', schedules: 'schedules', sessions: 'sessions', data: 'data' };
if (hash) {
const mappedTab = hashMap[hash] || hash;
if (validTabs.includes(mappedTab)) {
switchTab(mappedTab);
// Open the relevant sub-tab for backwards compat
if (subTabMap[hash]) {
switchSoundSubTab(subTabMap[hash]);
}
}
}
});
</script>
<!-- Portal access modal -->
<div id="portal-access-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick="if(event.target===this)closePortalAccess()">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6">
<div class="flex items-center justify-between mb-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal access</h3>
<button onclick="closePortalAccess()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Send the client the link <em>and</em> the password. Read-only. Disabling rotates the link.
</p>
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Portal enabled</span>
<button id="pa-toggle" onclick="togglePortalEnabled()"
class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600"></button>
</div>
<div id="pa-details" class="hidden space-y-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Portal link</label>
<div class="flex gap-2">
<input id="pa-link" readonly class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyField('pa-link', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Password</label>
<div class="flex gap-2">
<input id="pa-pass" readonly placeholder="•••••••• (set one below)"
class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyField('pa-pass', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
<button onclick="regeneratePassword()" class="mt-2 text-sm text-seismo-orange hover:text-seismo-navy font-medium">↻ Generate new password</button>
<p class="text-xs text-gray-400 mt-1">Shown once — copy it now. Regenerating invalidates the old one.</p>
</div>
</div>
</div>
</div>
<script>
const PA_PROJECT_ID = "{{ project_id }}";
let paEnabled = false;
function paToast(msg) { if (window.showToast) showToast(msg, 'error'); else alert(msg); }
function openPortalAccess() { document.getElementById('portal-access-modal').classList.remove('hidden'); loadPortalAccess(); }
function closePortalAccess() { document.getElementById('portal-access-modal').classList.add('hidden'); }
function copyField(id, btn) {
const inp = document.getElementById(id); inp.select();
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
else { document.execCommand('copy'); done(); }
}
async function loadPortalAccess() {
try {
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access`);
if (!r.ok) throw new Error('load failed');
renderPortalAccess(await r.json());
} catch (e) { paToast('Could not load portal access.'); }
}
function renderPortalAccess(j) {
paEnabled = !!j.enabled;
const toggle = document.getElementById('pa-toggle');
const details = document.getElementById('pa-details');
toggle.textContent = paEnabled ? 'On — click to disable' : 'Off — click to enable';
toggle.className = 'px-3 py-1.5 text-sm rounded-lg border ' +
(paEnabled ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-slate-300 dark:border-slate-600');
details.classList.toggle('hidden', !paEnabled);
document.getElementById('pa-link').value = (paEnabled && j.link_url) ? j.link_url : '';
}
async function togglePortalEnabled() {
const action = paEnabled ? 'disable' : 'enable';
try {
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/${action}`, { method: 'POST' });
if (!r.ok) throw new Error('toggle failed');
const j = await r.json();
renderPortalAccess(action === 'disable' ? { enabled: false, link_url: null } : j);
} catch (e) { paToast(`Could not ${action} the portal.`); }
}
async function regeneratePassword() {
try {
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/password`, { method: 'POST' });
if (!r.ok) throw new Error('password failed');
const j = await r.json();
if (j.password) { const f = document.getElementById('pa-pass'); f.value = j.password; f.placeholder = ''; }
} catch (e) { paToast('Could not generate a password.'); }
}
</script>
{% endblock %}