feat: Add report templates API for CRUD operations and implement SLM settings modal

- Implemented a new API router for managing report templates, including endpoints for listing, creating, retrieving, updating, and deleting templates.
- Added a new HTML partial for a unified SLM settings modal, allowing users to configure SLM settings with dynamic modem selection and FTP credentials.
- Created a report preview page with an editable data table using jspreadsheet, enabling users to modify report details and download the report as an Excel file.
This commit is contained in:
serversdwn
2026-01-20 21:43:50 +00:00
parent a9c9b1fd48
commit 1f3fa7a718
12 changed files with 1959 additions and 364 deletions

View File

@@ -22,6 +22,16 @@
</span>
</div>
<div class="flex items-center gap-2">
<button onclick="showFTPSettings('{{ unit_item.unit.id }}')"
id="settings-ftp-{{ unit_item.unit.id }}"
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Configure FTP credentials">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Settings
</button>
<button onclick="enableFTP('{{ unit_item.unit.id }}')"
id="enable-ftp-{{ unit_item.unit.id }}"
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
@@ -605,3 +615,6 @@ setTimeout(function() {
{% endfor %}
}, 100);
</script>
<!-- Include the unified SLM Settings Modal -->
{% include 'partials/slm_settings_modal.html' %}

View File

@@ -1307,123 +1307,6 @@ window.addEventListener('beforeunload', function() {
// Timer will resume on next page load if measurement is still active
stopMeasurementTimer();
});
// ========================================
// Settings Modal
// ========================================
async function openSettingsModal(unitId) {
const modal = document.getElementById('settings-modal');
const errorDiv = document.getElementById('settings-error');
const successDiv = document.getElementById('settings-success');
// Clear previous messages
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Store unit ID
document.getElementById('settings-unit-id').value = unitId;
// Load current SLMM config
try {
const response = await fetch(`/api/slmm/${unitId}/config`);
if (!response.ok) {
throw new Error('Failed to load configuration');
}
const result = await response.json();
const config = result.data || {};
// Populate form fields
document.getElementById('settings-host').value = config.host || '';
document.getElementById('settings-tcp-port').value = config.tcp_port || 2255;
document.getElementById('settings-ftp-port').value = config.ftp_port || 21;
document.getElementById('settings-ftp-username').value = config.ftp_username || '';
document.getElementById('settings-ftp-password').value = config.ftp_password || '';
document.getElementById('settings-tcp-enabled').checked = config.tcp_enabled !== false;
document.getElementById('settings-ftp-enabled').checked = config.ftp_enabled === true;
document.getElementById('settings-web-enabled').checked = config.web_enabled === true;
modal.classList.remove('hidden');
} catch (error) {
console.error('Failed to load SLMM config:', error);
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
errorDiv.classList.remove('hidden');
modal.classList.remove('hidden');
}
}
function closeSettingsModal() {
document.getElementById('settings-modal').classList.add('hidden');
}
document.getElementById('settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
const unitId = document.getElementById('settings-unit-id').value;
const errorDiv = document.getElementById('settings-error');
const successDiv = document.getElementById('settings-success');
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Gather form data
const configData = {
host: document.getElementById('settings-host').value.trim(),
tcp_port: parseInt(document.getElementById('settings-tcp-port').value),
ftp_port: parseInt(document.getElementById('settings-ftp-port').value),
ftp_username: document.getElementById('settings-ftp-username').value.trim() || null,
ftp_password: document.getElementById('settings-ftp-password').value || null,
tcp_enabled: document.getElementById('settings-tcp-enabled').checked,
ftp_enabled: document.getElementById('settings-ftp-enabled').checked,
web_enabled: document.getElementById('settings-web-enabled').checked
};
// Validation
if (!configData.host) {
errorDiv.textContent = 'Host/IP address is required';
errorDiv.classList.remove('hidden');
return;
}
if (configData.tcp_port < 1 || configData.tcp_port > 65535) {
errorDiv.textContent = 'TCP port must be between 1 and 65535';
errorDiv.classList.remove('hidden');
return;
}
if (configData.ftp_port < 1 || configData.ftp_port > 65535) {
errorDiv.textContent = 'FTP port must be between 1 and 65535';
errorDiv.classList.remove('hidden');
return;
}
try {
const response = await fetch(`/api/slmm/${unitId}/config`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(configData)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to update configuration');
}
successDiv.textContent = 'Configuration saved successfully!';
successDiv.classList.remove('hidden');
// Close modal after 1.5 seconds
setTimeout(() => {
closeSettingsModal();
// Optionally reload the page to reflect changes
// window.location.reload();
}, 1500);
} catch (error) {
errorDiv.textContent = error.message;
errorDiv.classList.remove('hidden');
}
});
// ========================================
// FTP Browser Modal
// ========================================
@@ -2201,125 +2084,6 @@ document.getElementById('preview-modal')?.addEventListener('click', function(e)
});
</script>
<!-- Settings Modal -->
<div id="settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
<button onclick="closeSettingsModal()" 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="settings-form" class="p-6 space-y-6">
<input type="hidden" id="settings-unit-id">
<!-- Network Configuration -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Network Configuration</h4>
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host / IP Address</label>
<input type="text" id="settings-host"
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"
placeholder="e.g., 192.168.1.100" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
<input type="number" id="settings-tcp-port"
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"
placeholder="2255" min="1" max="65535" required>
<p class="text-xs text-gray-500 mt-1">Default: 2255 for NL-43/NL-53</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
<input type="number" id="settings-ftp-port"
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"
placeholder="21" min="1" max="65535" required>
<p class="text-xs text-gray-500 mt-1">Standard FTP port (default: 21)</p>
</div>
</div>
</div>
<!-- FTP Credentials -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">FTP Credentials</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username</label>
<input type="text" id="settings-ftp-username"
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"
placeholder="anonymous">
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
<input type="password" id="settings-ftp-password"
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"
placeholder="••••••••">
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
</div>
</div>
</div>
<!-- Protocol Toggles -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Protocol Settings</h4>
<div class="space-y-3">
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
<div>
<span class="font-medium text-gray-900 dark:text-white">TCP Communication</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable TCP control commands</p>
</div>
<input type="checkbox" id="settings-tcp-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
<div>
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable FTP file browsing and downloads</p>
</div>
<input type="checkbox" id="settings-ftp-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
<div>
<span class="font-medium text-gray-900 dark:text-white">Web Interface</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable web UI access (future feature)</p>
</div>
<input type="checkbox" id="settings-web-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
</div>
</div>
<div id="settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
<div id="settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<button type="button" onclick="closeSettingsModal()"
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-orange-600 text-white rounded-lg font-medium">
Save Configuration
</button>
</div>
</form>
</div>
</div>
<!-- FTP Browser Modal -->
<div id="ftp-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-4xl max-h-[90vh] overflow-hidden m-4 flex flex-col">
@@ -2407,3 +2171,6 @@ document.getElementById('preview-modal')?.addEventListener('click', function(e)
</div>
</div>
</div>
<!-- Unified SLM Settings Modal -->
{% include 'partials/slm_settings_modal.html' %}

View File

@@ -0,0 +1,534 @@
<!-- Unified SLM Settings Modal - Include this partial where SLM settings are needed -->
<!-- Usage: include 'partials/slm_settings_modal.html' (with Jinja braces) -->
<!-- Then call: openSLMSettingsModal(unitId) from JavaScript -->
<div id="slm-settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
<!-- Header -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<svg class="w-8 h-8 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
<p id="slm-settings-unit-display" class="text-sm text-gray-500 dark:text-gray-400"></p>
</div>
</div>
<button onclick="closeSLMSettingsModal()" 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="slm-settings-form" onsubmit="saveSLMSettings(event)" class="p-6 space-y-6">
<input type="hidden" id="slm-settings-unit-id">
<!-- Network Configuration -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
Network Configuration
</h4>
<div class="space-y-4">
<!-- Modem Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connected via Modem</label>
<div class="flex gap-2">
<select id="slm-settings-modem" class="flex-1 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="">Select a modem...</option>
<!-- Modems loaded dynamically -->
</select>
<button type="button" onclick="testModemConnection()" id="slm-settings-test-modem-btn"
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled title="Test modem connectivity">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem this SLM is connected through</p>
</div>
<!-- Port Configuration -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
<input type="number" id="slm-settings-tcp-port"
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"
placeholder="2255" min="1" max="65535" value="2255">
<p class="text-xs text-gray-500 mt-1">Control port (default: 2255)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
<input type="number" id="slm-settings-ftp-port"
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"
placeholder="21" min="1" max="65535" value="21">
<p class="text-xs text-gray-500 mt-1">File transfer (default: 21)</p>
</div>
</div>
</div>
</div>
<!-- FTP Credentials -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
</svg>
FTP Credentials
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username</label>
<input type="text" id="slm-settings-ftp-username"
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"
placeholder="anonymous">
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
<input type="password" id="slm-settings-ftp-password"
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"
placeholder="Leave blank to keep existing">
<p class="text-xs text-gray-500 mt-1">Leave blank to keep existing</p>
</div>
</div>
</div>
<!-- Device Information -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
Device Information
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
<select id="slm-settings-model" 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="">Select model...</option>
<option value="NL-43">NL-43</option>
<option value="NL-53">NL-53</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
<input type="text" id="slm-settings-serial"
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"
placeholder="e.g., SN123456">
</div>
</div>
<div class="grid grid-cols-3 gap-4 mt-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
<select id="slm-settings-freq-weighting" 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="">Select...</option>
<option value="A">A-weighting</option>
<option value="C">C-weighting</option>
<option value="Z">Z-weighting (Linear)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
<select id="slm-settings-time-weighting" 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="">Select...</option>
<option value="Fast">Fast (125ms)</option>
<option value="Slow">Slow (1s)</option>
<option value="Impulse">Impulse</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
<select id="slm-settings-range" 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="">Select...</option>
<option value="30-130">30-130 dB</option>
<option value="40-140">40-140 dB</option>
<option value="50-140">50-140 dB</option>
</select>
</div>
</div>
</div>
<!-- FTP Enable Toggle -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<label class="flex items-center justify-between cursor-pointer">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
<div>
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Enable FTP for file browsing and downloads</p>
</div>
</div>
<input type="checkbox" id="slm-settings-ftp-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
</div>
<!-- Status Messages -->
<div id="slm-settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
<div id="slm-settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
<!-- Action Buttons -->
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<button type="button" onclick="closeSLMSettingsModal()"
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="button" onclick="testSLMConnection()"
class="px-6 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg">
Test SLM Connection
</button>
<button type="submit" id="slm-settings-save-btn"
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
Save Configuration
</button>
</div>
</form>
</div>
</div>
<script>
// ========================================
// Unified SLM Settings Modal JavaScript
// ========================================
let slmSettingsModems = []; // Cache modems list
// Open the SLM Settings Modal
async function openSLMSettingsModal(unitId) {
const modal = document.getElementById('slm-settings-modal');
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
// Clear previous messages
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Store unit ID
document.getElementById('slm-settings-unit-id').value = unitId;
document.getElementById('slm-settings-unit-display').textContent = unitId;
// Load modems list if not cached
if (slmSettingsModems.length === 0) {
await loadModemsForSLMSettings();
}
// Load current config from both Terra-View and SLMM
try {
// Fetch Terra-View unit data
const unitResponse = await fetch(`/api/roster/${unitId}`);
const unitData = unitResponse.ok ? await unitResponse.json() : {};
// Fetch SLMM config
const slmmResponse = await fetch(`/api/slmm/${unitId}/config`);
const slmmResult = slmmResponse.ok ? await slmmResponse.json() : {};
const slmmData = slmmResult.data || slmmResult || {};
// Populate form fields
// Modem selection
const modemSelect = document.getElementById('slm-settings-modem');
modemSelect.value = unitData.deployed_with_modem_id || '';
updateTestModemButton();
// Ports
document.getElementById('slm-settings-tcp-port').value = unitData.slm_tcp_port || slmmData.tcp_port || 2255;
document.getElementById('slm-settings-ftp-port').value = unitData.slm_ftp_port || slmmData.ftp_port || 21;
// FTP credentials from SLMM
document.getElementById('slm-settings-ftp-username').value = slmmData.ftp_username || '';
document.getElementById('slm-settings-ftp-password').value = ''; // Don't pre-fill
// Device info from Terra-View
document.getElementById('slm-settings-model').value = unitData.slm_model || '';
document.getElementById('slm-settings-serial').value = unitData.slm_serial_number || '';
document.getElementById('slm-settings-freq-weighting').value = unitData.slm_frequency_weighting || '';
document.getElementById('slm-settings-time-weighting').value = unitData.slm_time_weighting || '';
document.getElementById('slm-settings-range').value = unitData.slm_measurement_range || '';
// FTP enabled from SLMM
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
} catch (error) {
console.error('Failed to load SLM settings:', error);
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
errorDiv.classList.remove('hidden');
}
modal.classList.remove('hidden');
}
// Close the modal
function closeSLMSettingsModal() {
document.getElementById('slm-settings-modal').classList.add('hidden');
}
// Alias for backwards compatibility with existing code
function showFTPSettings(unitId) {
openSLMSettingsModal(unitId);
}
function closeFTPSettings() {
closeSLMSettingsModal();
}
function openSettingsModal(unitId) {
openSLMSettingsModal(unitId);
}
function closeSettingsModal() {
closeSLMSettingsModal();
}
function openConfigModal(unitId) {
openSLMSettingsModal(unitId);
}
function closeConfigModal() {
closeSLMSettingsModal();
}
function openDeviceConfigModal(unitId) {
openSLMSettingsModal(unitId);
}
function closeDeviceConfigModal() {
closeSLMSettingsModal();
}
// Load modems for dropdown
async function loadModemsForSLMSettings() {
try {
const response = await fetch('/api/roster/modems');
slmSettingsModems = await response.json();
const select = document.getElementById('slm-settings-modem');
// Clear existing options except first
select.innerHTML = '<option value="">Select a modem...</option>';
slmSettingsModems.forEach(modem => {
const option = document.createElement('option');
option.value = modem.id;
const ipText = modem.ip_address ? ` (${modem.ip_address})` : '';
const deployedText = modem.deployed ? '' : ' [Benched]';
option.textContent = modem.id + ipText + deployedText;
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load modems:', error);
}
}
// Update test modem button state based on selection
function updateTestModemButton() {
const modemSelect = document.getElementById('slm-settings-modem');
const testBtn = document.getElementById('slm-settings-test-modem-btn');
testBtn.disabled = !modemSelect.value;
}
// Listen for modem selection changes
document.getElementById('slm-settings-modem')?.addEventListener('change', updateTestModemButton);
// Test modem connection
async function testModemConnection() {
const modemId = document.getElementById('slm-settings-modem').value;
if (!modemId) return;
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
errorDiv.classList.add('hidden');
successDiv.textContent = 'Pinging modem...';
successDiv.classList.remove('hidden');
try {
const response = await fetch(`/api/slm-dashboard/test-modem/${modemId}`);
const data = await response.json();
if (response.ok && data.status === 'success') {
const ipAddr = data.ip_address || modemId;
const respTime = data.response_time || 'N/A';
successDiv.textContent = `✓ Modem responding! ${ipAddr} - ${respTime}ms`;
} else {
successDiv.classList.add('hidden');
errorDiv.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
errorDiv.classList.remove('hidden');
}
} catch (error) {
successDiv.classList.add('hidden');
errorDiv.textContent = 'Failed to ping modem: ' + error.message;
errorDiv.classList.remove('hidden');
}
}
// Test SLM connection
async function testSLMConnection() {
const unitId = document.getElementById('slm-settings-unit-id').value;
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
errorDiv.classList.add('hidden');
successDiv.textContent = 'Testing SLM connection...';
successDiv.classList.remove('hidden');
try {
const response = await fetch(`/api/slmm/${unitId}/status`);
const data = await response.json();
if (response.ok && data.status === 'online') {
successDiv.textContent = '✓ SLM connection successful! Device is responding.';
} else {
successDiv.classList.add('hidden');
errorDiv.textContent = '⚠ SLM not responding or offline. Check network settings.';
errorDiv.classList.remove('hidden');
}
} catch (error) {
successDiv.classList.add('hidden');
errorDiv.textContent = 'Connection test failed: ' + error.message;
errorDiv.classList.remove('hidden');
}
}
// Save SLM settings
async function saveSLMSettings(event) {
event.preventDefault();
const unitId = document.getElementById('slm-settings-unit-id').value;
const saveBtn = document.getElementById('slm-settings-save-btn');
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Get selected modem and resolve its IP
const modemId = document.getElementById('slm-settings-modem').value;
let modemIp = '';
if (modemId) {
const modem = slmSettingsModems.find(m => m.id === modemId);
modemIp = modem?.ip_address || '';
}
// Validation
if (!modemId) {
errorDiv.textContent = 'Please select a modem';
errorDiv.classList.remove('hidden');
saveBtn.disabled = false;
saveBtn.textContent = 'Save Configuration';
return;
}
if (!modemIp) {
errorDiv.textContent = 'Selected modem has no IP address configured';
errorDiv.classList.remove('hidden');
saveBtn.disabled = false;
saveBtn.textContent = 'Save Configuration';
return;
}
const tcpPort = parseInt(document.getElementById('slm-settings-tcp-port').value) || 2255;
const ftpPort = parseInt(document.getElementById('slm-settings-ftp-port').value) || 21;
if (tcpPort < 1 || tcpPort > 65535 || ftpPort < 1 || ftpPort > 65535) {
errorDiv.textContent = 'Port values must be between 1 and 65535';
errorDiv.classList.remove('hidden');
saveBtn.disabled = false;
saveBtn.textContent = 'Save Configuration';
return;
}
try {
// 1. Update Terra-View database (device info + modem assignment)
const terraViewData = {
deployed_with_modem_id: modemId,
slm_model: document.getElementById('slm-settings-model').value || null,
slm_serial_number: document.getElementById('slm-settings-serial').value || null,
slm_frequency_weighting: document.getElementById('slm-settings-freq-weighting').value || null,
slm_time_weighting: document.getElementById('slm-settings-time-weighting').value || null,
slm_measurement_range: document.getElementById('slm-settings-range').value || null,
slm_tcp_port: tcpPort,
slm_ftp_port: ftpPort
};
const terraResponse = await fetch(`/api/slm-dashboard/config/${unitId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(terraViewData)
});
if (!terraResponse.ok) {
throw new Error('Failed to save Terra-View configuration');
}
// 2. Update SLMM config (network + FTP credentials)
const slmmData = {
host: modemIp,
tcp_port: tcpPort,
ftp_port: ftpPort,
ftp_username: document.getElementById('slm-settings-ftp-username').value.trim() || null,
ftp_enabled: document.getElementById('slm-settings-ftp-enabled').checked
};
// Only include password if entered
const password = document.getElementById('slm-settings-ftp-password').value;
if (password) {
slmmData.ftp_password = password;
}
const slmmResponse = await fetch(`/api/slmm/${unitId}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slmmData)
});
if (!slmmResponse.ok) {
const errData = await slmmResponse.json().catch(() => ({}));
throw new Error(errData.detail || 'Failed to save SLMM configuration');
}
successDiv.textContent = 'Configuration saved successfully!';
successDiv.classList.remove('hidden');
// Close modal after delay and refresh if needed
setTimeout(() => {
closeSLMSettingsModal();
// Try to refresh any FTP status or unit lists on the page
if (typeof checkFTPStatus === 'function') {
checkFTPStatus(unitId);
}
if (typeof htmx !== 'undefined') {
htmx.trigger('#slm-list', 'load');
}
}, 1500);
} catch (error) {
errorDiv.textContent = 'Error: ' + error.message;
errorDiv.classList.remove('hidden');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Configuration';
}
}
// Alias for backwards compatibility
async function saveFTPSettings(event) {
return saveSLMSettings(event);
}
// Close modal on background click
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeSLMSettingsModal();
}
});
</script>

