- 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.
857 lines
43 KiB
HTML
857 lines
43 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ filename }} - Sound Level Data Viewer{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<!-- Chart.js -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
|
|
<style>
|
|
.data-table {
|
|
max-height: 500px;
|
|
overflow-y: auto;
|
|
}
|
|
.data-table table {
|
|
font-size: 0.75rem;
|
|
}
|
|
.data-table th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
}
|
|
.metric-card {
|
|
transition: transform 0.2s;
|
|
}
|
|
.metric-card:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-7xl mx-auto">
|
|
<!-- Header with breadcrumb -->
|
|
<div class="mb-6">
|
|
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-2">
|
|
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
|
|
<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="M9 5l7 7-7 7"></path>
|
|
</svg>
|
|
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name if project else 'Project' }}</a>
|
|
<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="M9 5l7 7-7 7"></path>
|
|
</svg>
|
|
<span class="text-gray-900 dark:text-white">{{ filename }}</span>
|
|
</nav>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
|
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
|
</svg>
|
|
{{ filename }}
|
|
</h1>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
Sound Level Meter Measurement Data
|
|
{% if unit %} - {{ unit.id }}{% endif %}
|
|
{% if location %} @ {{ location.name }}{% endif %}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
|
|
{% if file and '_Leq_' in file.file_path %}
|
|
<!-- Generate Excel Report Button -->
|
|
<button onclick="openReportModal()"
|
|
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="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>
|
|
</svg>
|
|
Generate Excel Report
|
|
</button>
|
|
{% endif %}
|
|
<a href="/api/projects/{{ project_id }}/files/{{ file_id }}/download"
|
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy 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 RND
|
|
</a>
|
|
<a href="/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">
|
|
Back to Project
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div id="loading-state" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-12 text-center">
|
|
<svg class="w-12 h-12 mx-auto mb-4 text-seismo-orange animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
</svg>
|
|
<p class="text-gray-500 dark:text-gray-400">Loading measurement data...</p>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div id="error-state" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-12 text-center">
|
|
<svg class="w-12 h-12 mx-auto mb-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
|
</svg>
|
|
<p class="text-red-500 font-semibold mb-2">Error Loading Data</p>
|
|
<p id="error-message" class="text-gray-500 dark:text-gray-400"></p>
|
|
</div>
|
|
|
|
<!-- Data Content (hidden until loaded) -->
|
|
<div id="data-content" class="hidden space-y-6">
|
|
<!-- Summary Cards -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">File Type</div>
|
|
<div id="summary-file-type" class="text-lg font-bold text-gray-900 dark:text-white mt-1">-</div>
|
|
</div>
|
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Rows</div>
|
|
<div id="summary-rows" class="text-lg font-bold text-gray-900 dark:text-white mt-1">-</div>
|
|
</div>
|
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Start Time</div>
|
|
<div id="summary-start" class="text-sm font-bold text-gray-900 dark:text-white mt-1">-</div>
|
|
</div>
|
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">End Time</div>
|
|
<div id="summary-end" class="text-sm font-bold text-gray-900 dark:text-white mt-1">-</div>
|
|
</div>
|
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4 bg-blue-50 dark:bg-blue-900/20">
|
|
<div class="text-xs text-blue-600 dark:text-blue-400 uppercase tracking-wide">Avg Level</div>
|
|
<div id="summary-avg" class="text-lg font-bold text-blue-700 dark:text-blue-300 mt-1">- dB</div>
|
|
</div>
|
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4 bg-red-50 dark:bg-red-900/20">
|
|
<div class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide">Max Level</div>
|
|
<div id="summary-max" class="text-lg font-bold text-red-700 dark:text-red-300 mt-1">- dB</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chart -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Sound Level Over Time</h2>
|
|
<div class="flex items-center gap-2">
|
|
<label class="text-sm text-gray-500 dark:text-gray-400">Show:</label>
|
|
<select id="chart-metric-select" class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
<option value="all">All Metrics</option>
|
|
<option value="leq">Leq Only</option>
|
|
<option value="lmax">Lmax Only</option>
|
|
<option value="lmin">Lmin Only</option>
|
|
<option value="lpeak">Lpeak Only</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="relative" style="height: 400px;">
|
|
<canvas id="soundLevelChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Data Table -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Measurement Data</h2>
|
|
<div class="flex items-center gap-4">
|
|
<input type="text" id="table-search" placeholder="Search..."
|
|
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-48">
|
|
<span id="row-count" class="text-sm text-gray-500 dark:text-gray-400"></span>
|
|
</div>
|
|
</div>
|
|
<div class="data-table">
|
|
<table class="w-full">
|
|
<thead id="table-header" class="bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-400 text-xs uppercase">
|
|
<!-- Headers will be inserted here -->
|
|
</thead>
|
|
<tbody id="table-body" class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
<!-- Data rows will be inserted here -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let chartInstance = null;
|
|
let allData = [];
|
|
let allHeaders = [];
|
|
|
|
// Load data on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadRndData();
|
|
});
|
|
|
|
async function loadRndData() {
|
|
try {
|
|
const response = await fetch('/api/projects/{{ project_id }}/files/{{ file_id }}/rnd-data');
|
|
const result = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(result.detail || 'Failed to load data');
|
|
}
|
|
|
|
// Store data globally
|
|
allData = result.data;
|
|
allHeaders = result.headers;
|
|
|
|
// Update summary cards
|
|
updateSummary(result.summary);
|
|
|
|
// Render chart
|
|
renderChart(result.data, result.summary.file_type);
|
|
|
|
// Render table
|
|
renderTable(result.headers, result.data);
|
|
|
|
// Show content, hide loading
|
|
document.getElementById('loading-state').classList.add('hidden');
|
|
document.getElementById('data-content').classList.remove('hidden');
|
|
|
|
} catch (error) {
|
|
console.error('Error loading RND data:', error);
|
|
document.getElementById('loading-state').classList.add('hidden');
|
|
document.getElementById('error-state').classList.remove('hidden');
|
|
document.getElementById('error-message').textContent = error.message;
|
|
}
|
|
}
|
|
|
|
function updateSummary(summary) {
|
|
document.getElementById('summary-file-type').textContent =
|
|
summary.file_type === 'leq' ? 'Leq (Time-Averaged)' :
|
|
summary.file_type === 'lp' ? 'Lp (Instantaneous)' : 'Unknown';
|
|
|
|
document.getElementById('summary-rows').textContent = summary.total_rows.toLocaleString();
|
|
document.getElementById('summary-start').textContent = summary.time_start || '-';
|
|
document.getElementById('summary-end').textContent = summary.time_end || '-';
|
|
|
|
// Find the main metric based on file type
|
|
const avgKey = summary.file_type === 'leq' ? 'Leq(Main)_avg' : 'Lp(Main)_avg';
|
|
const maxKey = summary.file_type === 'leq' ? 'Lmax(Main)_max' : 'Lmax(Main)_max';
|
|
|
|
if (summary[avgKey] !== undefined) {
|
|
document.getElementById('summary-avg').textContent = summary[avgKey].toFixed(1) + ' dB';
|
|
}
|
|
if (summary[maxKey] !== undefined) {
|
|
document.getElementById('summary-max').textContent = summary[maxKey].toFixed(1) + ' dB';
|
|
} else if (summary['Lpeak(Main)_max'] !== undefined) {
|
|
document.getElementById('summary-max').textContent = summary['Lpeak(Main)_max'].toFixed(1) + ' dB';
|
|
}
|
|
}
|
|
|
|
function renderChart(data, fileType) {
|
|
const ctx = document.getElementById('soundLevelChart').getContext('2d');
|
|
|
|
// Prepare datasets based on file type
|
|
const datasets = [];
|
|
const labels = data.map(row => row['Start Time'] || '');
|
|
|
|
// Define metrics to chart based on file type
|
|
const metricsConfig = fileType === 'leq' ? [
|
|
{ key: 'Leq(Main)', label: 'Leq', color: 'rgb(59, 130, 246)', fill: false },
|
|
{ key: 'Lmax(Main)', label: 'Lmax', color: 'rgb(239, 68, 68)', fill: false },
|
|
{ key: 'Lmin(Main)', label: 'Lmin', color: 'rgb(34, 197, 94)', fill: false },
|
|
{ key: 'Lpeak(Main)', label: 'Lpeak', color: 'rgb(168, 85, 247)', fill: false },
|
|
] : [
|
|
{ key: 'Lp(Main)', label: 'Lp', color: 'rgb(59, 130, 246)', fill: false },
|
|
{ key: 'Leq(Main)', label: 'Leq', color: 'rgb(249, 115, 22)', fill: false },
|
|
{ key: 'Lmax(Main)', label: 'Lmax', color: 'rgb(239, 68, 68)', fill: false },
|
|
{ key: 'Lmin(Main)', label: 'Lmin', color: 'rgb(34, 197, 94)', fill: false },
|
|
{ key: 'Lpeak(Main)', label: 'Lpeak', color: 'rgb(168, 85, 247)', fill: false },
|
|
];
|
|
|
|
metricsConfig.forEach(metric => {
|
|
const values = data.map(row => {
|
|
const val = row[metric.key];
|
|
return typeof val === 'number' ? val : null;
|
|
});
|
|
|
|
// Only add if we have data
|
|
if (values.some(v => v !== null)) {
|
|
datasets.push({
|
|
label: metric.label,
|
|
data: values,
|
|
borderColor: metric.color,
|
|
backgroundColor: metric.color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
|
|
fill: metric.fill,
|
|
tension: 0.1,
|
|
pointRadius: data.length > 100 ? 0 : 2,
|
|
borderWidth: 1.5,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Downsample if too many points
|
|
let chartLabels = labels;
|
|
let chartDatasets = datasets;
|
|
|
|
if (data.length > 1000) {
|
|
const sampleRate = Math.ceil(data.length / 1000);
|
|
chartLabels = labels.filter((_, i) => i % sampleRate === 0);
|
|
chartDatasets = datasets.map(ds => ({
|
|
...ds,
|
|
data: ds.data.filter((_, i) => i % sampleRate === 0)
|
|
}));
|
|
}
|
|
|
|
chartInstance = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: chartLabels,
|
|
datasets: chartDatasets
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'top',
|
|
labels: {
|
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
|
usePointStyle: true,
|
|
}
|
|
},
|
|
tooltip: {
|
|
backgroundColor: document.documentElement.classList.contains('dark') ? '#1f2937' : '#ffffff',
|
|
titleColor: document.documentElement.classList.contains('dark') ? '#f9fafb' : '#111827',
|
|
bodyColor: document.documentElement.classList.contains('dark') ? '#d1d5db' : '#4b5563',
|
|
borderColor: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
|
|
borderWidth: 1,
|
|
callbacks: {
|
|
label: function(context) {
|
|
return context.dataset.label + ': ' + (context.parsed.y !== null ? context.parsed.y.toFixed(1) + ' dB' : 'N/A');
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
display: true,
|
|
title: {
|
|
display: true,
|
|
text: 'Time',
|
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
|
},
|
|
ticks: {
|
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
|
maxTicksLimit: 10,
|
|
maxRotation: 45,
|
|
},
|
|
grid: {
|
|
color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
|
|
}
|
|
},
|
|
y: {
|
|
display: true,
|
|
title: {
|
|
display: true,
|
|
text: 'Sound Level (dB)',
|
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
|
},
|
|
ticks: {
|
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
|
},
|
|
grid: {
|
|
color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Chart metric filter
|
|
document.getElementById('chart-metric-select').addEventListener('change', function(e) {
|
|
const value = e.target.value;
|
|
chartInstance.data.datasets.forEach((ds, i) => {
|
|
if (value === 'all') {
|
|
ds.hidden = false;
|
|
} else if (value === 'leq') {
|
|
ds.hidden = !['Leq', 'Lp'].includes(ds.label);
|
|
} else if (value === 'lmax') {
|
|
ds.hidden = ds.label !== 'Lmax';
|
|
} else if (value === 'lmin') {
|
|
ds.hidden = ds.label !== 'Lmin';
|
|
} else if (value === 'lpeak') {
|
|
ds.hidden = ds.label !== 'Lpeak';
|
|
}
|
|
});
|
|
chartInstance.update();
|
|
});
|
|
}
|
|
|
|
function renderTable(headers, data) {
|
|
const headerRow = document.getElementById('table-header');
|
|
const tbody = document.getElementById('table-body');
|
|
|
|
// Render headers
|
|
headerRow.innerHTML = '<tr>' + headers.map(h =>
|
|
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
|
|
).join('') + '</tr>';
|
|
|
|
// Render rows (limit to first 500 for performance)
|
|
const displayData = data.slice(0, 500);
|
|
tbody.innerHTML = displayData.map(row =>
|
|
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
|
headers.map(h => {
|
|
const val = row[h];
|
|
let displayVal = val;
|
|
if (val === null || val === undefined) {
|
|
displayVal = '-';
|
|
} else if (typeof val === 'number') {
|
|
displayVal = val.toFixed(1);
|
|
}
|
|
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
|
}).join('') +
|
|
'</tr>'
|
|
).join('');
|
|
|
|
// Update row count
|
|
document.getElementById('row-count').textContent =
|
|
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows` : `${data.length.toLocaleString()} rows`;
|
|
|
|
// Search functionality
|
|
document.getElementById('table-search').addEventListener('input', function(e) {
|
|
const searchTerm = e.target.value.toLowerCase();
|
|
const filtered = data.filter(row =>
|
|
Object.values(row).some(v =>
|
|
String(v).toLowerCase().includes(searchTerm)
|
|
)
|
|
);
|
|
|
|
const displayFiltered = filtered.slice(0, 500);
|
|
tbody.innerHTML = displayFiltered.map(row =>
|
|
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
|
headers.map(h => {
|
|
const val = row[h];
|
|
let displayVal = val;
|
|
if (val === null || val === undefined) {
|
|
displayVal = '-';
|
|
} else if (typeof val === 'number') {
|
|
displayVal = val.toFixed(1);
|
|
}
|
|
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
|
}).join('') +
|
|
'</tr>'
|
|
).join('');
|
|
|
|
document.getElementById('row-count').textContent =
|
|
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
|
});
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// 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');
|
|
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(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,
|
|
project_name: projectName,
|
|
client_name: clientName,
|
|
location_name: locationName,
|
|
start_time: startTime,
|
|
end_time: endTime,
|
|
start_date: startDate,
|
|
end_date: endDate
|
|
});
|
|
|
|
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()}`;
|
|
}
|
|
|
|
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 -->
|
|
<div id="report-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
<!-- Background overlay -->
|
|
<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-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">
|
|
<svg class="h-6 w-6 text-emerald-600 dark:text-emerald-400" 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>
|
|
</svg>
|
|
</div>
|
|
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
|
<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">
|
|
</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 -->
|
|
<div>
|
|
<label for="report-location" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
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">
|
|
</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 -->
|
|
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
<strong>Report will include:</strong>
|
|
</p>
|
|
<ul class="mt-1 text-xs text-gray-500 dark:text-gray-400 list-disc list-inside space-y-0.5">
|
|
<li>Data table (Test #, Date, Time, LAmax, LA01, LA10, Comments)</li>
|
|
<li>Line chart visualization</li>
|
|
<li>Time period summary (Evening, Night, Morning, Daytime)</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
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="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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|