feat: add slm model schemas, please run migration on prod db
Feat: add complete combined sound report creation tool (wizard), add new slm schema for each model feat: update project header link for combined report wizard feat: add migration script to backfill device_model in monitoring_sessions feat: implement combined report preview template with spreadsheet functionality feat: create combined report wizard template for report generation.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
|||||||
# Terra-View Specifics
|
# Terra-View Specifics
|
||||||
|
# Dev build counter (local only, never commit)
|
||||||
|
build_number.txt
|
||||||
|
|
||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Build number for dev builds (injected via --build-arg)
|
||||||
|
ARG BUILD_NUMBER=0
|
||||||
|
ENV BUILD_NUMBER=${BUILD_NUMBER}
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
|||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.6.1"
|
VERSION = "0.6.1"
|
||||||
|
if ENVIRONMENT == "development":
|
||||||
|
_build = os.getenv("BUILD_NUMBER", "0")
|
||||||
|
if _build and _build != "0":
|
||||||
|
VERSION = f"{VERSION}-{_build}"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
|
|||||||
127
backend/migrate_add_session_device_model.py
Normal file
127
backend/migrate_add_session_device_model.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: Add device_model column to monitoring_sessions table.
|
||||||
|
|
||||||
|
Records which physical SLM model produced each session's data (e.g. "NL-43",
|
||||||
|
"NL-53", "NL-32"). Used by report generation to apply the correct parsing
|
||||||
|
logic without re-opening files to detect format.
|
||||||
|
|
||||||
|
Run once inside the Docker container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_session_device_model.py
|
||||||
|
|
||||||
|
Backfill strategy for existing rows:
|
||||||
|
1. If session.unit_id is set, use roster.slm_model for that unit.
|
||||||
|
2. Else, peek at the first .rnd file in the session: presence of the 'LAeq'
|
||||||
|
column header identifies AU2 / NL-32 format.
|
||||||
|
Sessions where neither hint is available remain NULL — the file-content
|
||||||
|
fallback in report code handles them transparently.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path("data/seismo_fleet.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _peek_first_row(abs_path: Path) -> dict:
|
||||||
|
"""Read only the header + first data row of an RND file. Very cheap."""
|
||||||
|
try:
|
||||||
|
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
return next(reader, None) or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_model_from_rnd(abs_path: Path) -> str | None:
|
||||||
|
"""Return 'NL-32' if file uses AU2 column format, else None."""
|
||||||
|
row = _peek_first_row(abs_path)
|
||||||
|
if "LAeq" in row:
|
||||||
|
return "NL-32"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# ── 1. Add column (idempotent) ───────────────────────────────────────────
|
||||||
|
cur.execute("PRAGMA table_info(monitoring_sessions)")
|
||||||
|
existing_cols = {row["name"] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
if "device_model" not in existing_cols:
|
||||||
|
cur.execute("ALTER TABLE monitoring_sessions ADD COLUMN device_model TEXT")
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Added column device_model to monitoring_sessions")
|
||||||
|
else:
|
||||||
|
print("○ Column device_model already exists — skipping ALTER TABLE")
|
||||||
|
|
||||||
|
# ── 2. Backfill existing NULL rows ───────────────────────────────────────
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, unit_id FROM monitoring_sessions WHERE device_model IS NULL"
|
||||||
|
)
|
||||||
|
sessions = cur.fetchall()
|
||||||
|
print(f"Backfilling {len(sessions)} session(s) with device_model=NULL...")
|
||||||
|
|
||||||
|
updated = skipped = 0
|
||||||
|
for row in sessions:
|
||||||
|
session_id = row["id"]
|
||||||
|
unit_id = row["unit_id"]
|
||||||
|
device_model = None
|
||||||
|
|
||||||
|
# Strategy A: look up unit's slm_model from the roster
|
||||||
|
if unit_id:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT slm_model FROM roster WHERE id = ?", (unit_id,)
|
||||||
|
)
|
||||||
|
unit_row = cur.fetchone()
|
||||||
|
if unit_row and unit_row["slm_model"]:
|
||||||
|
device_model = unit_row["slm_model"]
|
||||||
|
|
||||||
|
# Strategy B: detect from first .rnd file in the session
|
||||||
|
if device_model is None:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT file_path FROM data_files
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND lower(file_path) LIKE '%.rnd'
|
||||||
|
LIMIT 1""",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
file_row = cur.fetchone()
|
||||||
|
if file_row:
|
||||||
|
abs_path = Path("data") / file_row["file_path"]
|
||||||
|
device_model = _detect_model_from_rnd(abs_path)
|
||||||
|
# None here means NL-43/NL-53 format (or unreadable file) —
|
||||||
|
# leave as NULL so the existing fallback applies.
|
||||||
|
|
||||||
|
if device_model:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE monitoring_sessions SET device_model = ? WHERE id = ?",
|
||||||
|
(device_model, session_id),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"✓ Backfilled {updated} session(s) with a device_model.")
|
||||||
|
if skipped:
|
||||||
|
print(
|
||||||
|
f" {skipped} session(s) left as NULL "
|
||||||
|
"(no unit link and no AU2 file hint — NL-43/NL-53 or unknown; "
|
||||||
|
"file-content detection applies at report time)."
|
||||||
|
)
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -257,6 +257,10 @@ class MonitoringSession(Base):
|
|||||||
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
||||||
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
|
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
|
||||||
|
|
||||||
|
# Physical device model that produced this session's data (e.g. "NL-43", "NL-53", "NL-32").
|
||||||
|
# Null for older records; report code falls back to file-content detection when null.
|
||||||
|
device_model = Column(String, nullable=True)
|
||||||
|
|
||||||
session_type = Column(String, nullable=False) # sound | vibration
|
session_type = Column(String, nullable=False) # sound | vibration
|
||||||
started_at = Column(DateTime, nullable=False)
|
started_at = Column(DateTime, nullable=False)
|
||||||
stopped_at = Column(DateTime, nullable=True)
|
stopped_at = Column(DateTime, nullable=True)
|
||||||
|
|||||||
@@ -112,6 +112,232 @@ def _is_leq_file(file_path: str, rows: list[dict]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_rnd_rows(
|
||||||
|
rows: list[dict],
|
||||||
|
filter_start_time: str,
|
||||||
|
filter_end_time: str,
|
||||||
|
filter_start_date: str,
|
||||||
|
filter_end_date: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Filter RND data rows by time window and/or date range. Handles overnight ranges."""
|
||||||
|
if not filter_start_time and not filter_end_time and not filter_start_date and not filter_end_date:
|
||||||
|
return rows
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
|
||||||
|
start_hour = start_minute = end_hour = end_minute = None
|
||||||
|
if filter_start_time:
|
||||||
|
try:
|
||||||
|
parts = filter_start_time.split(':')
|
||||||
|
start_hour = int(parts[0])
|
||||||
|
start_minute = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if filter_end_time:
|
||||||
|
try:
|
||||||
|
parts = filter_end_time.split(':')
|
||||||
|
end_hour = int(parts[0])
|
||||||
|
end_minute = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
start_dt = end_dt = None
|
||||||
|
if filter_start_date:
|
||||||
|
try:
|
||||||
|
start_dt = datetime.strptime(filter_start_date, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if filter_end_date:
|
||||||
|
try:
|
||||||
|
end_dt = datetime.strptime(filter_end_date, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
start_time_str = row.get('Start Time', '')
|
||||||
|
if not start_time_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||||
|
row_date = dt.date()
|
||||||
|
row_hour = dt.hour
|
||||||
|
row_minute = dt.minute
|
||||||
|
|
||||||
|
if start_dt and row_date < start_dt:
|
||||||
|
continue
|
||||||
|
if end_dt and row_date > end_dt:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if start_hour is not None and end_hour is not None:
|
||||||
|
row_time_minutes = row_hour * 60 + row_minute
|
||||||
|
start_time_minutes = start_hour * 60 + start_minute
|
||||||
|
end_time_minutes = end_hour * 60 + end_minute
|
||||||
|
|
||||||
|
if start_time_minutes > end_time_minutes:
|
||||||
|
# Overnight range (e.g., 19:00-07:00)
|
||||||
|
if not (row_time_minutes >= start_time_minutes or row_time_minutes < end_time_minutes):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Same-day range (e.g., 07:00-19:00)
|
||||||
|
if not (start_time_minutes <= row_time_minutes < end_time_minutes):
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered.append(row)
|
||||||
|
except ValueError:
|
||||||
|
filtered.append(row)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _read_rnd_file_rows(file_path_str: str) -> list[dict]:
|
||||||
|
"""Read and parse a single RND CSV file into a list of cleaned row dicts."""
|
||||||
|
import csv as _csv
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
file_path = _Path("data") / file_path_str
|
||||||
|
if not file_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
content = f.read()
|
||||||
|
rows = []
|
||||||
|
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
|
||||||
|
rows.append(cleaned_row)
|
||||||
|
return rows
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _build_combined_location_data(
|
||||||
|
project_id: str,
|
||||||
|
db,
|
||||||
|
start_time: str = "",
|
||||||
|
end_time: str = "",
|
||||||
|
start_date: str = "",
|
||||||
|
end_date: str = "",
|
||||||
|
enabled_locations: list = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Read all Leq RND files for a project, apply time/date filters, and return
|
||||||
|
per-location spreadsheet data ready for the wizard preview.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"project": Project,
|
||||||
|
"location_data": [
|
||||||
|
{
|
||||||
|
"location_name": str,
|
||||||
|
"raw_count": int,
|
||||||
|
"filtered_count": int,
|
||||||
|
"spreadsheet_data": [[idx, date, time, lmax, ln1, ln2, ""], ...]
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Raises HTTPException 404 if project not found or no Leq files exist.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
|
# Group Leq files by location
|
||||||
|
location_files: dict = {}
|
||||||
|
for session in sessions:
|
||||||
|
files = db.query(DataFile).filter_by(session_id=session.id).all()
|
||||||
|
for file in files:
|
||||||
|
if not file.file_path or not file.file_path.lower().endswith('.rnd'):
|
||||||
|
continue
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
abs_path = _Path("data") / file.file_path
|
||||||
|
peek = _peek_rnd_headers(abs_path)
|
||||||
|
if not _is_leq_file(file.file_path, peek):
|
||||||
|
continue
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||||
|
loc_name = location.name if location else f"Session {session.id[:8]}"
|
||||||
|
if loc_name not in location_files:
|
||||||
|
location_files[loc_name] = []
|
||||||
|
location_files[loc_name].append(file)
|
||||||
|
|
||||||
|
if not location_files:
|
||||||
|
raise HTTPException(status_code=404, detail="No Leq measurement files found in project.")
|
||||||
|
|
||||||
|
# Filter by enabled_locations if specified
|
||||||
|
if enabled_locations:
|
||||||
|
location_files = {k: v for k, v in location_files.items() if k in enabled_locations}
|
||||||
|
if not location_files:
|
||||||
|
raise HTTPException(status_code=404, detail="None of the selected locations have Leq files.")
|
||||||
|
|
||||||
|
location_data = []
|
||||||
|
for loc_name, files in sorted(location_files.items()):
|
||||||
|
all_rows = []
|
||||||
|
for file in files:
|
||||||
|
rows = _read_rnd_file_rows(file.file_path)
|
||||||
|
rows, _ = _normalize_rnd_rows(rows)
|
||||||
|
all_rows.extend(rows)
|
||||||
|
|
||||||
|
if not all_rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_rows.sort(key=lambda r: r.get('Start Time', ''))
|
||||||
|
raw_count = len(all_rows)
|
||||||
|
|
||||||
|
filtered_rows = _filter_rnd_rows(all_rows, start_time, end_time, start_date, end_date)
|
||||||
|
|
||||||
|
spreadsheet_data = []
|
||||||
|
for idx, row in enumerate(filtered_rows, 1):
|
||||||
|
start_time_str = row.get('Start Time', '')
|
||||||
|
date_str = time_str = ''
|
||||||
|
if start_time_str:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||||
|
date_str = dt.strftime('%Y-%m-%d')
|
||||||
|
time_str = dt.strftime('%H:%M:%S')
|
||||||
|
except ValueError:
|
||||||
|
date_str = start_time_str
|
||||||
|
|
||||||
|
lmax = row.get('Lmax(Main)', '')
|
||||||
|
ln1 = row.get('LN1(Main)', '')
|
||||||
|
ln2 = row.get('LN2(Main)', '')
|
||||||
|
|
||||||
|
spreadsheet_data.append([
|
||||||
|
idx,
|
||||||
|
date_str,
|
||||||
|
time_str,
|
||||||
|
lmax if lmax else '',
|
||||||
|
ln1 if ln1 else '',
|
||||||
|
ln2 if ln2 else '',
|
||||||
|
'',
|
||||||
|
])
|
||||||
|
|
||||||
|
location_data.append({
|
||||||
|
"location_name": loc_name,
|
||||||
|
"raw_count": raw_count,
|
||||||
|
"filtered_count": len(filtered_rows),
|
||||||
|
"spreadsheet_data": spreadsheet_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"project": project, "location_data": location_data}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Project List & Overview
|
# Project List & Overview
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -979,6 +1205,7 @@ async def ftp_download_to_server(
|
|||||||
|
|
||||||
# If no active session, create one
|
# If no active session, create one
|
||||||
if not session:
|
if not session:
|
||||||
|
_ftp_unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
session = MonitoringSession(
|
session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -988,6 +1215,7 @@ async def ftp_download_to_server(
|
|||||||
status="completed",
|
status="completed",
|
||||||
started_at=datetime.utcnow(),
|
started_at=datetime.utcnow(),
|
||||||
stopped_at=datetime.utcnow(),
|
stopped_at=datetime.utcnow(),
|
||||||
|
device_model=_ftp_unit.slm_model if _ftp_unit else None,
|
||||||
session_metadata='{"source": "ftp_download", "note": "Auto-created for FTP download"}'
|
session_metadata='{"source": "ftp_download", "note": "Auto-created for FTP download"}'
|
||||||
)
|
)
|
||||||
db.add(session)
|
db.add(session)
|
||||||
@@ -1144,6 +1372,7 @@ async def ftp_download_folder_to_server(
|
|||||||
|
|
||||||
# If no active session, create one
|
# If no active session, create one
|
||||||
if not session:
|
if not session:
|
||||||
|
_ftp_unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
session = MonitoringSession(
|
session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -1153,6 +1382,7 @@ async def ftp_download_folder_to_server(
|
|||||||
status="completed",
|
status="completed",
|
||||||
started_at=datetime.utcnow(),
|
started_at=datetime.utcnow(),
|
||||||
stopped_at=datetime.utcnow(),
|
stopped_at=datetime.utcnow(),
|
||||||
|
device_model=_ftp_unit.slm_model if _ftp_unit else None,
|
||||||
session_metadata='{"source": "ftp_folder_download", "note": "Auto-created for FTP folder download"}'
|
session_metadata='{"source": "ftp_folder_download", "note": "Auto-created for FTP folder download"}'
|
||||||
)
|
)
|
||||||
db.add(session)
|
db.add(session)
|
||||||
@@ -2618,9 +2848,12 @@ async def generate_combined_excel_report(
|
|||||||
for session in sessions:
|
for session in sessions:
|
||||||
files = db.query(DataFile).filter_by(session_id=session.id).all()
|
files = db.query(DataFile).filter_by(session_id=session.id).all()
|
||||||
for file in files:
|
for file in files:
|
||||||
# Only include Leq files for reports (contain '_Leq_' in path)
|
if not file.file_path or not file.file_path.lower().endswith('.rnd'):
|
||||||
is_leq_file = file.file_path and '_Leq_' in file.file_path and file.file_path.endswith('.rnd')
|
continue
|
||||||
if is_leq_file:
|
from pathlib import Path as _Path
|
||||||
|
abs_path = _Path("data") / file.file_path
|
||||||
|
peek = _peek_rnd_headers(abs_path)
|
||||||
|
if _is_leq_file(file.file_path, peek):
|
||||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
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]}"
|
location_name = location.name if location else f"Session {session.id[:8]}"
|
||||||
|
|
||||||
@@ -2852,6 +3085,309 @@ async def generate_combined_excel_report(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Combined Report Wizard — config page, preview page, and generate endpoint
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/{project_id}/combined-report-wizard", response_class=HTMLResponse)
|
||||||
|
async def combined_report_wizard(
|
||||||
|
request: Request,
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Configuration page for the combined multi-location report wizard."""
|
||||||
|
from backend.models import ReportTemplate
|
||||||
|
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
|
# Build location list with Leq file counts (no filtering)
|
||||||
|
location_file_counts: dict = {}
|
||||||
|
for session in sessions:
|
||||||
|
files = db.query(DataFile).filter_by(session_id=session.id).all()
|
||||||
|
for file in files:
|
||||||
|
if not file.file_path or not file.file_path.lower().endswith('.rnd'):
|
||||||
|
continue
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
abs_path = _Path("data") / file.file_path
|
||||||
|
peek = _peek_rnd_headers(abs_path)
|
||||||
|
if not _is_leq_file(file.file_path, peek):
|
||||||
|
continue
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||||
|
loc_name = location.name if location else f"Session {session.id[:8]}"
|
||||||
|
location_file_counts[loc_name] = location_file_counts.get(loc_name, 0) + 1
|
||||||
|
|
||||||
|
locations = [
|
||||||
|
{"name": name, "file_count": count}
|
||||||
|
for name, count in sorted(location_file_counts.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
report_templates = db.query(ReportTemplate).all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("combined_report_wizard.html", {
|
||||||
|
"request": request,
|
||||||
|
"project": project,
|
||||||
|
"project_id": project_id,
|
||||||
|
"locations": locations,
|
||||||
|
"report_templates": report_templates,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/combined-report-preview", response_class=HTMLResponse)
|
||||||
|
async def combined_report_preview(
|
||||||
|
request: Request,
|
||||||
|
project_id: str,
|
||||||
|
report_title: str = Query("Background Noise Study"),
|
||||||
|
project_name: str = Query(""),
|
||||||
|
client_name: str = Query(""),
|
||||||
|
start_time: str = Query(""),
|
||||||
|
end_time: str = Query(""),
|
||||||
|
start_date: str = Query(""),
|
||||||
|
end_date: str = Query(""),
|
||||||
|
enabled_locations: str = Query(""),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Preview and edit combined report data before generating the Excel file."""
|
||||||
|
enabled_list = [loc.strip() for loc in enabled_locations.split(',') if loc.strip()] if enabled_locations else None
|
||||||
|
|
||||||
|
result = _build_combined_location_data(
|
||||||
|
project_id, db,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
enabled_locations=enabled_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
project = result["project"]
|
||||||
|
location_data = result["location_data"]
|
||||||
|
|
||||||
|
total_rows = sum(loc["filtered_count"] for loc in location_data)
|
||||||
|
final_project_name = project_name if project_name else project.name
|
||||||
|
|
||||||
|
# Build time filter display string
|
||||||
|
time_filter_desc = ""
|
||||||
|
if start_time and end_time:
|
||||||
|
time_filter_desc = f"{start_time} – {end_time}"
|
||||||
|
elif start_time or end_time:
|
||||||
|
time_filter_desc = f"{start_time or ''} – {end_time or ''}"
|
||||||
|
|
||||||
|
return templates.TemplateResponse("combined_report_preview.html", {
|
||||||
|
"request": request,
|
||||||
|
"project": project,
|
||||||
|
"project_id": project_id,
|
||||||
|
"report_title": report_title,
|
||||||
|
"project_name": final_project_name,
|
||||||
|
"client_name": client_name,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"time_filter_desc": time_filter_desc,
|
||||||
|
"location_data": location_data,
|
||||||
|
"locations_json": json.dumps(location_data),
|
||||||
|
"total_rows": total_rows,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/generate-combined-from-preview")
|
||||||
|
async def generate_combined_from_preview(
|
||||||
|
project_id: str,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Generate combined Excel report from wizard-edited spreadsheet data."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
report_title = data.get("report_title", "Background Noise Study")
|
||||||
|
project_name = data.get("project_name", project.name)
|
||||||
|
client_name = data.get("client_name", "")
|
||||||
|
locations = data.get("locations", [])
|
||||||
|
|
||||||
|
if not locations:
|
||||||
|
raise HTTPException(status_code=400, detail="No location data provided")
|
||||||
|
|
||||||
|
# Styles
|
||||||
|
title_font = Font(name='Arial', bold=True, size=12)
|
||||||
|
header_font = Font(name='Arial', bold=True, size=10)
|
||||||
|
data_font = Font(name='Arial', 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")
|
||||||
|
center_align = Alignment(horizontal='center', vertical='center')
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
wb.remove(wb.active)
|
||||||
|
|
||||||
|
all_location_summaries = []
|
||||||
|
|
||||||
|
for loc_info in locations:
|
||||||
|
loc_name = loc_info.get("location_name", "Unknown")
|
||||||
|
rows = loc_info.get("spreadsheet_data", [])
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
safe_sheet_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
|
||||||
|
ws = wb.create_sheet(title=safe_sheet_name)
|
||||||
|
|
||||||
|
# Title row
|
||||||
|
final_title = f"{report_title} - {project_name}"
|
||||||
|
ws['A1'] = final_title
|
||||||
|
ws['A1'].font = title_font
|
||||||
|
ws['A1'].alignment = center_align
|
||||||
|
ws.merge_cells('A1:G1')
|
||||||
|
ws.row_dimensions[1].height = 20
|
||||||
|
|
||||||
|
# Client row (row 2) if provided
|
||||||
|
if client_name:
|
||||||
|
ws['A2'] = client_name
|
||||||
|
ws['A2'].font = Font(name='Arial', italic=True, size=10)
|
||||||
|
ws['A2'].alignment = center_align
|
||||||
|
ws.merge_cells('A2:G2')
|
||||||
|
|
||||||
|
# Location row
|
||||||
|
ws['A3'] = loc_name
|
||||||
|
ws['A3'].font = Font(name='Arial', bold=True, size=12)
|
||||||
|
ws['A3'].alignment = center_align
|
||||||
|
ws.merge_cells('A3:G3')
|
||||||
|
ws.row_dimensions[3].height = 20
|
||||||
|
|
||||||
|
# Column headers at row 7
|
||||||
|
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 = center_align
|
||||||
|
ws.row_dimensions[7].height = 16
|
||||||
|
|
||||||
|
column_widths = [12, 11, 9, 11, 11, 11, 20]
|
||||||
|
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
|
||||||
|
lmax_vals = []
|
||||||
|
ln1_vals = []
|
||||||
|
ln2_vals = []
|
||||||
|
|
||||||
|
for row_idx, row in enumerate(rows):
|
||||||
|
data_row = data_start_row + row_idx
|
||||||
|
# row is [test#, date, time, lmax, ln1, ln2, comment]
|
||||||
|
test_num = row[0] if len(row) > 0 else row_idx + 1
|
||||||
|
date_val = row[1] if len(row) > 1 else ''
|
||||||
|
time_val = row[2] if len(row) > 2 else ''
|
||||||
|
lmax = row[3] if len(row) > 3 else ''
|
||||||
|
ln1 = row[4] if len(row) > 4 else ''
|
||||||
|
ln2 = row[5] if len(row) > 5 else ''
|
||||||
|
comment = row[6] if len(row) > 6 else ''
|
||||||
|
|
||||||
|
ws.cell(row=data_row, column=1, value=test_num).border = thin_border
|
||||||
|
ws.cell(row=data_row, column=2, value=date_val).border = thin_border
|
||||||
|
ws.cell(row=data_row, column=3, value=time_val).border = thin_border
|
||||||
|
ws.cell(row=data_row, column=4, value=lmax if lmax != '' else None).border = thin_border
|
||||||
|
ws.cell(row=data_row, column=5, value=ln1 if ln1 != '' else None).border = thin_border
|
||||||
|
ws.cell(row=data_row, column=6, value=ln2 if ln2 != '' else None).border = thin_border
|
||||||
|
ws.cell(row=data_row, column=7, value=comment).border = thin_border
|
||||||
|
|
||||||
|
if isinstance(lmax, (int, float)):
|
||||||
|
lmax_vals.append(lmax)
|
||||||
|
if isinstance(ln1, (int, float)):
|
||||||
|
ln1_vals.append(ln1)
|
||||||
|
if isinstance(ln2, (int, float)):
|
||||||
|
ln2_vals.append(ln2)
|
||||||
|
|
||||||
|
data_end_row = data_start_row + len(rows) - 1
|
||||||
|
|
||||||
|
# Line chart
|
||||||
|
chart = LineChart()
|
||||||
|
chart.title = loc_name
|
||||||
|
chart.style = 10
|
||||||
|
chart.y_axis.title = "Sound Level (dBA)"
|
||||||
|
chart.x_axis.title = "Time"
|
||||||
|
chart.height = 18
|
||||||
|
chart.width = 22
|
||||||
|
|
||||||
|
data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row)
|
||||||
|
categories = Reference(ws, min_col=3, 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, "H3")
|
||||||
|
|
||||||
|
from openpyxl.worksheet.properties import PageSetupProperties
|
||||||
|
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True)
|
||||||
|
ws.page_setup.orientation = 'landscape'
|
||||||
|
ws.page_setup.fitToWidth = 1
|
||||||
|
ws.page_setup.fitToHeight = 0
|
||||||
|
|
||||||
|
all_location_summaries.append({
|
||||||
|
'location': loc_name,
|
||||||
|
'samples': len(rows),
|
||||||
|
'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None,
|
||||||
|
'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None,
|
||||||
|
'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Summary sheet
|
||||||
|
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
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
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".replace(' ', '_')
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
output,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Project-level bulk upload (entire date-folder structure)
|
# Project-level bulk upload (entire date-folder structure)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -3062,6 +3598,23 @@ async def upload_all_project_data(
|
|||||||
serial_number = rnh_meta.get("serial_number", "")
|
serial_number = rnh_meta.get("serial_number", "")
|
||||||
index_number = rnh_meta.get("index_number", "")
|
index_number = rnh_meta.get("index_number", "")
|
||||||
|
|
||||||
|
# Detect device model from first RND file in this group (in-memory)
|
||||||
|
_bulk_device_model = None
|
||||||
|
for _fname, _fbytes in file_list:
|
||||||
|
if _fname.lower().endswith(".rnd"):
|
||||||
|
try:
|
||||||
|
import csv as _csv_dm, io as _io_dm
|
||||||
|
_text = _fbytes.decode("utf-8", errors="replace")
|
||||||
|
_reader = _csv_dm.DictReader(_io_dm.StringIO(_text))
|
||||||
|
_first = next(_reader, None)
|
||||||
|
if _first and "LAeq" in _first:
|
||||||
|
_bulk_device_model = "NL-32"
|
||||||
|
# NL-43/NL-53 have no distinguishing marker vs each other
|
||||||
|
# at the format level; leave None for those.
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
monitoring_session = MonitoringSession(
|
monitoring_session = MonitoringSession(
|
||||||
id=session_id,
|
id=session_id,
|
||||||
@@ -3073,6 +3626,7 @@ async def upload_all_project_data(
|
|||||||
stopped_at=stopped_at,
|
stopped_at=stopped_at,
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
status="completed",
|
status="completed",
|
||||||
|
device_model=_bulk_device_model,
|
||||||
session_metadata=json.dumps({
|
session_metadata=json.dumps({
|
||||||
"source": "bulk_upload",
|
"source": "bulk_upload",
|
||||||
"group_path": group_path,
|
"group_path": group_path,
|
||||||
|
|||||||
19
rebuild-dev.sh
Executable file
19
rebuild-dev.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Dev rebuild script — increments build number, rebuilds and restarts terra-view
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BUILD_FILE="$SCRIPT_DIR/build_number.txt"
|
||||||
|
|
||||||
|
# Read and increment build number
|
||||||
|
BUILD_NUMBER=$(cat "$BUILD_FILE" 2>/dev/null || echo "0")
|
||||||
|
BUILD_NUMBER=$((BUILD_NUMBER + 1))
|
||||||
|
echo "$BUILD_NUMBER" > "$BUILD_FILE"
|
||||||
|
|
||||||
|
echo "Building terra-view dev (build #$BUILD_NUMBER)..."
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
docker compose build --build-arg BUILD_NUMBER="$BUILD_NUMBER" terra-view
|
||||||
|
docker compose up -d terra-view
|
||||||
|
|
||||||
|
echo "Done — terra-view v0.6.1-$BUILD_NUMBER is running on :1001"
|
||||||
312
templates/combined_report_preview.html
Normal file
312
templates/combined_report_preview.html
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Combined Report Preview - {{ project.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- jspreadsheet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/jspreadsheet.min.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.css" />
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Combined Report Preview & Editor</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{{ location_data|length }} location{{ 's' if location_data|length != 1 else '' }}
|
||||||
|
{% if time_filter_desc %} | {{ time_filter_desc }}{% endif %}
|
||||||
|
| {{ total_rows }} total row{{ 's' if total_rows != 1 else '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="downloadCombinedReport()" id="download-btn"
|
||||||
|
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm font-medium">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Generate Excel
|
||||||
|
</button>
|
||||||
|
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
|
||||||
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
|
||||||
|
← Back to Config
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
|
||||||
|
|
||||||
|
<!-- Report Metadata -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report Title</label>
|
||||||
|
<input type="text" id="edit-report-title" value="{{ report_title }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
|
||||||
|
<input type="text" id="edit-project-name" value="{{ project_name }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client Name</label>
|
||||||
|
<input type="text" id="edit-client-name" value="{{ client_name }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location Tabs + Spreadsheet -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||||
|
<div class="flex min-w-max" id="tab-bar">
|
||||||
|
{% for loc in location_data %}
|
||||||
|
<button onclick="switchTab({{ loop.index0 }})"
|
||||||
|
id="tab-btn-{{ loop.index0 }}"
|
||||||
|
class="tab-btn px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
||||||
|
{% if loop.first %}border-emerald-500 text-emerald-600 dark:text-emerald-400
|
||||||
|
{% else %}border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300{% endif %}">
|
||||||
|
{{ loc.location_name }}
|
||||||
|
<span class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full
|
||||||
|
{% if loop.first %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400
|
||||||
|
{% else %}bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400{% endif %}"
|
||||||
|
id="tab-count-{{ loop.index0 }}">
|
||||||
|
{{ loc.filtered_count }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spreadsheet Panels -->
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white" id="active-tab-title">
|
||||||
|
{{ location_data[0].location_name if location_data else '' }}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Right-click for options</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span>Double-click to edit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for loc in location_data %}
|
||||||
|
<div id="panel-{{ loop.index0 }}" class="tab-panel {% if not loop.first %}hidden{% endif %} overflow-x-auto">
|
||||||
|
<div id="spreadsheet-{{ loop.index0 }}"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">Editing Tips</h3>
|
||||||
|
<ul class="text-sm text-blue-700 dark:text-blue-400 list-disc list-inside space-y-1">
|
||||||
|
<li>Double-click any cell to edit its value</li>
|
||||||
|
<li>Use the Comments column to add notes about specific measurements</li>
|
||||||
|
<li>Right-click a row to insert or delete rows</li>
|
||||||
|
<li>Press Enter to confirm edits, Escape to cancel</li>
|
||||||
|
<li>Switch between location tabs to edit each location's data independently</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- jspreadsheet JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/index.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const allLocationData = {{ locations_json | safe }};
|
||||||
|
const spreadsheets = {};
|
||||||
|
let activeTabIdx = 0;
|
||||||
|
|
||||||
|
const columnDef = [
|
||||||
|
{ title: 'Test #', width: 80, type: 'numeric' },
|
||||||
|
{ title: 'Date', width: 110, type: 'text' },
|
||||||
|
{ title: 'Time', width: 90, type: 'text' },
|
||||||
|
{ title: 'LAmax (dBA)', width: 110, type: 'numeric' },
|
||||||
|
{ title: 'LA01 (dBA)', width: 110, type: 'numeric' },
|
||||||
|
{ title: 'LA10 (dBA)', width: 110, type: 'numeric' },
|
||||||
|
{ title: 'Comments', width: 250, type: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const jssOptions = {
|
||||||
|
columns: columnDef,
|
||||||
|
allowInsertRow: true,
|
||||||
|
allowDeleteRow: true,
|
||||||
|
allowInsertColumn: false,
|
||||||
|
allowDeleteColumn: false,
|
||||||
|
rowDrag: true,
|
||||||
|
columnSorting: true,
|
||||||
|
search: true,
|
||||||
|
pagination: 50,
|
||||||
|
paginationOptions: [25, 50, 100, 200],
|
||||||
|
defaultColWidth: 100,
|
||||||
|
minDimensions: [7, 1],
|
||||||
|
tableOverflow: true,
|
||||||
|
tableWidth: '100%',
|
||||||
|
contextMenu: function(instance, col, row, e) {
|
||||||
|
const items = [];
|
||||||
|
if (row !== null) {
|
||||||
|
items.push({
|
||||||
|
title: 'Insert row above',
|
||||||
|
onclick: function() { instance.insertRow(1, row, true); }
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
title: 'Insert row below',
|
||||||
|
onclick: function() { instance.insertRow(1, row + 1, false); }
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
title: 'Delete this row',
|
||||||
|
onclick: function() { instance.deleteRow(row); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
A: 'text-align: center;',
|
||||||
|
B: 'text-align: center;',
|
||||||
|
C: 'text-align: center;',
|
||||||
|
D: 'text-align: right;',
|
||||||
|
E: 'text-align: right;',
|
||||||
|
F: 'text-align: right;',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
allLocationData.forEach(function(loc, idx) {
|
||||||
|
const el = document.getElementById('spreadsheet-' + idx);
|
||||||
|
if (!el) return;
|
||||||
|
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
|
||||||
|
spreadsheets[loc.location_name] = jspreadsheet(el, opts);
|
||||||
|
});
|
||||||
|
if (allLocationData.length > 0) {
|
||||||
|
switchTab(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function switchTab(idx) {
|
||||||
|
activeTabIdx = idx;
|
||||||
|
|
||||||
|
// Update panels
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(function(panel, i) {
|
||||||
|
panel.classList.toggle('hidden', i !== idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tab button styles
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(function(btn, i) {
|
||||||
|
const countBadge = document.getElementById('tab-count-' + i);
|
||||||
|
if (i === idx) {
|
||||||
|
btn.classList.add('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
|
||||||
|
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||||
|
if (countBadge) {
|
||||||
|
countBadge.classList.add('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
|
||||||
|
countBadge.classList.remove('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
|
||||||
|
btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||||
|
if (countBadge) {
|
||||||
|
countBadge.classList.remove('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
|
||||||
|
countBadge.classList.add('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update title
|
||||||
|
if (allLocationData[idx]) {
|
||||||
|
document.getElementById('active-tab-title').textContent = allLocationData[idx].location_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh jspreadsheet rendering after showing panel
|
||||||
|
const loc = allLocationData[idx];
|
||||||
|
if (loc && spreadsheets[loc.location_name]) {
|
||||||
|
try { spreadsheets[loc.location_name].updateTable(); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadCombinedReport() {
|
||||||
|
const btn = document.getElementById('download-btn');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locations = allLocationData.map(function(loc) {
|
||||||
|
return {
|
||||||
|
location_name: loc.location_name,
|
||||||
|
spreadsheet_data: spreadsheets[loc.location_name] ? spreadsheets[loc.location_name].getData() : loc.spreadsheet_data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
report_title: document.getElementById('edit-report-title').value || 'Background Noise Study',
|
||||||
|
project_name: document.getElementById('edit-project-name').value || '',
|
||||||
|
client_name: document.getElementById('edit-client-name').value || '',
|
||||||
|
locations: locations,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/projects/{{ project_id }}/generate-combined-from-preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = 'combined_report.xlsx';
|
||||||
|
if (contentDisposition) {
|
||||||
|
const match = contentDisposition.match(/filename="(.+)"/);
|
||||||
|
if (match) filename = match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
a.remove();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert('Error generating report: ' + (error.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error generating report: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Dark mode jspreadsheet styles */
|
||||||
|
.dark .jexcel { background-color: #1e293b; color: #e2e8f0; }
|
||||||
|
.dark .jexcel thead td { background-color: #334155 !important; color: #e2e8f0 !important; border-color: #475569 !important; }
|
||||||
|
.dark .jexcel tbody td { background-color: #1e293b; color: #e2e8f0; border-color: #475569; }
|
||||||
|
.dark .jexcel tbody td:hover { background-color: #334155; }
|
||||||
|
.dark .jexcel tbody tr:nth-child(even) td { background-color: #0f172a; }
|
||||||
|
.dark .jexcel_pagination { background-color: #1e293b; color: #e2e8f0; border-color: #475569; }
|
||||||
|
.dark .jexcel_pagination a { color: #e2e8f0; }
|
||||||
|
.dark .jexcel_search { background-color: #1e293b; color: #e2e8f0; border-color: #475569; }
|
||||||
|
.dark .jexcel_search input { background-color: #334155; color: #e2e8f0; border-color: #475569; }
|
||||||
|
.dark .jexcel_content { background-color: #1e293b; }
|
||||||
|
.dark .jexcel_contextmenu { background-color: #1e293b; border-color: #475569; }
|
||||||
|
.dark .jexcel_contextmenu a { color: #e2e8f0; }
|
||||||
|
.dark .jexcel_contextmenu a:hover { background-color: #334155; }
|
||||||
|
.jexcel_content { max-height: 600px; overflow: auto; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
371
templates/combined_report_wizard.html
Normal file
371
templates/combined_report_wizard.html
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Combined Report Wizard - {{ project.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Combined Report Wizard</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ project.name }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="/api/projects/{{ project_id }}"
|
||||||
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm w-fit">
|
||||||
|
← Back to Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||||
|
|
||||||
|
<!-- Report Settings Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Report Settings</h2>
|
||||||
|
|
||||||
|
<!-- Template Selection -->
|
||||||
|
<div class="flex items-end gap-2 mb-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Load Template
|
||||||
|
</label>
|
||||||
|
<select id="template-select" onchange="applyTemplate()"
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
<option value="">-- Select a template --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="saveAsTemplate()"
|
||||||
|
class="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||||
|
title="Save current settings as template">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Report Title -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Report Title
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-title" value="Background Noise Study"
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project and Client -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Project Name
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-project" value="{{ project.name }}"
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="report-client" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Client Name
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-client" value="{{ project.client_name if project.client_name else '' }}"
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Filter Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Time Filter</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Applied to all locations. Leave blank to include all data.</p>
|
||||||
|
|
||||||
|
<!-- Preset Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<button type="button" onclick="setTimePreset('night')" data-preset="night"
|
||||||
|
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Night 7PM – 7AM
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="setTimePreset('day')" data-preset="day"
|
||||||
|
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Day 7AM – 7PM
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="setTimePreset('all')" data-preset="all"
|
||||||
|
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700 transition-colors">
|
||||||
|
All Day
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="setTimePreset('custom')" data-preset="custom"
|
||||||
|
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Inputs -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Time</label>
|
||||||
|
<input type="time" id="start-time" value=""
|
||||||
|
onchange="updatePresetButtons()"
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="end-time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Time</label>
|
||||||
|
<input type="time" id="end-time" value=""
|
||||||
|
onchange="updatePresetButtons()"
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Range -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Date Range <span class="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From</label>
|
||||||
|
<input type="date" id="start-date" value=""
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="end-date" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To</label>
|
||||||
|
<input type="date" id="end-date" value=""
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Locations Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Locations to Include</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
<span id="selected-count">{{ locations|length }}</span> of {{ locations|length }} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 text-sm">
|
||||||
|
<button type="button" onclick="selectAll()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button>
|
||||||
|
<button type="button" onclick="deselectAll()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if locations %}
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for loc in locations %}
|
||||||
|
<label class="flex items-center gap-3 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700/50 px-2 rounded-md transition-colors">
|
||||||
|
<input type="checkbox" name="location" value="{{ loc.name }}" checked
|
||||||
|
onchange="updateSelectedCount()"
|
||||||
|
class="h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500">
|
||||||
|
<span class="flex-1 text-sm text-gray-900 dark:text-white font-medium">{{ loc.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ loc.file_count }} file{{ 's' if loc.file_count != 1 else '' }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<p>No Leq measurement files found in this project.</p>
|
||||||
|
<p class="text-sm mt-1">Upload RND files with '_Leq_' in the filename to generate reports.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
|
||||||
|
<a href="/api/projects/{{ project_id }}"
|
||||||
|
class="w-full sm:w-auto px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors text-center text-sm font-medium">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="button" onclick="gotoPreview()" id="preview-btn"
|
||||||
|
{% if not locations %}disabled{% endif %}
|
||||||
|
class="w-full sm:w-auto px-6 py-2.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||||
|
</svg>
|
||||||
|
Preview & Edit →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let reportTemplates = [];
|
||||||
|
|
||||||
|
// ---- Template management (same as rnd_viewer.html) ----
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/report-templates?project_id={{ project_id }}');
|
||||||
|
if (response.ok) {
|
||||||
|
reportTemplates = await response.json();
|
||||||
|
populateTemplateDropdown();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading templates:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateTemplateDropdown() {
|
||||||
|
const select = document.getElementById('template-select');
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '<option value="">-- Select a template --</option>';
|
||||||
|
reportTemplates.forEach(template => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = template.id;
|
||||||
|
option.textContent = template.name;
|
||||||
|
option.dataset.config = JSON.stringify(template);
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate() {
|
||||||
|
const select = document.getElementById('template-select');
|
||||||
|
const selectedOption = select.options[select.selectedIndex];
|
||||||
|
if (!selectedOption.value) return;
|
||||||
|
const template = JSON.parse(selectedOption.dataset.config);
|
||||||
|
if (template.report_title) document.getElementById('report-title').value = template.report_title;
|
||||||
|
if (template.start_time) document.getElementById('start-time').value = template.start_time;
|
||||||
|
if (template.end_time) document.getElementById('end-time').value = template.end_time;
|
||||||
|
if (template.start_date) document.getElementById('start-date').value = template.start_date;
|
||||||
|
if (template.end_date) document.getElementById('end-date').value = template.end_date;
|
||||||
|
updatePresetButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAsTemplate() {
|
||||||
|
const name = prompt('Enter a name for this template:');
|
||||||
|
if (!name) return;
|
||||||
|
const templateData = {
|
||||||
|
name: name,
|
||||||
|
project_id: '{{ project_id }}',
|
||||||
|
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||||||
|
start_time: document.getElementById('start-time').value || null,
|
||||||
|
end_time: document.getElementById('end-time').value || null,
|
||||||
|
start_date: document.getElementById('start-date').value || null,
|
||||||
|
end_date: document.getElementById('end-date').value || null
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/report-templates', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(templateData)
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Template saved successfully!');
|
||||||
|
loadTemplates();
|
||||||
|
} else {
|
||||||
|
alert('Failed to save template');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error saving template: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Time preset buttons ----
|
||||||
|
|
||||||
|
function setTimePreset(preset) {
|
||||||
|
const startTimeInput = document.getElementById('start-time');
|
||||||
|
const endTimeInput = document.getElementById('end-time');
|
||||||
|
|
||||||
|
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('bg-emerald-600', 'text-white');
|
||||||
|
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case 'night':
|
||||||
|
startTimeInput.value = '19:00';
|
||||||
|
endTimeInput.value = '07:00';
|
||||||
|
break;
|
||||||
|
case 'day':
|
||||||
|
startTimeInput.value = '07:00';
|
||||||
|
endTimeInput.value = '19:00';
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
startTimeInput.value = '';
|
||||||
|
endTimeInput.value = '';
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
||||||
|
if (activeBtn) {
|
||||||
|
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||||
|
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePresetButtons() {
|
||||||
|
const startTime = document.getElementById('start-time').value;
|
||||||
|
const endTime = document.getElementById('end-time').value;
|
||||||
|
|
||||||
|
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('bg-emerald-600', 'text-white');
|
||||||
|
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||||
|
});
|
||||||
|
|
||||||
|
let preset = 'custom';
|
||||||
|
if (startTime === '19:00' && endTime === '07:00') preset = 'night';
|
||||||
|
else if (startTime === '07:00' && endTime === '19:00') preset = 'day';
|
||||||
|
else if (!startTime && !endTime) preset = 'all';
|
||||||
|
|
||||||
|
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
||||||
|
if (activeBtn) {
|
||||||
|
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||||
|
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Location checkboxes ----
|
||||||
|
|
||||||
|
function updateSelectedCount() {
|
||||||
|
const checked = document.querySelectorAll('input[name="location"]:checked').length;
|
||||||
|
document.getElementById('selected-count').textContent = checked;
|
||||||
|
document.getElementById('preview-btn').disabled = checked === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = true);
|
||||||
|
updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAll() {
|
||||||
|
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = false);
|
||||||
|
updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckedLocations() {
|
||||||
|
return Array.from(document.querySelectorAll('input[name="location"]:checked')).map(cb => cb.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Navigate to preview ----
|
||||||
|
|
||||||
|
function gotoPreview() {
|
||||||
|
const checked = getCheckedLocations();
|
||||||
|
if (checked.length === 0) {
|
||||||
|
alert('Please select at least one location.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||||||
|
project_name: document.getElementById('report-project').value || '',
|
||||||
|
client_name: document.getElementById('report-client').value || '',
|
||||||
|
start_time: document.getElementById('start-time').value || '',
|
||||||
|
end_time: document.getElementById('end-time').value || '',
|
||||||
|
start_date: document.getElementById('start-date').value || '',
|
||||||
|
end_date: document.getElementById('end-date').value || '',
|
||||||
|
enabled_locations: checked.join(','),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = `/api/projects/{{ project_id }}/combined-report-preview?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Init ----
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
loadTemplates();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<!-- Project Actions -->
|
<!-- Project Actions -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||||
<a href="/api/projects/{{ project.id }}/generate-combined-report"
|
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
|
||||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
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">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
|||||||
Reference in New Issue
Block a user