- Updated reservation list to display estimated units and improved count display. - Added "Upcoming" status to project dashboard and header with corresponding styles. - Implemented a dropdown for quick status updates in project header. - Modified project list compact view to reflect new status labels. - Updated project overview to include a tab for upcoming projects. - Added migration script to introduce estimated_units column in job_reservations table.
1833 lines
89 KiB
HTML
1833 lines
89 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Project Dashboard - Terra-View{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Breadcrumb Navigation -->
|
|
<div class="mb-6">
|
|
<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>
|
|
</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 onclick="switchTab('locations')"
|
|
data-tab="locations"
|
|
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">
|
|
<span id="locations-tab-label">Locations</span>
|
|
</button>
|
|
<button id="units-tab-btn" onclick="switchTab('units')"
|
|
data-tab="units"
|
|
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">
|
|
Assigned Units
|
|
</button>
|
|
<button id="schedules-tab-btn" onclick="switchTab('schedules')"
|
|
data-tab="schedules"
|
|
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">
|
|
Schedules
|
|
</button>
|
|
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
|
data-tab="sessions"
|
|
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">
|
|
Monitoring Sessions
|
|
</button>
|
|
<button id="data-tab-btn" onclick="switchTab('data')"
|
|
data-tab="data"
|
|
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">
|
|
Data Files
|
|
</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">
|
|
<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>
|
|
|
|
<!-- Locations Tab -->
|
|
<div id="locations-tab" class="tab-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">
|
|
<span id="locations-header">Locations</span>
|
|
</h2>
|
|
<button onclick="openLocationModal()" id="add-location-btn"
|
|
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>
|
|
<span id="add-location-label">Add Location</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div id="project-locations"
|
|
hx-get="/api/projects/{{ project_id }}/locations"
|
|
hx-trigger="load"
|
|
hx-swap="innerHTML">
|
|
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Units Tab -->
|
|
<div id="units-tab" class="tab-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 locations
|
|
</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 Tab -->
|
|
<div id="schedules-tab" class="tab-panel hidden">
|
|
<!-- Recurring Schedules Section -->
|
|
<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>
|
|
|
|
<!-- Scheduled Actions Section -->
|
|
<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>
|
|
|
|
<!-- Monitoring Sessions Tab -->
|
|
<div id="sessions-tab" class="tab-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>
|
|
<div class="flex items-center gap-4">
|
|
<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>
|
|
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Data Files Tab -->
|
|
<div id="data-tab" class="tab-panel hidden">
|
|
<!-- FTP File Browser (Download from Devices) -->
|
|
<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 (Database + Filesystem) -->
|
|
<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>
|
|
<!-- Progress bar -->
|
|
<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>
|
|
<!-- Result summary -->
|
|
<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>
|
|
|
|
<!-- 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">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>
|
|
<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="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>
|
|
|
|
<!-- 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 projectTypeId = null;
|
|
|
|
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) {
|
|
// Hide all tab panels
|
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
|
panel.classList.add('hidden');
|
|
});
|
|
|
|
// Reset all tab buttons
|
|
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');
|
|
});
|
|
|
|
// Show selected tab panel
|
|
const panel = document.getElementById(`${tabName}-tab`);
|
|
if (panel) {
|
|
panel.classList.remove('hidden');
|
|
}
|
|
|
|
// Highlight selected tab button
|
|
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');
|
|
}
|
|
|
|
// Persist active tab in URL hash so refresh stays on this tab
|
|
history.replaceState(null, '', `#${tabName}`);
|
|
}
|
|
|
|
// 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();
|
|
projectTypeId = data.project_type_id || null;
|
|
|
|
// 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();
|
|
|
|
// Update tab labels and visibility based on project type
|
|
const isSoundProject = projectTypeId === 'sound_monitoring';
|
|
if (isSoundProject) {
|
|
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
|
}
|
|
// Monitoring Sessions and Data Files tabs are sound-only
|
|
// Data Files also hides the FTP browser section for manual projects
|
|
const isRemote = mode === 'remote';
|
|
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
|
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
|
// Schedules and Assigned Units are remote-only (manual projects collect data by hand)
|
|
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
|
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
|
// FTP browser within Data Files tab
|
|
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);
|
|
}
|
|
}
|
|
|
|
function openLocationModal(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');
|
|
if (projectTypeId === 'sound_monitoring') {
|
|
locationTypeSelect.value = 'sound';
|
|
locationTypeSelect.disabled = true;
|
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
|
} else if (projectTypeId === 'vibration_monitoring') {
|
|
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');
|
|
if (projectTypeId === 'sound_monitoring') {
|
|
locationTypeSelect.value = 'sound';
|
|
locationTypeSelect.disabled = true;
|
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
|
} else if (projectTypeId === 'vibration_monitoring') {
|
|
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 (projectTypeId === 'sound_monitoring') {
|
|
locationType = 'sound';
|
|
} else if (projectTypeId === 'vibration_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();
|
|
refreshProjectDashboard();
|
|
// Refresh locations tab if visible
|
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
|
target: '#project-locations',
|
|
swap: 'innerHTML'
|
|
});
|
|
} catch (err) {
|
|
const errorEl = document.getElementById('location-error');
|
|
errorEl.textContent = err.message || 'Failed to save location.';
|
|
errorEl.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
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');
|
|
}
|
|
refreshProjectDashboard();
|
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
|
target: '#project-locations',
|
|
swap: 'innerHTML'
|
|
});
|
|
} catch (err) {
|
|
alert(err.message || 'Failed to delete 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();
|
|
refreshProjectDashboard();
|
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
|
target: '#project-locations',
|
|
swap: 'innerHTML'
|
|
});
|
|
} 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');
|
|
}
|
|
refreshProjectDashboard();
|
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
|
target: '#project-locations',
|
|
swap: 'innerHTML'
|
|
});
|
|
} 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> — ${sess.files} files`;
|
|
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
|
|
if (sess.store_name) html += ` — ${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
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadProjectDetails();
|
|
|
|
// Restore tab from URL hash (e.g. #schedules, #settings)
|
|
const hash = window.location.hash.replace('#', '');
|
|
const validTabs = ['overview', 'locations', 'units', 'schedules', 'sessions', 'data', 'settings'];
|
|
if (hash && validTabs.includes(hash)) {
|
|
switchTab(hash);
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|