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:
312
templates/combined_report_preview.html
Normal file
312
templates/combined_report_preview.html
Normal file
@@ -0,0 +1,312 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Combined Report Preview - {{ project.name }}{% 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">Combined Report Preview & Editor</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ location_data|length }} location{{ 's' if location_data|length != 1 else '' }}
|
||||
{% if time_filter_desc %} | {{ time_filter_desc }}{% endif %}
|
||||
| {{ total_rows }} total row{{ 's' if total_rows != 1 else '' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="downloadCombinedReport()" id="download-btn"
|
||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm font-medium">
|
||||
<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>
|
||||
Generate Excel
|
||||
</button>
|
||||
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
|
||||
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">
|
||||
← Back to Config
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
|
||||
|
||||
<!-- Report Metadata -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 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>
|
||||
</div>
|
||||
|
||||
<!-- Location Tabs + Spreadsheet -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||
<div class="flex min-w-max" id="tab-bar">
|
||||
{% for loc in location_data %}
|
||||
<button onclick="switchTab({{ loop.index0 }})"
|
||||
id="tab-btn-{{ loop.index0 }}"
|
||||
class="tab-btn px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
||||
{% if loop.first %}border-emerald-500 text-emerald-600 dark:text-emerald-400
|
||||
{% else %}border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300{% endif %}">
|
||||
{{ loc.location_name }}
|
||||
<span class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full
|
||||
{% if loop.first %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400
|
||||
{% else %}bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400{% endif %}"
|
||||
id="tab-count-{{ loop.index0 }}">
|
||||
{{ loc.filtered_count }}
|
||||
</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spreadsheet Panels -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white" id="active-tab-title">
|
||||
{{ location_data[0].location_name if location_data else '' }}
|
||||
</h3>
|
||||
<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>
|
||||
|
||||
{% for loc in location_data %}
|
||||
<div id="panel-{{ loop.index0 }}" class="tab-panel {% if not loop.first %}hidden{% endif %} overflow-x-auto">
|
||||
<div id="spreadsheet-{{ loop.index0 }}"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help -->
|
||||
<div class="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 insert or delete rows</li>
|
||||
<li>Press Enter to confirm edits, Escape to cancel</li>
|
||||
<li>Switch between location tabs to edit each location's data independently</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>
|
||||
const allLocationData = {{ locations_json | safe }};
|
||||
const spreadsheets = {};
|
||||
let activeTabIdx = 0;
|
||||
|
||||
const columnDef = [
|
||||
{ title: 'Test #', width: 80, type: 'numeric' },
|
||||
{ title: 'Date', width: 110, type: 'text' },
|
||||
{ title: 'Time', width: 90, type: 'text' },
|
||||
{ title: 'LAmax (dBA)', width: 110, type: 'numeric' },
|
||||
{ title: 'LA01 (dBA)', width: 110, type: 'numeric' },
|
||||
{ title: 'LA10 (dBA)', width: 110, type: 'numeric' },
|
||||
{ title: 'Comments', width: 250, type: 'text' },
|
||||
];
|
||||
|
||||
const jssOptions = {
|
||||
columns: columnDef,
|
||||
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;',
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
allLocationData.forEach(function(loc, idx) {
|
||||
const el = document.getElementById('spreadsheet-' + idx);
|
||||
if (!el) return;
|
||||
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
|
||||
spreadsheets[loc.location_name] = jspreadsheet(el, opts);
|
||||
});
|
||||
if (allLocationData.length > 0) {
|
||||
switchTab(0);
|
||||
}
|
||||
});
|
||||
|
||||
function switchTab(idx) {
|
||||
activeTabIdx = idx;
|
||||
|
||||
// Update panels
|
||||
document.querySelectorAll('.tab-panel').forEach(function(panel, i) {
|
||||
panel.classList.toggle('hidden', i !== idx);
|
||||
});
|
||||
|
||||
// Update tab button styles
|
||||
document.querySelectorAll('.tab-btn').forEach(function(btn, i) {
|
||||
const countBadge = document.getElementById('tab-count-' + i);
|
||||
if (i === idx) {
|
||||
btn.classList.add('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
|
||||
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||
if (countBadge) {
|
||||
countBadge.classList.add('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
|
||||
countBadge.classList.remove('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
|
||||
}
|
||||
} else {
|
||||
btn.classList.remove('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
|
||||
btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||
if (countBadge) {
|
||||
countBadge.classList.remove('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
|
||||
countBadge.classList.add('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update title
|
||||
if (allLocationData[idx]) {
|
||||
document.getElementById('active-tab-title').textContent = allLocationData[idx].location_name;
|
||||
}
|
||||
|
||||
// Refresh jspreadsheet rendering after showing panel
|
||||
const loc = allLocationData[idx];
|
||||
if (loc && spreadsheets[loc.location_name]) {
|
||||
try { spreadsheets[loc.location_name].updateTable(); } catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadCombinedReport() {
|
||||
const btn = document.getElementById('download-btn');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating...';
|
||||
|
||||
try {
|
||||
const locations = allLocationData.map(function(loc) {
|
||||
return {
|
||||
location_name: loc.location_name,
|
||||
spreadsheet_data: spreadsheets[loc.location_name] ? spreadsheets[loc.location_name].getData() : loc.spreadsheet_data,
|
||||
};
|
||||
});
|
||||
|
||||
const payload = {
|
||||
report_title: document.getElementById('edit-report-title').value || 'Background Noise Study',
|
||||
project_name: document.getElementById('edit-project-name').value || '',
|
||||
client_name: document.getElementById('edit-client-name').value || '',
|
||||
locations: locations,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/projects/{{ project_id }}/generate-combined-from-preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = 'combined_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);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Dark mode jspreadsheet styles */
|
||||
.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; }
|
||||
.jexcel_content { max-height: 600px; overflow: auto; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
371
templates/combined_report_wizard.html
Normal file
371
templates/combined_report_wizard.html
Normal 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 %}
|
||||
@@ -17,7 +17,7 @@
|
||||
<!-- Project Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||
<a href="/api/projects/{{ project.id }}/generate-combined-report"
|
||||
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
|
||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
||||
<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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
|
||||
Reference in New Issue
Block a user