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.
This commit is contained in:
2026-03-05 20:43:22 +00:00
parent 3637cf5af8
commit ef8c046f31
10 changed files with 1402 additions and 4 deletions

View File

@@ -0,0 +1,371 @@
{% 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 %}