Compare commits
2 Commits
ff38b74548
...
a9c9b1fd48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9c9b1fd48 | ||
|
|
4c213c96ee |
@@ -10,7 +10,7 @@ Provides API endpoints for the Projects system:
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, and_
|
from sqlalchemy import func, and_
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -18,6 +18,7 @@ from typing import Optional
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import io
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
@@ -644,6 +645,8 @@ async def ftp_download_to_server(
|
|||||||
'.flac': 'audio',
|
'.flac': 'audio',
|
||||||
'.m4a': 'audio',
|
'.m4a': 'audio',
|
||||||
'.aac': 'audio',
|
'.aac': 'audio',
|
||||||
|
# Sound level meter measurement files
|
||||||
|
'.rnd': 'measurement',
|
||||||
# Data files
|
# Data files
|
||||||
'.csv': 'data',
|
'.csv': 'data',
|
||||||
'.txt': 'data',
|
'.txt': 'data',
|
||||||
@@ -1023,6 +1026,897 @@ async def download_project_file(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/sessions/{session_id}/download-all")
|
||||||
|
async def download_session_files(
|
||||||
|
project_id: str,
|
||||||
|
session_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Download all files from a session as a single zip archive.
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
from pathlib import Path
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# Verify session belongs to this project
|
||||||
|
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if session.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Session does not belong to this project")
|
||||||
|
|
||||||
|
# Get all files for this session
|
||||||
|
files = db.query(DataFile).filter_by(session_id=session_id).all()
|
||||||
|
if not files:
|
||||||
|
raise HTTPException(status_code=404, detail="No files found in this session")
|
||||||
|
|
||||||
|
# Create zip in memory
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
|
||||||
|
# Get session info for folder naming
|
||||||
|
session_date = session.started_at.strftime('%Y-%m-%d_%H%M') if session.started_at else 'unknown'
|
||||||
|
|
||||||
|
# Get unit and location for naming
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.unit_id else None
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||||
|
|
||||||
|
unit_name = unit.id if unit else "unknown_unit"
|
||||||
|
location_name = location.name.replace(" ", "_") if location else ""
|
||||||
|
|
||||||
|
# Build folder name for zip contents
|
||||||
|
folder_name = f"{session_date}_{unit_name}"
|
||||||
|
if location_name:
|
||||||
|
folder_name += f"_{location_name}"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
for file_record in files:
|
||||||
|
file_path = Path("data") / file_record.file_path
|
||||||
|
if file_path.exists():
|
||||||
|
# Add file to zip with folder structure
|
||||||
|
arcname = f"{folder_name}/{file_path.name}"
|
||||||
|
zip_file.write(file_path, arcname)
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
|
||||||
|
# Generate filename for the zip
|
||||||
|
zip_filename = f"{folder_name}.zip"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
zip_buffer,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={zip_filename}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/files/{file_id}")
|
||||||
|
async def delete_project_file(
|
||||||
|
project_id: str,
|
||||||
|
file_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a single data file from a project.
|
||||||
|
Removes both the database record and the file on disk.
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Get the file record
|
||||||
|
file_record = db.query(DataFile).filter_by(id=file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
# Verify file belongs to this project
|
||||||
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
|
if not session or session.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
|
# Delete file from disk if it exists
|
||||||
|
file_path = Path("data") / file_record.file_path
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
|
||||||
|
# Delete database record
|
||||||
|
db.delete(file_record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return JSONResponse({"status": "success", "message": "File deleted"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/sessions/{session_id}")
|
||||||
|
async def delete_session(
|
||||||
|
project_id: str,
|
||||||
|
session_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete an entire session and all its files.
|
||||||
|
Removes database records and files on disk.
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Verify session belongs to this project
|
||||||
|
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if session.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Session does not belong to this project")
|
||||||
|
|
||||||
|
# Get all files for this session
|
||||||
|
files = db.query(DataFile).filter_by(session_id=session_id).all()
|
||||||
|
|
||||||
|
# Delete files from disk
|
||||||
|
deleted_count = 0
|
||||||
|
for file_record in files:
|
||||||
|
file_path = Path("data") / file_record.file_path
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
deleted_count += 1
|
||||||
|
# Delete database record
|
||||||
|
db.delete(file_record)
|
||||||
|
|
||||||
|
# Delete the session record
|
||||||
|
db.delete(session)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Session and {deleted_count} file(s) deleted"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
|
||||||
|
async def view_rnd_file(
|
||||||
|
request: Request,
|
||||||
|
project_id: str,
|
||||||
|
file_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
View an RND (sound level meter measurement) file.
|
||||||
|
Returns a dedicated page with data table and charts.
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Get the file record
|
||||||
|
file_record = db.query(DataFile).filter_by(id=file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
# Verify file belongs to this project
|
||||||
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
|
if not session or session.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
|
# Build full file path
|
||||||
|
file_path = Path("data") / file_record.file_path
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||||
|
|
||||||
|
# Get project info
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
|
||||||
|
# Get location info if available
|
||||||
|
location = None
|
||||||
|
if session.location_id:
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
|
||||||
|
|
||||||
|
# Get unit info if available
|
||||||
|
unit = None
|
||||||
|
if session.unit_id:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()
|
||||||
|
|
||||||
|
# Parse file metadata
|
||||||
|
metadata = {}
|
||||||
|
if file_record.file_metadata:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(file_record.file_metadata)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return templates.TemplateResponse("rnd_viewer.html", {
|
||||||
|
"request": request,
|
||||||
|
"project": project,
|
||||||
|
"project_id": project_id,
|
||||||
|
"file": file_record,
|
||||||
|
"file_id": file_id,
|
||||||
|
"session": session,
|
||||||
|
"location": location,
|
||||||
|
"unit": unit,
|
||||||
|
"metadata": metadata,
|
||||||
|
"filename": file_path.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/files/{file_id}/rnd-data")
|
||||||
|
async def get_rnd_data(
|
||||||
|
project_id: str,
|
||||||
|
file_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get parsed RND file data as JSON.
|
||||||
|
Returns the measurement data for charts and tables.
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
from pathlib import Path
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Get the file record
|
||||||
|
file_record = db.query(DataFile).filter_by(id=file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
# Verify file belongs to this project
|
||||||
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
|
if not session or session.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
|
# Build full file path
|
||||||
|
file_path = Path("data") / file_record.file_path
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||||
|
|
||||||
|
# Read and parse the RND file
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Parse as CSV
|
||||||
|
reader = csv.DictReader(io.StringIO(content))
|
||||||
|
rows = []
|
||||||
|
headers = []
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
if not headers:
|
||||||
|
headers = list(row.keys())
|
||||||
|
# Clean up values - strip whitespace and handle special values
|
||||||
|
cleaned_row = {}
|
||||||
|
for key, value in row.items():
|
||||||
|
if key: # Skip empty keys
|
||||||
|
cleaned_key = key.strip()
|
||||||
|
cleaned_value = value.strip() if value else ''
|
||||||
|
# Convert numeric values
|
||||||
|
if cleaned_value and cleaned_value not in ['-.-', '-', '']:
|
||||||
|
try:
|
||||||
|
cleaned_value = float(cleaned_value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif cleaned_value in ['-.-', '-']:
|
||||||
|
cleaned_value = None
|
||||||
|
cleaned_row[cleaned_key] = cleaned_value
|
||||||
|
rows.append(cleaned_row)
|
||||||
|
|
||||||
|
# Detect file type (Leq vs Lp) based on columns
|
||||||
|
file_type = 'unknown'
|
||||||
|
if headers:
|
||||||
|
header_str = ','.join(headers).lower()
|
||||||
|
if 'leq' in header_str:
|
||||||
|
file_type = 'leq' # Time-averaged data
|
||||||
|
elif 'lp(main)' in header_str or 'lp (main)' in header_str:
|
||||||
|
file_type = 'lp' # Instantaneous data
|
||||||
|
|
||||||
|
# Get summary statistics
|
||||||
|
summary = {
|
||||||
|
"total_rows": len(rows),
|
||||||
|
"file_type": file_type,
|
||||||
|
"headers": [h.strip() for h in headers if h.strip()],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate min/max/avg for key metrics if available
|
||||||
|
metrics_to_summarize = ['Leq(Main)', 'Lmax(Main)', 'Lmin(Main)', 'Lpeak(Main)', 'Lp(Main)']
|
||||||
|
for metric in metrics_to_summarize:
|
||||||
|
values = [row.get(metric) for row in rows if isinstance(row.get(metric), (int, float))]
|
||||||
|
if values:
|
||||||
|
summary[f"{metric}_min"] = min(values)
|
||||||
|
summary[f"{metric}_max"] = max(values)
|
||||||
|
summary[f"{metric}_avg"] = sum(values) / len(values)
|
||||||
|
|
||||||
|
# Get time range
|
||||||
|
if rows:
|
||||||
|
first_time = rows[0].get('Start Time', '')
|
||||||
|
last_time = rows[-1].get('Start Time', '')
|
||||||
|
summary['time_start'] = first_time
|
||||||
|
summary['time_end'] = last_time
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"summary": summary,
|
||||||
|
"headers": summary["headers"],
|
||||||
|
"data": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing RND file: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error parsing file: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/files/{file_id}/generate-report")
|
||||||
|
async def generate_excel_report(
|
||||||
|
project_id: str,
|
||||||
|
file_id: str,
|
||||||
|
report_title: str = Query("Background Noise Study", description="Title for the report"),
|
||||||
|
location_name: str = Query("", description="Location name (e.g., 'NRL 1 - West Side')"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate an Excel report from an RND file.
|
||||||
|
|
||||||
|
Creates a formatted Excel workbook with:
|
||||||
|
- Title and location headers
|
||||||
|
- Data table (Test #, Date, Time, LAmax, LA01, LA10, Comments)
|
||||||
|
- Line chart visualization
|
||||||
|
- Time period summary statistics
|
||||||
|
|
||||||
|
Column mapping from RND to Report:
|
||||||
|
- Lmax(Main) -> LAmax (dBA)
|
||||||
|
- LN1(Main) -> LA01 (dBA) [L1 percentile]
|
||||||
|
- LN2(Main) -> LA10 (dBA) [L10 percentile]
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
from pathlib import Path
|
||||||
|
import csv
|
||||||
|
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
from openpyxl.chart import LineChart, Reference
|
||||||
|
from openpyxl.chart.label import DataLabelList
|
||||||
|
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="openpyxl is not installed. Run: pip install openpyxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the file record
|
||||||
|
file_record = db.query(DataFile).filter_by(id=file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
# Verify file belongs to this project
|
||||||
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
|
if not session or session.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
|
# Get related data for report context
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||||
|
|
||||||
|
# Build full file path
|
||||||
|
file_path = Path("data") / file_record.file_path
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||||
|
|
||||||
|
# Validate this is a Leq file (contains '_Leq_' in path)
|
||||||
|
# Lp files (instantaneous 100ms readings) don't have the LN percentile data needed for reports
|
||||||
|
if '_Leq_' not in file_record.file_path:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Reports can only be generated from Leq files (15-minute averaged data). This appears to be an Lp (instantaneous) file."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read and parse the Leq RND file
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(content))
|
||||||
|
rnd_rows = []
|
||||||
|
for row in reader:
|
||||||
|
cleaned_row = {}
|
||||||
|
for key, value in row.items():
|
||||||
|
if key:
|
||||||
|
cleaned_key = key.strip()
|
||||||
|
cleaned_value = value.strip() if value else ''
|
||||||
|
if cleaned_value and cleaned_value not in ['-.-', '-', '']:
|
||||||
|
try:
|
||||||
|
cleaned_value = float(cleaned_value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif cleaned_value in ['-.-', '-']:
|
||||||
|
cleaned_value = None
|
||||||
|
cleaned_row[cleaned_key] = cleaned_value
|
||||||
|
rnd_rows.append(cleaned_row)
|
||||||
|
|
||||||
|
if not rnd_rows:
|
||||||
|
raise HTTPException(status_code=400, detail="No data found in RND file")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading RND file: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
|
||||||
|
|
||||||
|
# Create Excel workbook
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Sound Level Data"
|
||||||
|
|
||||||
|
# Define styles
|
||||||
|
title_font = Font(bold=True, size=14)
|
||||||
|
header_font = Font(bold=True, size=10)
|
||||||
|
thin_border = Border(
|
||||||
|
left=Side(style='thin'),
|
||||||
|
right=Side(style='thin'),
|
||||||
|
top=Side(style='thin'),
|
||||||
|
bottom=Side(style='thin')
|
||||||
|
)
|
||||||
|
header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid")
|
||||||
|
|
||||||
|
# Row 1: Report title
|
||||||
|
final_title = report_title
|
||||||
|
if project:
|
||||||
|
final_title = f"{report_title} - {project.name}"
|
||||||
|
ws['A1'] = final_title
|
||||||
|
ws['A1'].font = title_font
|
||||||
|
ws.merge_cells('A1:G1')
|
||||||
|
|
||||||
|
# Row 3: Location name
|
||||||
|
final_location = location_name
|
||||||
|
if not final_location and location:
|
||||||
|
final_location = location.name
|
||||||
|
if final_location:
|
||||||
|
ws['A3'] = final_location
|
||||||
|
ws['A3'].font = Font(bold=True, size=11)
|
||||||
|
|
||||||
|
# Row 7: Headers
|
||||||
|
headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
|
||||||
|
for col, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=7, column=col, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = Alignment(horizontal='center')
|
||||||
|
|
||||||
|
# Set column widths
|
||||||
|
column_widths = [16, 12, 10, 12, 12, 12, 40]
|
||||||
|
for i, width in enumerate(column_widths, 1):
|
||||||
|
ws.column_dimensions[get_column_letter(i)].width = width
|
||||||
|
|
||||||
|
# Data rows starting at row 8
|
||||||
|
data_start_row = 8
|
||||||
|
for idx, row in enumerate(rnd_rows, 1):
|
||||||
|
data_row = data_start_row + idx - 1
|
||||||
|
|
||||||
|
# Test Increment #
|
||||||
|
ws.cell(row=data_row, column=1, value=idx).border = thin_border
|
||||||
|
|
||||||
|
# Parse the Start Time to get Date and Time
|
||||||
|
start_time_str = row.get('Start Time', '')
|
||||||
|
if start_time_str:
|
||||||
|
try:
|
||||||
|
# Format: "2025/12/26 20:23:38"
|
||||||
|
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||||
|
ws.cell(row=data_row, column=2, value=dt.date())
|
||||||
|
ws.cell(row=data_row, column=3, value=dt.time())
|
||||||
|
except ValueError:
|
||||||
|
ws.cell(row=data_row, column=2, value=start_time_str)
|
||||||
|
ws.cell(row=data_row, column=3, value='')
|
||||||
|
else:
|
||||||
|
ws.cell(row=data_row, column=2, value='')
|
||||||
|
ws.cell(row=data_row, column=3, value='')
|
||||||
|
|
||||||
|
# LAmax - from Lmax(Main)
|
||||||
|
lmax = row.get('Lmax(Main)')
|
||||||
|
ws.cell(row=data_row, column=4, value=lmax if lmax else '').border = thin_border
|
||||||
|
|
||||||
|
# LA01 - from LN1(Main)
|
||||||
|
ln1 = row.get('LN1(Main)')
|
||||||
|
ws.cell(row=data_row, column=5, value=ln1 if ln1 else '').border = thin_border
|
||||||
|
|
||||||
|
# LA10 - from LN2(Main)
|
||||||
|
ln2 = row.get('LN2(Main)')
|
||||||
|
ws.cell(row=data_row, column=6, value=ln2 if ln2 else '').border = thin_border
|
||||||
|
|
||||||
|
# Comments (empty for now, can be populated)
|
||||||
|
ws.cell(row=data_row, column=7, value='').border = thin_border
|
||||||
|
|
||||||
|
# Apply borders to date/time cells
|
||||||
|
ws.cell(row=data_row, column=2).border = thin_border
|
||||||
|
ws.cell(row=data_row, column=3).border = thin_border
|
||||||
|
|
||||||
|
data_end_row = data_start_row + len(rnd_rows) - 1
|
||||||
|
|
||||||
|
# Add Line Chart
|
||||||
|
chart = LineChart()
|
||||||
|
chart.title = f"{final_location or 'Sound Level Data'} - Background Noise Study"
|
||||||
|
chart.style = 10
|
||||||
|
chart.y_axis.title = "Sound Level (dBA)"
|
||||||
|
chart.x_axis.title = "Test Increment"
|
||||||
|
chart.height = 12
|
||||||
|
chart.width = 20
|
||||||
|
|
||||||
|
# Data references (LAmax, LA01, LA10 are columns D, E, F)
|
||||||
|
data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row)
|
||||||
|
categories = Reference(ws, min_col=1, min_row=data_start_row, max_row=data_end_row)
|
||||||
|
|
||||||
|
chart.add_data(data_ref, titles_from_data=True)
|
||||||
|
chart.set_categories(categories)
|
||||||
|
|
||||||
|
# Style the series
|
||||||
|
if len(chart.series) >= 3:
|
||||||
|
chart.series[0].graphicalProperties.line.solidFill = "FF0000" # LAmax - Red
|
||||||
|
chart.series[1].graphicalProperties.line.solidFill = "00B050" # LA01 - Green
|
||||||
|
chart.series[2].graphicalProperties.line.solidFill = "0070C0" # LA10 - Blue
|
||||||
|
|
||||||
|
# Position chart to the right of data
|
||||||
|
ws.add_chart(chart, "I3")
|
||||||
|
|
||||||
|
# Add summary statistics section below the data
|
||||||
|
summary_row = data_end_row + 3
|
||||||
|
ws.cell(row=summary_row, column=1, value="Summary Statistics").font = Font(bold=True, size=12)
|
||||||
|
|
||||||
|
# Calculate time-period statistics
|
||||||
|
time_periods = {
|
||||||
|
'Evening (7PM-10PM)': [],
|
||||||
|
'Nighttime (10PM-7AM)': [],
|
||||||
|
'Morning (7AM-12PM)': [],
|
||||||
|
'Daytime (12PM-7PM)': []
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rnd_rows:
|
||||||
|
start_time_str = row.get('Start Time', '')
|
||||||
|
if start_time_str:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||||
|
hour = dt.hour
|
||||||
|
|
||||||
|
lmax = row.get('Lmax(Main)')
|
||||||
|
ln1 = row.get('LN1(Main)')
|
||||||
|
ln2 = row.get('LN2(Main)')
|
||||||
|
|
||||||
|
if isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
|
||||||
|
data_point = {'lmax': lmax, 'ln1': ln1, 'ln2': ln2}
|
||||||
|
|
||||||
|
if 19 <= hour < 22:
|
||||||
|
time_periods['Evening (7PM-10PM)'].append(data_point)
|
||||||
|
elif hour >= 22 or hour < 7:
|
||||||
|
time_periods['Nighttime (10PM-7AM)'].append(data_point)
|
||||||
|
elif 7 <= hour < 12:
|
||||||
|
time_periods['Morning (7AM-12PM)'].append(data_point)
|
||||||
|
else: # 12-19
|
||||||
|
time_periods['Daytime (12PM-7PM)'].append(data_point)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Summary table headers
|
||||||
|
summary_row += 2
|
||||||
|
summary_headers = ['Time Period', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
|
||||||
|
for col, header in enumerate(summary_headers, 1):
|
||||||
|
cell = ws.cell(row=summary_row, column=col, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.border = thin_border
|
||||||
|
|
||||||
|
# Summary data
|
||||||
|
summary_row += 1
|
||||||
|
for period_name, samples in time_periods.items():
|
||||||
|
ws.cell(row=summary_row, column=1, value=period_name).border = thin_border
|
||||||
|
ws.cell(row=summary_row, column=2, value=len(samples)).border = thin_border
|
||||||
|
|
||||||
|
if samples:
|
||||||
|
avg_lmax = sum(s['lmax'] for s in samples) / len(samples)
|
||||||
|
avg_ln1 = sum(s['ln1'] for s in samples) / len(samples)
|
||||||
|
avg_ln2 = sum(s['ln2'] for s in samples) / len(samples)
|
||||||
|
ws.cell(row=summary_row, column=3, value=round(avg_lmax, 1)).border = thin_border
|
||||||
|
ws.cell(row=summary_row, column=4, value=round(avg_ln1, 1)).border = thin_border
|
||||||
|
ws.cell(row=summary_row, column=5, value=round(avg_ln2, 1)).border = thin_border
|
||||||
|
else:
|
||||||
|
ws.cell(row=summary_row, column=3, value='-').border = thin_border
|
||||||
|
ws.cell(row=summary_row, column=4, value='-').border = thin_border
|
||||||
|
ws.cell(row=summary_row, column=5, value='-').border = thin_border
|
||||||
|
|
||||||
|
summary_row += 1
|
||||||
|
|
||||||
|
# Overall summary
|
||||||
|
summary_row += 1
|
||||||
|
ws.cell(row=summary_row, column=1, value='Overall').font = Font(bold=True)
|
||||||
|
ws.cell(row=summary_row, column=1).border = thin_border
|
||||||
|
ws.cell(row=summary_row, column=2, value=len(rnd_rows)).border = thin_border
|
||||||
|
|
||||||
|
all_lmax = [r.get('Lmax(Main)') for r in rnd_rows if isinstance(r.get('Lmax(Main)'), (int, float))]
|
||||||
|
all_ln1 = [r.get('LN1(Main)') for r in rnd_rows if isinstance(r.get('LN1(Main)'), (int, float))]
|
||||||
|
all_ln2 = [r.get('LN2(Main)') for r in rnd_rows if isinstance(r.get('LN2(Main)'), (int, float))]
|
||||||
|
|
||||||
|
if all_lmax:
|
||||||
|
ws.cell(row=summary_row, column=3, value=round(sum(all_lmax) / len(all_lmax), 1)).border = thin_border
|
||||||
|
if all_ln1:
|
||||||
|
ws.cell(row=summary_row, column=4, value=round(sum(all_ln1) / len(all_ln1), 1)).border = thin_border
|
||||||
|
if all_ln2:
|
||||||
|
ws.cell(row=summary_row, column=5, value=round(sum(all_ln2) / len(all_ln2), 1)).border = thin_border
|
||||||
|
|
||||||
|
# Save to buffer
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
# Generate filename
|
||||||
|
filename = file_record.file_path.split('/')[-1].replace('.rnd', '')
|
||||||
|
if location:
|
||||||
|
filename = f"{location.name}_{filename}"
|
||||||
|
filename = f"{filename}_report.xlsx"
|
||||||
|
# Clean filename
|
||||||
|
filename = "".join(c for c in filename if c.isalnum() or c in ('_', '-', '.')).rstrip()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
output,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/generate-combined-report")
|
||||||
|
async def generate_combined_excel_report(
|
||||||
|
project_id: str,
|
||||||
|
report_title: str = Query("Background Noise Study", description="Title for the report"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate a combined Excel report from all RND files in a project.
|
||||||
|
|
||||||
|
Creates a multi-sheet Excel workbook with:
|
||||||
|
- One sheet per location/RND file
|
||||||
|
- Data tables with LAmax, LA01, LA10
|
||||||
|
- Line charts for each location
|
||||||
|
- Summary sheet combining all locations
|
||||||
|
|
||||||
|
Column mapping from RND to Report:
|
||||||
|
- Lmax(Main) -> LAmax (dBA)
|
||||||
|
- LN1(Main) -> LA01 (dBA) [L1 percentile]
|
||||||
|
- LN2(Main) -> LA10 (dBA) [L10 percentile]
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
from pathlib import Path
|
||||||
|
import csv
|
||||||
|
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
from openpyxl.chart import LineChart, Reference
|
||||||
|
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="openpyxl is not installed. Run: pip install openpyxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get project
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
# Get all sessions with measurement files
|
||||||
|
sessions = db.query(RecordingSession).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
|
# Collect all Leq RND files grouped by location
|
||||||
|
# Only include files with '_Leq_' in the path (15-minute averaged data)
|
||||||
|
# Exclude Lp files (instantaneous 100ms readings)
|
||||||
|
location_files = {}
|
||||||
|
for session in sessions:
|
||||||
|
files = db.query(DataFile).filter_by(session_id=session.id).all()
|
||||||
|
for file in files:
|
||||||
|
# Only include Leq files for reports (contain '_Leq_' in path)
|
||||||
|
is_leq_file = file.file_path and '_Leq_' in file.file_path and file.file_path.endswith('.rnd')
|
||||||
|
if is_leq_file:
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||||
|
location_name = location.name if location else f"Session {session.id[:8]}"
|
||||||
|
|
||||||
|
if location_name not in location_files:
|
||||||
|
location_files[location_name] = []
|
||||||
|
location_files[location_name].append({
|
||||||
|
'file': file,
|
||||||
|
'session': session,
|
||||||
|
'location': location
|
||||||
|
})
|
||||||
|
|
||||||
|
if not location_files:
|
||||||
|
raise HTTPException(status_code=404, detail="No Leq measurement files found in project. Reports require Leq data (files with '_Leq_' in the name).")
|
||||||
|
|
||||||
|
# Define styles
|
||||||
|
title_font = Font(bold=True, size=14)
|
||||||
|
header_font = Font(bold=True, size=10)
|
||||||
|
thin_border = Border(
|
||||||
|
left=Side(style='thin'),
|
||||||
|
right=Side(style='thin'),
|
||||||
|
top=Side(style='thin'),
|
||||||
|
bottom=Side(style='thin')
|
||||||
|
)
|
||||||
|
header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid")
|
||||||
|
|
||||||
|
# Create Excel workbook
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
|
||||||
|
# Remove default sheet
|
||||||
|
wb.remove(wb.active)
|
||||||
|
|
||||||
|
# Track all data for summary
|
||||||
|
all_location_summaries = []
|
||||||
|
|
||||||
|
# Create a sheet for each location
|
||||||
|
for location_name, file_list in location_files.items():
|
||||||
|
# Sanitize sheet name (max 31 chars, no special chars)
|
||||||
|
safe_sheet_name = "".join(c for c in location_name if c.isalnum() or c in (' ', '-', '_'))[:31]
|
||||||
|
ws = wb.create_sheet(title=safe_sheet_name)
|
||||||
|
|
||||||
|
# Row 1: Report title
|
||||||
|
final_title = f"{report_title} - {project.name}"
|
||||||
|
ws['A1'] = final_title
|
||||||
|
ws['A1'].font = title_font
|
||||||
|
ws.merge_cells('A1:G1')
|
||||||
|
|
||||||
|
# Row 3: Location name
|
||||||
|
ws['A3'] = location_name
|
||||||
|
ws['A3'].font = Font(bold=True, size=11)
|
||||||
|
|
||||||
|
# Row 7: Headers
|
||||||
|
headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
|
||||||
|
for col, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=7, column=col, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = Alignment(horizontal='center')
|
||||||
|
|
||||||
|
# Set column widths
|
||||||
|
column_widths = [16, 12, 10, 12, 12, 12, 40]
|
||||||
|
for i, width in enumerate(column_widths, 1):
|
||||||
|
ws.column_dimensions[get_column_letter(i)].width = width
|
||||||
|
|
||||||
|
# Combine data from all files for this location
|
||||||
|
all_rnd_rows = []
|
||||||
|
for file_info in file_list:
|
||||||
|
file = file_info['file']
|
||||||
|
file_path = Path("data") / file.file_path
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(content))
|
||||||
|
for row in reader:
|
||||||
|
cleaned_row = {}
|
||||||
|
for key, value in row.items():
|
||||||
|
if key:
|
||||||
|
cleaned_key = key.strip()
|
||||||
|
cleaned_value = value.strip() if value else ''
|
||||||
|
if cleaned_value and cleaned_value not in ['-.-', '-', '']:
|
||||||
|
try:
|
||||||
|
cleaned_value = float(cleaned_value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif cleaned_value in ['-.-', '-']:
|
||||||
|
cleaned_value = None
|
||||||
|
cleaned_row[cleaned_key] = cleaned_value
|
||||||
|
all_rnd_rows.append(cleaned_row)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error reading file {file.file_path}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not all_rnd_rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by start time
|
||||||
|
all_rnd_rows.sort(key=lambda r: r.get('Start Time', ''))
|
||||||
|
|
||||||
|
# Data rows starting at row 8
|
||||||
|
data_start_row = 8
|
||||||
|
for idx, row in enumerate(all_rnd_rows, 1):
|
||||||
|
data_row = data_start_row + idx - 1
|
||||||
|
|
||||||
|
ws.cell(row=data_row, column=1, value=idx).border = thin_border
|
||||||
|
|
||||||
|
start_time_str = row.get('Start Time', '')
|
||||||
|
if start_time_str:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||||
|
ws.cell(row=data_row, column=2, value=dt.date())
|
||||||
|
ws.cell(row=data_row, column=3, value=dt.time())
|
||||||
|
except ValueError:
|
||||||
|
ws.cell(row=data_row, column=2, value=start_time_str)
|
||||||
|
ws.cell(row=data_row, column=3, value='')
|
||||||
|
else:
|
||||||
|
ws.cell(row=data_row, column=2, value='')
|
||||||
|
ws.cell(row=data_row, column=3, value='')
|
||||||
|
|
||||||
|
lmax = row.get('Lmax(Main)')
|
||||||
|
ws.cell(row=data_row, column=4, value=lmax if lmax else '').border = thin_border
|
||||||
|
|
||||||
|
ln1 = row.get('LN1(Main)')
|
||||||
|
ws.cell(row=data_row, column=5, value=ln1 if ln1 else '').border = thin_border
|
||||||
|
|
||||||
|
ln2 = row.get('LN2(Main)')
|
||||||
|
ws.cell(row=data_row, column=6, value=ln2 if ln2 else '').border = thin_border
|
||||||
|
|
||||||
|
ws.cell(row=data_row, column=7, value='').border = thin_border
|
||||||
|
ws.cell(row=data_row, column=2).border = thin_border
|
||||||
|
ws.cell(row=data_row, column=3).border = thin_border
|
||||||
|
|
||||||
|
data_end_row = data_start_row + len(all_rnd_rows) - 1
|
||||||
|
|
||||||
|
# Add Line Chart
|
||||||
|
chart = LineChart()
|
||||||
|
chart.title = f"{location_name}"
|
||||||
|
chart.style = 10
|
||||||
|
chart.y_axis.title = "Sound Level (dBA)"
|
||||||
|
chart.x_axis.title = "Test Increment"
|
||||||
|
chart.height = 12
|
||||||
|
chart.width = 20
|
||||||
|
|
||||||
|
data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row)
|
||||||
|
categories = Reference(ws, min_col=1, min_row=data_start_row, max_row=data_end_row)
|
||||||
|
|
||||||
|
chart.add_data(data_ref, titles_from_data=True)
|
||||||
|
chart.set_categories(categories)
|
||||||
|
|
||||||
|
if len(chart.series) >= 3:
|
||||||
|
chart.series[0].graphicalProperties.line.solidFill = "FF0000"
|
||||||
|
chart.series[1].graphicalProperties.line.solidFill = "00B050"
|
||||||
|
chart.series[2].graphicalProperties.line.solidFill = "0070C0"
|
||||||
|
|
||||||
|
ws.add_chart(chart, "I3")
|
||||||
|
|
||||||
|
# Calculate summary for this location
|
||||||
|
all_lmax = [r.get('Lmax(Main)') for r in all_rnd_rows if isinstance(r.get('Lmax(Main)'), (int, float))]
|
||||||
|
all_ln1 = [r.get('LN1(Main)') for r in all_rnd_rows if isinstance(r.get('LN1(Main)'), (int, float))]
|
||||||
|
all_ln2 = [r.get('LN2(Main)') for r in all_rnd_rows if isinstance(r.get('LN2(Main)'), (int, float))]
|
||||||
|
|
||||||
|
all_location_summaries.append({
|
||||||
|
'location': location_name,
|
||||||
|
'samples': len(all_rnd_rows),
|
||||||
|
'lmax_avg': round(sum(all_lmax) / len(all_lmax), 1) if all_lmax else None,
|
||||||
|
'ln1_avg': round(sum(all_ln1) / len(all_ln1), 1) if all_ln1 else None,
|
||||||
|
'ln2_avg': round(sum(all_ln2) / len(all_ln2), 1) if all_ln2 else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create Summary sheet at the beginning
|
||||||
|
summary_ws = wb.create_sheet(title="Summary", index=0)
|
||||||
|
|
||||||
|
summary_ws['A1'] = f"{report_title} - {project.name} - Summary"
|
||||||
|
summary_ws['A1'].font = title_font
|
||||||
|
summary_ws.merge_cells('A1:E1')
|
||||||
|
|
||||||
|
summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
|
||||||
|
for col, header in enumerate(summary_headers, 1):
|
||||||
|
cell = summary_ws.cell(row=3, column=col, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.border = thin_border
|
||||||
|
|
||||||
|
for i, width in enumerate([30, 10, 12, 12, 12], 1):
|
||||||
|
summary_ws.column_dimensions[get_column_letter(i)].width = width
|
||||||
|
|
||||||
|
for idx, loc_summary in enumerate(all_location_summaries, 4):
|
||||||
|
summary_ws.cell(row=idx, column=1, value=loc_summary['location']).border = thin_border
|
||||||
|
summary_ws.cell(row=idx, column=2, value=loc_summary['samples']).border = thin_border
|
||||||
|
summary_ws.cell(row=idx, column=3, value=loc_summary['lmax_avg'] or '-').border = thin_border
|
||||||
|
summary_ws.cell(row=idx, column=4, value=loc_summary['ln1_avg'] or '-').border = thin_border
|
||||||
|
summary_ws.cell(row=idx, column=5, value=loc_summary['ln2_avg'] or '-').border = thin_border
|
||||||
|
|
||||||
|
# Save to buffer
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
# Generate filename
|
||||||
|
project_name_clean = "".join(c for c in project.name if c.isalnum() or c in ('_', '-', ' ')).strip()
|
||||||
|
filename = f"{project_name_clean}_combined_report.xlsx"
|
||||||
|
filename = filename.replace(' ', '_')
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
output,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/types/list", response_class=HTMLResponse)
|
@router.get("/types/list", response_class=HTMLResponse)
|
||||||
async def get_project_types(request: Request, db: Session = Depends(get_db)):
|
async def get_project_types(request: Request, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ jinja2==3.1.2
|
|||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
Pillow==10.1.0
|
Pillow==10.1.0
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
|
openpyxl==3.1.2
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||||
@@ -12,3 +14,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 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"
|
||||||
|
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>
|
||||||
|
</svg>
|
||||||
|
Generate Combined Report
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,24 @@
|
|||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||||
{{ session.status or 'unknown' }}
|
{{ session.status or 'unknown' }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Download All Files in Session -->
|
||||||
|
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
|
||||||
|
title="Download all files in this session">
|
||||||
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Download All
|
||||||
|
</button>
|
||||||
|
<!-- Delete Session -->
|
||||||
|
<button onclick="event.stopPropagation(); confirmDeleteSession('{{ session.id }}', '{{ files|length }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center gap-1"
|
||||||
|
title="Delete session and all files">
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +90,10 @@
|
|||||||
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
{% elif file.file_type == 'measurement' %}
|
||||||
|
<svg class="w-6 h-6 text-emerald-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>
|
||||||
{% else %}
|
{% else %}
|
||||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||||
@@ -95,6 +117,7 @@
|
|||||||
<span class="px-1.5 py-0.5 rounded font-medium
|
<span class="px-1.5 py-0.5 rounded font-medium
|
||||||
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
|
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
|
||||||
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
|
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
|
||||||
|
{% elif file.file_type == 'measurement' %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300
|
||||||
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
|
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
|
||||||
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
|
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
|
||||||
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
|
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
|
||||||
@@ -102,6 +125,17 @@
|
|||||||
{{ file.file_type or 'unknown' }}
|
{{ file.file_type or 'unknown' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{# Leq vs Lp badge for RND files #}
|
||||||
|
{% if file.file_path and '_Leq_' in file.file_path %}
|
||||||
|
<span class="px-1.5 py-0.5 rounded font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
|
Leq (15-min avg)
|
||||||
|
</span>
|
||||||
|
{% elif file.file_path and '_Lp' in file.file_path and file.file_path.endswith('.rnd') %}
|
||||||
|
<span class="px-1.5 py-0.5 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300">
|
||||||
|
Lp (instant)
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- File Size -->
|
<!-- File Size -->
|
||||||
<span class="mx-1">•</span>
|
<span class="mx-1">•</span>
|
||||||
{% if file.file_size_bytes %}
|
{% if file.file_size_bytes %}
|
||||||
@@ -141,9 +175,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download Button -->
|
<!-- Action Buttons -->
|
||||||
{% if exists %}
|
{% if exists %}
|
||||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-2">
|
||||||
|
{% if file.file_type == 'measurement' or file.file_path.endswith('.rnd') %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/view-rnd"
|
||||||
|
onclick="event.stopPropagation();"
|
||||||
|
class="px-3 py-1 text-xs bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" 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>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
|
||||||
|
{% if '_Leq_' in file.file_path %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/generate-report"
|
||||||
|
onclick="event.stopPropagation();"
|
||||||
|
class="px-3 py-1 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center"
|
||||||
|
title="Generate Excel Report">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" 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>
|
||||||
|
Report
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<button onclick="event.stopPropagation(); downloadFile('{{ file.id }}')"
|
<button onclick="event.stopPropagation(); downloadFile('{{ file.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -151,6 +207,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="event.stopPropagation(); confirmDeleteFile('{{ file.id }}', '{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
title="Delete this file">
|
||||||
|
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -197,4 +260,56 @@ function toggleSession(sessionId, headerElement) {
|
|||||||
function downloadFile(fileId) {
|
function downloadFile(fileId) {
|
||||||
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
|
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadSessionFiles(sessionId) {
|
||||||
|
window.location.href = `/api/projects/{{ project_id }}/sessions/${sessionId}/download-all`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteFile(fileId, fileName) {
|
||||||
|
if (confirm(`Are you sure you want to delete "${fileName}"?\n\nThis action cannot be undone.`)) {
|
||||||
|
deleteFile(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(fileId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/{{ project_id }}/files/${fileId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload the files list
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Failed to delete file: ${data.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error deleting file: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteSession(sessionId, fileCount) {
|
||||||
|
if (confirm(`Are you sure you want to delete this session and all ${fileCount} file(s)?\n\nThis action cannot be undone.`)) {
|
||||||
|
deleteSession(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSession(sessionId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload the files list
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Failed to delete session: ${data.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error deleting session: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
566
templates/rnd_viewer.html
Normal file
566
templates/rnd_viewer.html
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
{% 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
|
||||||
|
function openReportModal() {
|
||||||
|
document.getElementById('report-modal').classList.remove('hidden');
|
||||||
|
// Pre-fill location name if available
|
||||||
|
const locationInput = document.getElementById('report-location');
|
||||||
|
if (locationInput && !locationInput.value) {
|
||||||
|
locationInput.value = '{{ location.name if location else "" }}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReportModal() {
|
||||||
|
document.getElementById('report-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateReport() {
|
||||||
|
const reportTitle = document.getElementById('report-title').value || 'Background Noise Study';
|
||||||
|
const locationName = document.getElementById('report-location').value || '';
|
||||||
|
|
||||||
|
// Build the URL with query parameters
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
report_title: reportTitle,
|
||||||
|
location_name: locationName
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger download
|
||||||
|
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/generate-report?${params.toString()}`;
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
closeReportModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeReportModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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-lg 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">
|
||||||
|
<!-- Report Title -->
|
||||||
|
<div>
|
||||||
|
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Report Title
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-title" value="Background Noise Study"
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
|
||||||
|
placeholder="e.g., Background Noise Study - Commercial Street Bridge">
|
||||||
|
</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"
|
||||||
|
placeholder="e.g., NRL 1 - West Side">
|
||||||
|
</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 sm:flex sm:flex-row-reverse gap-2">
|
||||||
|
<button type="button" onclick="generateReport()"
|
||||||
|
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-emerald-600 text-base font-medium text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Generate & Download
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeReportModal()"
|
||||||
|
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:mt-0 sm:w-auto sm:text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user