View File

@@ -0,0 +1,309 @@
{% extends "base.html" %}
{% block title %}Report Preview - {{ project.name if project else 'Sound Level Data' }}{% endblock %}
{% block content %}
<!-- jspreadsheet CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/jspreadsheet.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.css" />
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
<!-- Header -->
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Report Preview & Editor
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{% if file %}{{ file.file_path.split('/')[-1] }}{% endif %}
{% if location %} @ {{ location.name }}{% endif %}
{% if start_time and end_time %} | Time: {{ start_time }} - {{ end_time }}{% endif %}
| {{ filtered_count }} of {{ original_count }} rows
</p>
</div>
<div class="flex items-center gap-3">
<button onclick="downloadReport()"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download Excel
</button>
<a href="/api/projects/{{ project_id }}/files/{{ file_id }}/view-rnd"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
Back to Viewer
</a>
</div>
</div>
</div>
</div>
<!-- Report Info Section -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report Title</label>
<input type="text" id="edit-report-title" value="{{ report_title }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
<input type="text" id="edit-project-name" value="{{ project_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client Name</label>
<input type="text" id="edit-client-name" value="{{ client_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Location</label>
<input type="text" id="edit-location-name" value="{{ location_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
</div>
</div>
<!-- Spreadsheet Editor -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Data Table</h2>
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Right-click for options</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>Double-click to edit</span>
</div>
</div>
<div id="spreadsheet" class="overflow-x-auto"></div>
</div>
<!-- Help Text -->
<div class="mt-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">Editing Tips</h3>
<ul class="text-sm text-blue-700 dark:text-blue-400 list-disc list-inside space-y-1">
<li>Double-click any cell to edit its value</li>
<li>Use the Comments column to add notes about specific measurements</li>
<li>Right-click a row to delete it from the report</li>
<li>Right-click to add new rows if needed</li>
<li>Press Enter to confirm edits, Escape to cancel</li>
</ul>
</div>
</div>
</div>
<!-- jspreadsheet JS -->
<script src="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/index.min.js"></script>
<script>
// Initialize spreadsheet data from server
const initialData = {{ spreadsheet_data | tojson }};
// Create jspreadsheet instance
let spreadsheet = null;
document.addEventListener('DOMContentLoaded', function() {
spreadsheet = jspreadsheet(document.getElementById('spreadsheet'), {
data: initialData,
columns: [
{ title: 'Test #', width: 80, type: 'numeric' },
{ title: 'Date', width: 110, type: 'text' },
{ title: 'Time', width: 90, type: 'text' },
{ title: 'LAmax (dBA)', width: 100, type: 'numeric' },
{ title: 'LA01 (dBA)', width: 100, type: 'numeric' },
{ title: 'LA10 (dBA)', width: 100, type: 'numeric' },
{ title: 'Comments', width: 250, type: 'text' }
],
allowInsertRow: true,
allowDeleteRow: true,
allowInsertColumn: false,
allowDeleteColumn: false,
rowDrag: true,
columnSorting: true,
search: true,
pagination: 50,
paginationOptions: [25, 50, 100, 200],
defaultColWidth: 100,
minDimensions: [7, 1],
tableOverflow: true,
tableWidth: '100%',
contextMenu: function(instance, col, row, e) {
const items = [];
if (row !== null) {
items.push({
title: 'Insert row above',
onclick: function() {
instance.insertRow(1, row, true);
}
});
items.push({
title: 'Insert row below',
onclick: function() {
instance.insertRow(1, row + 1, false);
}
});
items.push({
title: 'Delete this row',
onclick: function() {
instance.deleteRow(row);
}
});
}
return items;
},
style: {
A: 'text-align: center;',
B: 'text-align: center;',
C: 'text-align: center;',
D: 'text-align: right;',
E: 'text-align: right;',
F: 'text-align: right;',
}
});
});
async function downloadReport() {
// Get current data from spreadsheet
const data = spreadsheet.getData();
// Get report settings
const reportTitle = document.getElementById('edit-report-title').value;
const projectName = document.getElementById('edit-project-name').value;
const clientName = document.getElementById('edit-client-name').value;
const locationName = document.getElementById('edit-location-name').value;
// Build time filter info
let timeFilter = '';
{% if start_time and end_time %}
timeFilter = 'Time Filter: {{ start_time }} - {{ end_time }}';
{% if start_date or end_date %}
timeFilter += ' | Date Range: {{ start_date or "start" }} to {{ end_date or "end" }}';
{% endif %}
timeFilter += ' | {{ filtered_count }} of {{ original_count }} rows';
{% endif %}
// Send to server to generate Excel
try {
const response = await fetch('/api/projects/{{ project_id }}/files/{{ file_id }}/generate-from-preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: data,
report_title: reportTitle,
project_name: projectName,
client_name: clientName,
location_name: locationName,
time_filter: timeFilter
})
});
if (response.ok) {
// Download the file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Get filename from Content-Disposition header if available
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'report.xlsx';
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) filename = match[1];
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} else {
const error = await response.json();
alert('Error generating report: ' + (error.detail || 'Unknown error'));
}
} catch (error) {
alert('Error generating report: ' + error.message);
}
}
</script>
<style>
/* Custom styles for jspreadsheet to match dark mode */
.dark .jexcel {
background-color: #1e293b;
color: #e2e8f0;
}
.dark .jexcel thead td {
background-color: #334155 !important;
color: #e2e8f0 !important;
border-color: #475569 !important;
}
.dark .jexcel tbody td {
background-color: #1e293b;
color: #e2e8f0;
border-color: #475569;
}
.dark .jexcel tbody td:hover {
background-color: #334155;
}
.dark .jexcel tbody tr:nth-child(even) td {
background-color: #0f172a;
}
.dark .jexcel_pagination {
background-color: #1e293b;
color: #e2e8f0;
border-color: #475569;
}
.dark .jexcel_pagination a {
color: #e2e8f0;
}
.dark .jexcel_search {
background-color: #1e293b;
color: #e2e8f0;
border-color: #475569;
}
.dark .jexcel_search input {
background-color: #334155;
color: #e2e8f0;
border-color: #475569;
}
.dark .jexcel_content {
background-color: #1e293b;
}
.dark .jexcel_contextmenu {
background-color: #1e293b;
border-color: #475569;
}
.dark .jexcel_contextmenu a {
color: #e2e8f0;
}
.dark .jexcel_contextmenu a:hover {
background-color: #334155;
}
/* Ensure proper sizing */
.jexcel_content {
max-height: 600px;
overflow: auto;
}
</style>
{% endblock %}

