Files
terra-view/templates/combined_report_wizard.html
serversdown ef8c046f31 feat: add slm model schemas, please run migration on prod db
Feat: add complete combined sound report creation tool (wizard), add new slm schema for each model

feat: update project header link for combined report wizard

feat: add migration script to backfill device_model in monitoring_sessions

feat: implement combined report preview template with spreadsheet functionality

feat: create combined report wizard template for report generation.
2026-03-05 20:43:22 +00:00

372 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Combined Report Wizard - {{ project.name }}{% endblock %}
{% block content %}
<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-4xl 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">Combined Report Wizard</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ project.name }}</p>
</div>
<a href="/api/projects/{{ project_id }}"
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 text-sm w-fit">
← Back to Project
</a>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
<!-- Report Settings Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Report Settings</h2>
<!-- Template Selection -->
<div class="flex items-end gap-2 mb-4">
<div class="flex-1">
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Load Template
</label>
<select id="template-select" onchange="applyTemplate()"
class="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="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 class="mb-4">
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Report Title
</label>
<input type="text" id="report-title" value="Background Noise Study"
class="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 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project Name
</label>
<input type="text" id="report-project" value="{{ project.name }}"
class="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 mb-1">
Client Name
</label>
<input type="text" id="report-client" value="{{ project.client_name if project.client_name else '' }}"
class="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>
<!-- Time Filter Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Time Filter</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Applied to all locations. Leave blank to include all data.</p>
<!-- Preset Buttons -->
<div class="flex flex-wrap gap-2 mb-4">
<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">
Night 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">
Day 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>
<!-- Time Inputs -->
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Time</label>
<input type="time" id="start-time" value=""
onchange="updatePresetButtons()"
class="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 mb-1">End Time</label>
<input type="time" id="end-time" value=""
onchange="updatePresetButtons()"
class="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>
<!-- Date Range -->
<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-4">
<div>
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From</label>
<input type="date" id="start-date" value=""
class="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 mb-1">To</label>
<input type="date" id="end-date" value=""
class="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>
</div>
<!-- Locations Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Locations to Include</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
<span id="selected-count">{{ locations|length }}</span> of {{ locations|length }} selected
</p>
</div>
<div class="flex gap-3 text-sm">
<button type="button" onclick="selectAll()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button>
<button type="button" onclick="deselectAll()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
</div>
</div>
{% if locations %}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{% for loc in locations %}
<label class="flex items-center gap-3 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700/50 px-2 rounded-md transition-colors">
<input type="checkbox" name="location" value="{{ loc.name }}" checked
onchange="updateSelectedCount()"
class="h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500">
<span class="flex-1 text-sm text-gray-900 dark:text-white font-medium">{{ loc.name }}</span>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ loc.file_count }} file{{ 's' if loc.file_count != 1 else '' }}</span>
</label>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No Leq measurement files found in this project.</p>
<p class="text-sm mt-1">Upload RND files with '_Leq_' in the filename to generate reports.</p>
</div>
{% endif %}
</div>
<!-- Footer Buttons -->
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
<a href="/api/projects/{{ project_id }}"
class="w-full sm:w-auto px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors text-center text-sm font-medium">
Cancel
</a>
<button type="button" onclick="gotoPreview()" id="preview-btn"
{% if not locations %}disabled{% endif %}
class="w-full sm:w-auto px-6 py-2.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
Preview & Edit →
</button>
</div>
</div>
</div>
<script>
let reportTemplates = [];
// ---- Template management (same as rnd_viewer.html) ----
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;
updatePresetButtons();
}
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);
}
}
// ---- Time preset buttons ----
function setTimePreset(preset) {
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
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');
});
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':
break;
}
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;
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');
});
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');
}
}
// ---- Location checkboxes ----
function updateSelectedCount() {
const checked = document.querySelectorAll('input[name="location"]:checked').length;
document.getElementById('selected-count').textContent = checked;
document.getElementById('preview-btn').disabled = checked === 0;
}
function selectAll() {
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = true);
updateSelectedCount();
}
function deselectAll() {
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = false);
updateSelectedCount();
}
function getCheckedLocations() {
return Array.from(document.querySelectorAll('input[name="location"]:checked')).map(cb => cb.value);
}
// ---- Navigate to preview ----
function gotoPreview() {
const checked = getCheckedLocations();
if (checked.length === 0) {
alert('Please select at least one location.');
return;
}
const params = new URLSearchParams({
report_title: document.getElementById('report-title').value || 'Background Noise Study',
project_name: document.getElementById('report-project').value || '',
client_name: document.getElementById('report-client').value || '',
start_time: document.getElementById('start-time').value || '',
end_time: document.getElementById('end-time').value || '',
start_date: document.getElementById('start-date').value || '',
end_date: document.getElementById('end-date').value || '',
enabled_locations: checked.join(','),
});
window.location.href = `/api/projects/{{ project_id }}/combined-report-preview?${params.toString()}`;
}
// ---- Init ----
document.addEventListener('DOMContentLoaded', function () {
loadTemplates();
});
</script>
{% endblock %}