View File

@@ -454,42 +454,227 @@ function escapeHtml(str) {
}
// Report Generation Modal Functions
let reportTemplates = [];
async function loadTemplates() {
try {
const response = await fetch('/api/report-templates?project_id={{ project_id }}');
if (response.ok) {
reportTemplates = await response.json();
populateTemplateDropdown();
}
} catch (error) {
console.error('Error loading templates:', error);
}
}
function populateTemplateDropdown() {
const select = document.getElementById('template-select');
if (!select) return;
select.innerHTML = '<option value="">-- Select a template --</option>';
reportTemplates.forEach(template => {
const option = document.createElement('option');
option.value = template.id;
option.textContent = template.name;
option.dataset.config = JSON.stringify(template);
select.appendChild(option);
});
}
function applyTemplate() {
const select = document.getElementById('template-select');
const selectedOption = select.options[select.selectedIndex];
if (!selectedOption.value) return;
const template = JSON.parse(selectedOption.dataset.config);
if (template.report_title) {
document.getElementById('report-title').value = template.report_title;
}
if (template.start_time) {
document.getElementById('start-time').value = template.start_time;
}
if (template.end_time) {
document.getElementById('end-time').value = template.end_time;
}
if (template.start_date) {
document.getElementById('start-date').value = template.start_date;
}
if (template.end_date) {
document.getElementById('end-date').value = template.end_date;
}
// Update preset buttons
updatePresetButtons();
}
function setTimePreset(preset) {
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
// Remove active state from all preset buttons
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.classList.remove('bg-emerald-600', 'text-white');
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
});
// Set time values based on preset
switch(preset) {
case 'night':
startTimeInput.value = '19:00';
endTimeInput.value = '07:00';
break;
case 'day':
startTimeInput.value = '07:00';
endTimeInput.value = '19:00';
break;
case 'all':
startTimeInput.value = '';
endTimeInput.value = '';
break;
case 'custom':
// Just enable custom input, don't change values
break;
}
// Highlight active preset
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
if (activeBtn) {
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
activeBtn.classList.add('bg-emerald-600', 'text-white');
}
}
function updatePresetButtons() {
const startTime = document.getElementById('start-time').value;
const endTime = document.getElementById('end-time').value;
// Remove active state from all
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.classList.remove('bg-emerald-600', 'text-white');
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
});
// Check which preset matches
let preset = 'custom';
if (startTime === '19:00' && endTime === '07:00') preset = 'night';
else if (startTime === '07:00' && endTime === '19:00') preset = 'day';
else if (!startTime && !endTime) preset = 'all';
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
if (activeBtn) {
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
activeBtn.classList.add('bg-emerald-600', 'text-white');
}
}
function openReportModal() {
document.getElementById('report-modal').classList.remove('hidden');
// Pre-fill location name if available
loadTemplates();
// Pre-fill fields if available
const locationInput = document.getElementById('report-location');
if (locationInput && !locationInput.value) {
locationInput.value = '{{ location.name if location else "" }}';
}
const projectInput = document.getElementById('report-project');
if (projectInput && !projectInput.value) {
projectInput.value = '{{ project.name if project else "" }}';
}
const clientInput = document.getElementById('report-client');
if (clientInput && !clientInput.value) {
clientInput.value = '{{ project.client_name if project and project.client_name else "" }}';
}
// Set default to "All Day"
setTimePreset('all');
}
function closeReportModal() {
document.getElementById('report-modal').classList.add('hidden');
}
function generateReport() {
function generateReport(preview = false) {
const reportTitle = document.getElementById('report-title').value || 'Background Noise Study';
const projectName = document.getElementById('report-project').value || '';
const clientName = document.getElementById('report-client').value || '';
const locationName = document.getElementById('report-location').value || '';
const startTime = document.getElementById('start-time').value || '';
const endTime = document.getElementById('end-time').value || '';
const startDate = document.getElementById('start-date').value || '';
const endDate = document.getElementById('end-date').value || '';
// Build the URL with query parameters
const params = new URLSearchParams({
report_title: reportTitle,
location_name: locationName
project_name: projectName,
client_name: clientName,
location_name: locationName,
start_time: startTime,
end_time: endTime,
start_date: startDate,
end_date: endDate
});
// Trigger download
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/generate-report?${params.toString()}`;
if (preview) {
// Open preview page
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/preview-report?${params.toString()}`;
} else {
// Direct download
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/generate-report?${params.toString()}`;
}
// Close modal
closeReportModal();
}
async function saveAsTemplate() {
const name = prompt('Enter a name for this template:');
if (!name) return;
const templateData = {
name: name,
project_id: '{{ project_id }}',
report_title: document.getElementById('report-title').value || 'Background Noise Study',
start_time: document.getElementById('start-time').value || null,
end_time: document.getElementById('end-time').value || null,
start_date: document.getElementById('start-date').value || null,
end_date: document.getElementById('end-date').value || null
};
try {
const response = await fetch('/api/report-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(templateData)
});
if (response.ok) {
alert('Template saved successfully!');
loadTemplates();
} else {
alert('Failed to save template');
}
} catch (error) {
alert('Error saving template: ' + error.message);
}
}
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeReportModal();
}
});
// Update preset buttons when time inputs change
document.addEventListener('DOMContentLoaded', function() {
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
if (startTimeInput) startTimeInput.addEventListener('change', updatePresetButtons);
if (endTimeInput) endTimeInput.addEventListener('change', updatePresetButtons);
});
</script>
<!-- Report Generation Modal -->
@@ -499,7 +684,7 @@ document.addEventListener('keydown', function(e) {
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 transition-opacity" onclick="closeReportModal()"></div>
<!-- Modal panel -->
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<div class="bg-white dark:bg-slate-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 sm:mx-0 sm:h-10 sm:w-10">
@@ -511,15 +696,53 @@ document.addEventListener('keydown', function(e) {
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
Generate Excel Report
</h3>
<div class="mt-4 space-y-4">
<!-- Template Selection -->
<div class="flex items-center gap-2">
<div class="flex-1">
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Load Template
</label>
<select id="template-select" onchange="applyTemplate()"
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
<option value="">-- Select a template --</option>
</select>
</div>
<button type="button" onclick="saveAsTemplate()"
class="mt-6 px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
title="Save current settings as template">
<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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
</svg>
</button>
</div>
<!-- Report Title -->
<div>
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Report Title
</label>
<input type="text" id="report-title" value="Background Noise Study"
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="e.g., Background Noise Study - Commercial Street Bridge">
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<!-- Project and Client in a row -->
<div class="grid grid-cols-2 gap-3">
<div>
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Project Name
</label>
<input type="text" id="report-project" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="report-client" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Client Name
</label>
<input type="text" id="report-client" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
<!-- Location Name -->
@@ -528,8 +751,67 @@ document.addEventListener('keydown', function(e) {
Location Name
</label>
<input type="text" id="report-location" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="e.g., NRL 1 - West Side">
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<!-- Time Filter Section -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Time Filter
</label>
<!-- Preset Buttons -->
<div class="flex gap-2 mb-3">
<button type="button" onclick="setTimePreset('night')" data-preset="night"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
7PM - 7AM
</button>
<button type="button" onclick="setTimePreset('day')" data-preset="day"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
7AM - 7PM
</button>
<button type="button" onclick="setTimePreset('all')" data-preset="all"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700 transition-colors">
All Day
</button>
<button type="button" onclick="setTimePreset('custom')" data-preset="custom"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
Custom
</button>
</div>
<!-- Custom Time Inputs -->
<div class="grid grid-cols-2 gap-3">
<div>
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400">Start Time</label>
<input type="time" id="start-time" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="end-time" class="block text-xs text-gray-500 dark:text-gray-400">End Time</label>
<input type="time" id="end-time" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
</div>
<!-- Date Range (Optional) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Date Range <span class="text-gray-400 font-normal">(optional)</span>
</label>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="date" id="start-date" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="end-date" class="block text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="date" id="end-date" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
</div>
<!-- Info about what's included -->
@@ -547,16 +829,24 @@ document.addEventListener('keydown', function(e) {
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2">
<button type="button" onclick="generateReport()"
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:px-6 flex flex-col sm:flex-row-reverse gap-2">
<button type="button" onclick="generateReport(false)"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-emerald-600 text-base font-medium text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
<svg class="w-4 h-4 mr-2" 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-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Generate & Download
Download Excel
</button>
<button type="button" onclick="generateReport(true)"
class="w-full inline-flex justify-center rounded-md border border-emerald-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
<svg class="w-4 h-4 mr-2" 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 & Edit
</button>
<button type="button" onclick="closeReportModal()"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:mt-0 sm:w-auto sm:text-sm">
class="w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
Cancel
</button>
</div>

View File

@@ -77,7 +77,7 @@
{% if not from_project and not from_nrl %}
<!-- Configure button only shown in administrative context (accessed from roster/SLM dashboard) -->
<div class="flex gap-3">
<button onclick="openConfigModal()"
<button onclick="openSLMSettingsModal('{{ unit_id }}')"
class="px-4 py-2 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 flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
@@ -104,73 +104,7 @@
</div>
</div>
<!-- Configuration Modal -->
<div id="config-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">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Configure {{ unit_id }}</h2>
<button onclick="closeConfigModal()" 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>
<div id="config-modal-content"
hx-get="/api/slm-dashboard/config/{{ unit_id }}"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Loading skeleton -->
<div class="p-6 space-y-4 animate-pulse">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
<script>
// Modal functions
function openConfigModal() {
const modal = document.getElementById('config-modal');
modal.classList.remove('hidden');
// Reload config when opening
htmx.ajax('GET', '/api/slm-dashboard/config/{{ unit_id }}', {
target: '#config-modal-content',
swap: 'innerHTML'
});
}
function closeConfigModal() {
document.getElementById('config-modal').classList.add('hidden');
}
// Keyboard shortcut
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeConfigModal();
}
});
// Click outside to close
document.getElementById('config-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeConfigModal();
}
});
// Listen for config updates to refresh live view
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.pathInfo.requestPath.includes('/config/') && event.detail.successful) {
// Refresh live view after config update
htmx.ajax('GET', '/api/slm-dashboard/live-view/{{ unit_id }}', {
target: '#live-view-content',
swap: 'innerHTML'
});
closeConfigModal();
}
});
</script>
<!-- Unified SLM Settings Modal -->
{% include 'partials/slm_settings_modal.html' %}
{% endblock %}

View File

@@ -137,27 +137,8 @@
</div>
</div>
<!-- Configuration Modal -->
<div id="slm-config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
<button onclick="closeDeviceConfigModal()" 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>
<div id="slm-config-modal-content">
<div class="animate-pulse space-y-4">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
<!-- Unified SLM Settings Modal -->
{% include 'partials/slm_settings_modal.html' %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
@@ -365,33 +346,23 @@ function updateDashboardChart(data) {
}
}
// Configuration modal
// Configuration modal - use unified SLM settings modal
function openDeviceConfigModal(unitId) {
const modal = document.getElementById('slm-config-modal');
modal.classList.remove('hidden');
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
target: '#slm-config-modal-content',
swap: 'innerHTML'
});
// Call the unified modal function from slm_settings_modal.html
if (typeof openSLMSettingsModal === 'function') {
openSLMSettingsModal(unitId);
} else {
console.error('openSLMSettingsModal not found');
}
}
function closeDeviceConfigModal() {
document.getElementById('slm-config-modal').classList.add('hidden');
// Call the unified modal close function
if (typeof closeSLMSettingsModal === 'function') {
closeSLMSettingsModal();
}
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDeviceConfigModal();
}
});
document.getElementById('slm-config-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeDeviceConfigModal();
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
stopDashboardStream();