Compare commits
3 Commits
14856e61ef
...
86010de60c
| Author | SHA1 | Date | |
|---|---|---|---|
| 86010de60c | |||
| f89f04cd6f | |||
| 67a2faa2d3 |
131
backend/migrate_add_session_period_type.py
Normal file
131
backend/migrate_add_session_period_type.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: Add session_label and period_type columns to monitoring_sessions.
|
||||||
|
|
||||||
|
session_label - user-editable display name, e.g. "NRL-1 Sun 2/23 Night"
|
||||||
|
period_type - one of: weekday_day | weekday_night | weekend_day | weekend_night
|
||||||
|
Auto-derived from started_at when NULL.
|
||||||
|
|
||||||
|
Period definitions (used in report stats table):
|
||||||
|
weekday_day Mon-Fri 07:00-22:00 -> Daytime (7AM-10PM)
|
||||||
|
weekday_night Mon-Fri 22:00-07:00 -> Nighttime (10PM-7AM)
|
||||||
|
weekend_day Sat-Sun 07:00-22:00 -> Daytime (7AM-10PM)
|
||||||
|
weekend_night Sat-Sun 22:00-07:00 -> Nighttime (10PM-7AM)
|
||||||
|
|
||||||
|
Run once inside the Docker container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_session_period_type.py
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DB_PATH = Path("data/seismo_fleet.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_period_type(started_at_str: str) -> str | None:
|
||||||
|
"""Derive period_type from a started_at ISO datetime string."""
|
||||||
|
if not started_at_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(started_at_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
is_weekend = dt.weekday() >= 5 # 5=Sat, 6=Sun
|
||||||
|
is_night = dt.hour >= 22 or dt.hour < 7
|
||||||
|
if is_weekend:
|
||||||
|
return "weekend_night" if is_night else "weekend_day"
|
||||||
|
else:
|
||||||
|
return "weekday_night" if is_night else "weekday_day"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_label(started_at_str: str, location_name: str | None, period_type: str | None) -> str | None:
|
||||||
|
"""Build a human-readable session label."""
|
||||||
|
if not started_at_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(started_at_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
day_abbr = dt.strftime("%a") # Mon, Tue, Sun, etc.
|
||||||
|
date_str = dt.strftime("%-m/%-d") # 2/23
|
||||||
|
|
||||||
|
period_labels = {
|
||||||
|
"weekday_day": "Day",
|
||||||
|
"weekday_night": "Night",
|
||||||
|
"weekend_day": "Day",
|
||||||
|
"weekend_night": "Night",
|
||||||
|
}
|
||||||
|
period_str = period_labels.get(period_type or "", "")
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if location_name:
|
||||||
|
parts.append(location_name)
|
||||||
|
parts.append(f"{day_abbr} {date_str}")
|
||||||
|
if period_str:
|
||||||
|
parts.append(period_str)
|
||||||
|
return " — ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
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 columns (idempotent)
|
||||||
|
cur.execute("PRAGMA table_info(monitoring_sessions)")
|
||||||
|
existing_cols = {row["name"] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
for col, typedef in [("session_label", "TEXT"), ("period_type", "TEXT")]:
|
||||||
|
if col not in existing_cols:
|
||||||
|
cur.execute(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {typedef}")
|
||||||
|
conn.commit()
|
||||||
|
print(f"✓ Added column {col} to monitoring_sessions")
|
||||||
|
else:
|
||||||
|
print(f"○ Column {col} already exists — skipping ALTER TABLE")
|
||||||
|
|
||||||
|
# 2. Backfill existing rows
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT ms.id, ms.started_at, ms.location_id
|
||||||
|
FROM monitoring_sessions ms
|
||||||
|
WHERE ms.period_type IS NULL OR ms.session_label IS NULL"""
|
||||||
|
)
|
||||||
|
sessions = cur.fetchall()
|
||||||
|
print(f"Backfilling {len(sessions)} session(s)...")
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for row in sessions:
|
||||||
|
session_id = row["id"]
|
||||||
|
started_at = row["started_at"]
|
||||||
|
location_id = row["location_id"]
|
||||||
|
|
||||||
|
# Look up location name
|
||||||
|
location_name = None
|
||||||
|
if location_id:
|
||||||
|
cur.execute("SELECT name FROM monitoring_locations WHERE id = ?", (location_id,))
|
||||||
|
loc_row = cur.fetchone()
|
||||||
|
if loc_row:
|
||||||
|
location_name = loc_row["name"]
|
||||||
|
|
||||||
|
period_type = _derive_period_type(started_at)
|
||||||
|
label = _build_label(started_at, location_name, period_type)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE monitoring_sessions SET period_type = ?, session_label = ? WHERE id = ?",
|
||||||
|
(period_type, label, session_id),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"✓ Backfilled {updated} session(s).")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -272,6 +272,14 @@ class MonitoringSession(Base):
|
|||||||
duration_seconds = Column(Integer, nullable=True)
|
duration_seconds = Column(Integer, nullable=True)
|
||||||
status = Column(String, default="recording") # recording, completed, failed
|
status = Column(String, default="recording") # recording, completed, failed
|
||||||
|
|
||||||
|
# Human-readable label auto-derived from date/location, editable by user.
|
||||||
|
# e.g. "NRL-1 — Sun 2/23 — Night"
|
||||||
|
session_label = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Period classification for report stats columns.
|
||||||
|
# weekday_day | weekday_night | weekend_day | weekend_night
|
||||||
|
period_type = Column(String, nullable=True)
|
||||||
|
|
||||||
# Snapshot of device configuration at recording time
|
# Snapshot of device configuration at recording time
|
||||||
session_metadata = Column(Text, nullable=True) # JSON
|
session_metadata = Column(Text, nullable=True) # JSON
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,39 @@ from backend.templates_config import templates
|
|||||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Session period helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _derive_period_type(dt: datetime) -> str:
|
||||||
|
"""
|
||||||
|
Classify a session start time into one of four period types.
|
||||||
|
Night = 22:00–07:00, Day = 07:00–22:00.
|
||||||
|
Weekend = Saturday (5) or Sunday (6).
|
||||||
|
"""
|
||||||
|
is_weekend = dt.weekday() >= 5
|
||||||
|
is_night = dt.hour >= 22 or dt.hour < 7
|
||||||
|
if is_weekend:
|
||||||
|
return "weekend_night" if is_night else "weekend_day"
|
||||||
|
return "weekday_night" if is_night else "weekday_day"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
|
||||||
|
"""Build a human-readable session label, e.g. 'NRL-1 — Sun 2/23 — Night'.
|
||||||
|
Uses started_at date as-is; user can correct period_type in the wizard.
|
||||||
|
"""
|
||||||
|
day_abbr = dt.strftime("%a")
|
||||||
|
date_str = f"{dt.month}/{dt.day}"
|
||||||
|
period_str = {
|
||||||
|
"weekday_day": "Day",
|
||||||
|
"weekday_night": "Night",
|
||||||
|
"weekend_day": "Day",
|
||||||
|
"weekend_night": "Night",
|
||||||
|
}.get(period_type, "")
|
||||||
|
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
|
||||||
|
return " — ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Monitoring Locations CRUD
|
# Monitoring Locations CRUD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -634,6 +667,30 @@ async def upload_nrl_data(
|
|||||||
if not file_entries:
|
if not file_entries:
|
||||||
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
||||||
|
|
||||||
|
# --- Step 1b: Filter to only relevant files ---
|
||||||
|
# Keep: .rnh (metadata) and measurement .rnd files
|
||||||
|
# NL-43 generates two .rnd types: _Leq_ (15-min averages, wanted) and _Lp_ (1-sec granular, skip)
|
||||||
|
# AU2 (NL-23/older Rion) generates a single Au2_####.rnd per session — always keep those
|
||||||
|
# Drop: _Lp_ .rnd, .xlsx, .mp3, and anything else
|
||||||
|
def _is_wanted(fname: str) -> bool:
|
||||||
|
n = fname.lower()
|
||||||
|
if n.endswith(".rnh"):
|
||||||
|
return True
|
||||||
|
if n.endswith(".rnd"):
|
||||||
|
if "_leq_" in n: # NL-43 Leq file
|
||||||
|
return True
|
||||||
|
if n.startswith("au2_"): # AU2 format (NL-23) — always Leq equivalent
|
||||||
|
return True
|
||||||
|
if "_lp" not in n and "_leq_" not in n:
|
||||||
|
# Unknown .rnd format — include it so we don't silently drop data
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
file_entries = [(fname, fbytes) for fname, fbytes in file_entries if _is_wanted(fname)]
|
||||||
|
|
||||||
|
if not file_entries:
|
||||||
|
raise HTTPException(status_code=400, detail="No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files.")
|
||||||
|
|
||||||
# --- Step 2: Find and parse .rnh metadata ---
|
# --- Step 2: Find and parse .rnh metadata ---
|
||||||
rnh_meta = {}
|
rnh_meta = {}
|
||||||
for fname, fbytes in file_entries:
|
for fname, fbytes in file_entries:
|
||||||
@@ -652,6 +709,9 @@ async def upload_nrl_data(
|
|||||||
index_number = rnh_meta.get("index_number", "")
|
index_number = rnh_meta.get("index_number", "")
|
||||||
|
|
||||||
# --- Step 3: Create MonitoringSession ---
|
# --- Step 3: Create MonitoringSession ---
|
||||||
|
period_type = _derive_period_type(started_at) if started_at else None
|
||||||
|
session_label = _build_session_label(started_at, location.name, period_type) if started_at else None
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
monitoring_session = MonitoringSession(
|
monitoring_session = MonitoringSession(
|
||||||
id=session_id,
|
id=session_id,
|
||||||
@@ -663,6 +723,8 @@ async def upload_nrl_data(
|
|||||||
stopped_at=stopped_at,
|
stopped_at=stopped_at,
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
status="completed",
|
status="completed",
|
||||||
|
session_label=session_label,
|
||||||
|
period_type=period_type,
|
||||||
session_metadata=json.dumps({
|
session_metadata=json.dumps({
|
||||||
"source": "manual_upload",
|
"source": "manual_upload",
|
||||||
"store_name": store_name,
|
"store_name": store_name,
|
||||||
|
|||||||
@@ -1794,6 +1794,34 @@ async def delete_session(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"}
|
||||||
|
|
||||||
|
@router.patch("/{project_id}/sessions/{session_id}")
|
||||||
|
async def patch_session(
|
||||||
|
project_id: str,
|
||||||
|
session_id: str,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update session_label and/or period_type on a monitoring session."""
|
||||||
|
session = db.query(MonitoringSession).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")
|
||||||
|
|
||||||
|
if "session_label" in data:
|
||||||
|
session.session_label = str(data["session_label"]).strip() or None
|
||||||
|
if "period_type" in data:
|
||||||
|
pt = data["period_type"]
|
||||||
|
if pt and pt not in VALID_PERIOD_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid period_type. Must be one of: {', '.join(sorted(VALID_PERIOD_TYPES))}")
|
||||||
|
session.period_type = pt or None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return JSONResponse({"status": "success", "session_label": session.session_label, "period_type": session.period_type})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
|
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
|
||||||
async def view_rnd_file(
|
async def view_rnd_file(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -3277,32 +3305,59 @@ async def combined_report_wizard(
|
|||||||
):
|
):
|
||||||
"""Configuration page for the combined multi-location report wizard."""
|
"""Configuration page for the combined multi-location report wizard."""
|
||||||
from backend.models import ReportTemplate
|
from backend.models import ReportTemplate
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()
|
||||||
|
|
||||||
# Build location list with Leq file counts (no filtering)
|
# Build location -> sessions list, only including sessions that have Leq files
|
||||||
location_file_counts: dict = {}
|
location_sessions: dict = {} # loc_name -> list of session dicts
|
||||||
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()
|
||||||
|
has_leq = False
|
||||||
for file in files:
|
for file in files:
|
||||||
if not file.file_path or not file.file_path.lower().endswith('.rnd'):
|
if not file.file_path or not file.file_path.lower().endswith('.rnd'):
|
||||||
continue
|
continue
|
||||||
from pathlib import Path as _Path
|
|
||||||
abs_path = _Path("data") / file.file_path
|
abs_path = _Path("data") / file.file_path
|
||||||
peek = _peek_rnd_headers(abs_path)
|
peek = _peek_rnd_headers(abs_path)
|
||||||
if not _is_leq_file(file.file_path, peek):
|
if _is_leq_file(file.file_path, peek):
|
||||||
continue
|
has_leq = True
|
||||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
break
|
||||||
loc_name = location.name if location else f"Session {session.id[:8]}"
|
if not has_leq:
|
||||||
location_file_counts[loc_name] = location_file_counts.get(loc_name, 0) + 1
|
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_sessions:
|
||||||
|
location_sessions[loc_name] = []
|
||||||
|
|
||||||
|
# Build a display date and day-of-week from started_at
|
||||||
|
date_display = ""
|
||||||
|
day_of_week = ""
|
||||||
|
if session.started_at:
|
||||||
|
date_display = session.started_at.strftime("%-m/%-d/%Y")
|
||||||
|
day_of_week = session.started_at.strftime("%A") # Monday, Sunday, etc.
|
||||||
|
|
||||||
|
location_sessions[loc_name].append({
|
||||||
|
"session_id": session.id,
|
||||||
|
"session_label": session.session_label or "",
|
||||||
|
"date_display": date_display,
|
||||||
|
"day_of_week": day_of_week,
|
||||||
|
"started_at": session.started_at.isoformat() if session.started_at else "",
|
||||||
|
"stopped_at": session.stopped_at.isoformat() if session.stopped_at else "",
|
||||||
|
"duration_h": (session.duration_seconds // 3600) if session.duration_seconds else 0,
|
||||||
|
"duration_m": ((session.duration_seconds % 3600) // 60) if session.duration_seconds else 0,
|
||||||
|
"period_type": session.period_type or "",
|
||||||
|
"status": session.status,
|
||||||
|
})
|
||||||
|
|
||||||
locations = [
|
locations = [
|
||||||
{"name": name, "file_count": count}
|
{"name": name, "sessions": sess_list}
|
||||||
for name, count in sorted(location_file_counts.items())
|
for name, sess_list in sorted(location_sessions.items())
|
||||||
]
|
]
|
||||||
|
|
||||||
report_templates = db.query(ReportTemplate).all()
|
report_templates = db.query(ReportTemplate).all()
|
||||||
@@ -3312,10 +3367,160 @@ async def combined_report_wizard(
|
|||||||
"project": project,
|
"project": project,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"locations": locations,
|
"locations": locations,
|
||||||
|
"locations_json": json.dumps(locations),
|
||||||
"report_templates": report_templates,
|
"report_templates": report_templates,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _build_location_data_from_sessions(project_id: str, db, selected_session_ids: list) -> dict:
|
||||||
|
"""
|
||||||
|
Build per-location spreadsheet data using an explicit list of session IDs.
|
||||||
|
Only rows from those sessions are included. Per-session period_type is
|
||||||
|
stored on each row so the report can filter stats correctly.
|
||||||
|
"""
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
if not selected_session_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="No sessions selected.")
|
||||||
|
|
||||||
|
# Load every requested session — one entry per (session_id, loc_name) pair.
|
||||||
|
# Keyed by session_id so overnight sessions are never split by calendar date.
|
||||||
|
session_entries: dict = {} # session_id -> {loc_name, session_label, period_type, rows[]}
|
||||||
|
|
||||||
|
for session_id in selected_session_ids:
|
||||||
|
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
|
||||||
|
if not session:
|
||||||
|
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]}"
|
||||||
|
|
||||||
|
session_entries[session_id] = {
|
||||||
|
"loc_name": loc_name,
|
||||||
|
"session_label": session.session_label or "",
|
||||||
|
"period_type": session.period_type or "",
|
||||||
|
"started_at": session.started_at,
|
||||||
|
"rows": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
abs_path = _Path("data") / file.file_path
|
||||||
|
peek = _peek_rnd_headers(abs_path)
|
||||||
|
if not _is_leq_file(file.file_path, peek):
|
||||||
|
continue
|
||||||
|
rows = _read_rnd_file_rows(file.file_path)
|
||||||
|
rows, _ = _normalize_rnd_rows(rows)
|
||||||
|
session_entries[session_id]["rows"].extend(rows)
|
||||||
|
|
||||||
|
if not any(e["rows"] for e in session_entries.values()):
|
||||||
|
raise HTTPException(status_code=404, detail="No Leq data found in the selected sessions.")
|
||||||
|
|
||||||
|
location_data = []
|
||||||
|
for session_id in selected_session_ids:
|
||||||
|
entry = session_entries.get(session_id)
|
||||||
|
if not entry or not entry["rows"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
loc_name = entry["loc_name"]
|
||||||
|
period_type = entry["period_type"]
|
||||||
|
raw_rows = sorted(entry["rows"], key=lambda r: r.get('Start Time', ''))
|
||||||
|
|
||||||
|
# Parse all rows to datetimes first so we can apply period-aware filtering
|
||||||
|
parsed = []
|
||||||
|
for row in raw_rows:
|
||||||
|
start_time_str = row.get('Start Time', '')
|
||||||
|
dt = None
|
||||||
|
if start_time_str:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
parsed.append((dt, row))
|
||||||
|
|
||||||
|
# Determine which rows to keep based on period_type
|
||||||
|
is_day_session = period_type in ('weekday_day', 'weekend_day')
|
||||||
|
target_date = None
|
||||||
|
if is_day_session:
|
||||||
|
# Day: 07:00–18:59 only, restricted to the LAST calendar date that has daytime rows
|
||||||
|
daytime_dates = sorted({
|
||||||
|
dt.date() for dt, row in parsed
|
||||||
|
if dt and 7 <= dt.hour < 19
|
||||||
|
})
|
||||||
|
target_date = daytime_dates[-1] if daytime_dates else None
|
||||||
|
filtered = [
|
||||||
|
(dt, row) for dt, row in parsed
|
||||||
|
if dt and dt.date() == target_date and 7 <= dt.hour < 19
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# Night: 19:00–06:59, spanning both calendar days — no date restriction
|
||||||
|
filtered = [
|
||||||
|
(dt, row) for dt, row in parsed
|
||||||
|
if dt and (dt.hour >= 19 or dt.hour < 7)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fall back to all rows if filtering removed everything
|
||||||
|
if not filtered:
|
||||||
|
filtered = parsed
|
||||||
|
|
||||||
|
spreadsheet_data = []
|
||||||
|
for idx, (dt, row) in enumerate(filtered, 1):
|
||||||
|
date_str = dt.strftime('%Y-%m-%d') if dt else ''
|
||||||
|
time_str = dt.strftime('%H:%M') if dt else ''
|
||||||
|
|
||||||
|
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 '',
|
||||||
|
'',
|
||||||
|
period_type, # col index 7 — hidden, used by report gen for day/night bucketing
|
||||||
|
])
|
||||||
|
|
||||||
|
# For the label/filename, use target_date (day sessions) or started_at (night sessions)
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
started_at_dt = entry["started_at"]
|
||||||
|
if is_day_session and target_date:
|
||||||
|
# Use the actual target date from data filtering (last date with daytime rows)
|
||||||
|
label_dt = datetime.combine(target_date, datetime.min.time())
|
||||||
|
else:
|
||||||
|
label_dt = started_at_dt
|
||||||
|
|
||||||
|
# Rebuild session label using the correct label date
|
||||||
|
if label_dt and entry["loc_name"]:
|
||||||
|
period_str = {"weekday_day": "Day", "weekday_night": "Night",
|
||||||
|
"weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "")
|
||||||
|
day_abbr = label_dt.strftime("%a")
|
||||||
|
date_label = f"{label_dt.month}/{label_dt.day}"
|
||||||
|
session_label = " — ".join(p for p in [loc_name, f"{day_abbr} {date_label}", period_str] if p)
|
||||||
|
else:
|
||||||
|
session_label = entry["session_label"]
|
||||||
|
|
||||||
|
location_data.append({
|
||||||
|
"session_id": session_id,
|
||||||
|
"location_name": loc_name,
|
||||||
|
"session_label": session_label,
|
||||||
|
"period_type": period_type,
|
||||||
|
"started_at": label_dt.isoformat() if label_dt else "",
|
||||||
|
"raw_count": len(raw_rows),
|
||||||
|
"filtered_count": len(filtered),
|
||||||
|
"spreadsheet_data": spreadsheet_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"project": project, "location_data": location_data}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/combined-report-preview", response_class=HTMLResponse)
|
@router.get("/{project_id}/combined-report-preview", response_class=HTMLResponse)
|
||||||
async def combined_report_preview(
|
async def combined_report_preview(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -3323,38 +3528,19 @@ async def combined_report_preview(
|
|||||||
report_title: str = Query("Background Noise Study"),
|
report_title: str = Query("Background Noise Study"),
|
||||||
project_name: str = Query(""),
|
project_name: str = Query(""),
|
||||||
client_name: str = Query(""),
|
client_name: str = Query(""),
|
||||||
start_time: str = Query(""),
|
selected_sessions: str = Query(""), # comma-separated session IDs
|
||||||
end_time: str = Query(""),
|
|
||||||
start_date: str = Query(""),
|
|
||||||
end_date: str = Query(""),
|
|
||||||
enabled_locations: str = Query(""),
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Preview and edit combined report data before generating the Excel file."""
|
"""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
|
session_ids = [s.strip() for s in selected_sessions.split(',') if s.strip()] if selected_sessions else []
|
||||||
|
|
||||||
result = _build_combined_location_data(
|
result = _build_location_data_from_sessions(project_id, db, session_ids)
|
||||||
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"]
|
project = result["project"]
|
||||||
location_data = result["location_data"]
|
location_data = result["location_data"]
|
||||||
|
|
||||||
total_rows = sum(loc["filtered_count"] for loc in location_data)
|
total_rows = sum(loc["filtered_count"] for loc in location_data)
|
||||||
final_project_name = project_name if project_name else project.name
|
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", {
|
return templates.TemplateResponse("combined_report_preview.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project": project,
|
"project": project,
|
||||||
@@ -3362,11 +3548,7 @@ async def combined_report_preview(
|
|||||||
"report_title": report_title,
|
"report_title": report_title,
|
||||||
"project_name": final_project_name,
|
"project_name": final_project_name,
|
||||||
"client_name": client_name,
|
"client_name": client_name,
|
||||||
"start_time": start_time,
|
"time_filter_desc": f"{len(session_ids)} session{'s' if len(session_ids) != 1 else ''} selected",
|
||||||
"end_time": end_time,
|
|
||||||
"start_date": start_date,
|
|
||||||
"end_date": end_date,
|
|
||||||
"time_filter_desc": time_filter_desc,
|
|
||||||
"location_data": location_data,
|
"location_data": location_data,
|
||||||
"locations_json": json.dumps(location_data),
|
"locations_json": json.dumps(location_data),
|
||||||
"total_rows": total_rows,
|
"total_rows": total_rows,
|
||||||
@@ -3379,12 +3561,17 @@ async def generate_combined_from_preview(
|
|||||||
data: dict,
|
data: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Generate combined Excel report from wizard-edited spreadsheet data."""
|
"""Generate combined Excel report from wizard-edited spreadsheet data.
|
||||||
|
|
||||||
|
Produces one .xlsx per day (each with one sheet per location) packaged
|
||||||
|
into a single .zip file for download.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
import openpyxl
|
import openpyxl
|
||||||
from openpyxl.chart import LineChart, Reference
|
from openpyxl.chart import LineChart, Reference
|
||||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
|
from openpyxl.worksheet.properties import PageSetupProperties
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl")
|
raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl")
|
||||||
|
|
||||||
@@ -3400,12 +3587,13 @@ async def generate_combined_from_preview(
|
|||||||
if not locations:
|
if not locations:
|
||||||
raise HTTPException(status_code=400, detail="No location data provided")
|
raise HTTPException(status_code=400, detail="No location data provided")
|
||||||
|
|
||||||
# Styles
|
# Shared styles
|
||||||
f_title = Font(name='Arial', bold=True, size=12)
|
f_title = Font(name='Arial', bold=True, size=12)
|
||||||
f_bold = Font(name='Arial', bold=True, size=10)
|
f_bold = Font(name='Arial', bold=True, size=10)
|
||||||
f_data = Font(name='Arial', size=10)
|
f_data = Font(name='Arial', size=10)
|
||||||
thin = Side(style='thin')
|
thin = Side(style='thin')
|
||||||
dbl = Side(style='double')
|
dbl = Side(style='double')
|
||||||
|
med = Side(style='medium')
|
||||||
hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin)
|
hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin)
|
||||||
hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin)
|
hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin)
|
||||||
hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin)
|
hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin)
|
||||||
@@ -3415,34 +3603,28 @@ async def generate_combined_from_preview(
|
|||||||
data_inner = Border(left=thin, right=thin, top=thin, bottom=thin)
|
data_inner = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
data_left = Border(left=dbl, right=thin, top=thin, bottom=thin)
|
data_left = Border(left=dbl, right=thin, top=thin, bottom=thin)
|
||||||
data_right = Border(left=thin, right=dbl, top=thin, bottom=thin)
|
data_right = Border(left=thin, right=dbl, top=thin, bottom=thin)
|
||||||
hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||||
center_a = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
center_a = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||||
left_a = Alignment(horizontal='left', vertical='center')
|
left_a = Alignment(horizontal='left', vertical='center')
|
||||||
right_a = Alignment(horizontal='right', vertical='center')
|
thin_border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
|
||||||
from openpyxl.worksheet.properties import PageSetupProperties
|
tbl_top_left = Border(left=med, right=thin, top=med, bottom=thin)
|
||||||
|
tbl_top_mid = Border(left=thin, right=thin, top=med, bottom=thin)
|
||||||
|
tbl_top_right = Border(left=thin, right=med, top=med, bottom=thin)
|
||||||
|
tbl_mid_left = Border(left=med, right=thin, top=thin, bottom=thin)
|
||||||
|
tbl_mid_mid = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
tbl_mid_right = Border(left=thin, right=med, top=thin, bottom=thin)
|
||||||
|
tbl_bot_left = Border(left=med, right=thin, top=thin, bottom=med)
|
||||||
|
tbl_bot_mid = Border(left=thin, right=thin, top=thin, bottom=med)
|
||||||
|
tbl_bot_right = Border(left=thin, right=med, top=thin, bottom=med)
|
||||||
|
|
||||||
wb = openpyxl.Workbook()
|
col_widths = [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 18.0, 18.0, 14.0, 14.0, 10.0, 8.0, 6.43, 6.43]
|
||||||
wb.remove(wb.active)
|
|
||||||
|
|
||||||
all_location_summaries = []
|
def _build_location_sheet(ws, loc_name, day_rows, final_title):
|
||||||
|
"""Write one location's data onto ws. day_rows is a list of spreadsheet row arrays."""
|
||||||
for loc_info in locations:
|
for col_i, col_w in zip(range(1, 17), col_widths):
|
||||||
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)
|
|
||||||
|
|
||||||
# Column widths from Soundstudyexample.xlsx NRL_1 (sheet2)
|
|
||||||
# A B C D E F G H I J K L M N O P
|
|
||||||
for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]):
|
|
||||||
ws.column_dimensions[get_column_letter(col_i)].width = col_w
|
ws.column_dimensions[get_column_letter(col_i)].width = col_w
|
||||||
|
|
||||||
final_title = f"{report_title} - {project_name}"
|
|
||||||
ws.merge_cells('A1:G1')
|
ws.merge_cells('A1:G1')
|
||||||
ws['A1'] = final_title
|
ws['A1'] = final_title
|
||||||
ws['A1'].font = f_title; ws['A1'].alignment = center_a
|
ws['A1'].font = f_title; ws['A1'].alignment = center_a
|
||||||
@@ -3453,6 +3635,28 @@ async def generate_combined_from_preview(
|
|||||||
ws['A3'] = loc_name
|
ws['A3'] = loc_name
|
||||||
ws['A3'].font = f_title; ws['A3'].alignment = center_a
|
ws['A3'].font = f_title; ws['A3'].alignment = center_a
|
||||||
ws.row_dimensions[3].height = 15.75
|
ws.row_dimensions[3].height = 15.75
|
||||||
|
|
||||||
|
# Row 4: date range derived from the data rows
|
||||||
|
def _fmt_date(d):
|
||||||
|
try:
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
return _dt.strptime(d, '%Y-%m-%d').strftime('%-m/%-d/%y')
|
||||||
|
except Exception:
|
||||||
|
return d
|
||||||
|
|
||||||
|
dates_in_data = sorted({
|
||||||
|
row[1] for row in day_rows
|
||||||
|
if len(row) > 1 and row[1]
|
||||||
|
})
|
||||||
|
if len(dates_in_data) >= 2:
|
||||||
|
date_label = f"{_fmt_date(dates_in_data[0])} to {_fmt_date(dates_in_data[-1])}"
|
||||||
|
elif len(dates_in_data) == 1:
|
||||||
|
date_label = _fmt_date(dates_in_data[0])
|
||||||
|
else:
|
||||||
|
date_label = ""
|
||||||
|
ws.merge_cells('A4:G4')
|
||||||
|
ws['A4'] = date_label
|
||||||
|
ws['A4'].font = f_data; ws['A4'].alignment = center_a
|
||||||
ws.row_dimensions[4].height = 15
|
ws.row_dimensions[4].height = 15
|
||||||
ws.row_dimensions[5].height = 15.75
|
ws.row_dimensions[5].height = 15.75
|
||||||
|
|
||||||
@@ -3463,27 +3667,25 @@ async def generate_combined_from_preview(
|
|||||||
cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
|
cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
|
||||||
ws.row_dimensions[6].height = 39
|
ws.row_dimensions[6].height = 39
|
||||||
|
|
||||||
# Data rows starting at row 7
|
|
||||||
data_start_row = 7
|
data_start_row = 7
|
||||||
parsed_rows_p = []
|
parsed_rows = []
|
||||||
lmax_vals = []
|
lmax_vals, ln1_vals, ln2_vals = [], [], []
|
||||||
ln1_vals = []
|
|
||||||
ln2_vals = []
|
|
||||||
|
|
||||||
for row_idx, row in enumerate(rows):
|
for row_idx, row in enumerate(day_rows):
|
||||||
dr = data_start_row + row_idx
|
dr = data_start_row + row_idx
|
||||||
is_last = (row_idx == len(rows) - 1)
|
is_last = (row_idx == len(day_rows) - 1)
|
||||||
b_left = last_left if is_last else data_left
|
b_left = last_left if is_last else data_left
|
||||||
b_inner = last_inner if is_last else data_inner
|
b_inner = last_inner if is_last else data_inner
|
||||||
b_right = last_right if is_last else data_right
|
b_right = last_right if is_last else data_right
|
||||||
|
|
||||||
test_num = row[0] if len(row) > 0 else row_idx + 1
|
test_num = row[0] if len(row) > 0 else row_idx + 1
|
||||||
date_val = row[1] if len(row) > 1 else ''
|
date_val = _fmt_date(row[1]) if len(row) > 1 and row[1] else ''
|
||||||
time_val = row[2] if len(row) > 2 else ''
|
time_val = row[2] if len(row) > 2 else ''
|
||||||
lmax = row[3] if len(row) > 3 else ''
|
lmax = row[3] if len(row) > 3 else ''
|
||||||
ln1 = row[4] if len(row) > 4 else ''
|
ln1 = row[4] if len(row) > 4 else ''
|
||||||
ln2 = row[5] if len(row) > 5 else ''
|
ln2 = row[5] if len(row) > 5 else ''
|
||||||
comment = row[6] if len(row) > 6 else ''
|
comment = row[6] if len(row) > 6 else ''
|
||||||
|
row_period = row[7] if len(row) > 7 else '' # hidden period_type from session
|
||||||
|
|
||||||
c = ws.cell(row=dr, column=1, value=test_num)
|
c = ws.cell(row=dr, column=1, value=test_num)
|
||||||
c.font = f_data; c.alignment = center_a; c.border = b_left
|
c.font = f_data; c.alignment = center_a; c.border = b_left
|
||||||
@@ -3508,20 +3710,11 @@ async def generate_combined_from_preview(
|
|||||||
if isinstance(ln2, (int, float)):
|
if isinstance(ln2, (int, float)):
|
||||||
ln2_vals.append(ln2)
|
ln2_vals.append(ln2)
|
||||||
|
|
||||||
# Parse time for evening/nighttime stats
|
if isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
|
||||||
if time_val and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
|
parsed_rows.append((row_period, time_val, float(lmax), float(ln1), float(ln2)))
|
||||||
try:
|
|
||||||
try:
|
|
||||||
row_dt = datetime.strptime(str(time_val), '%H:%M')
|
|
||||||
except ValueError:
|
|
||||||
row_dt = datetime.strptime(str(time_val), '%H:%M:%S')
|
|
||||||
parsed_rows_p.append((row_dt, float(lmax), float(ln1), float(ln2)))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
data_end_row = data_start_row + len(rows) - 1
|
data_end_row = data_start_row + len(day_rows) - 1
|
||||||
|
|
||||||
# Chart anchored at H4
|
|
||||||
chart = LineChart()
|
chart = LineChart()
|
||||||
chart.title = f"{loc_name} - {final_title}"
|
chart.title = f"{loc_name} - {final_title}"
|
||||||
chart.style = 2
|
chart.style = 2
|
||||||
@@ -3545,69 +3738,114 @@ async def generate_combined_from_preview(
|
|||||||
|
|
||||||
ws.add_chart(chart, "H4")
|
ws.add_chart(chart, "H4")
|
||||||
|
|
||||||
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
|
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||||
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
|
|
||||||
|
def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None
|
||||||
|
def _max(vals): return round(max(vals), 1) if vals else None
|
||||||
|
|
||||||
|
# --- Period bucketing ------------------------------------------------
|
||||||
|
# For night sessions: split into Evening (7PM–10PM) and Nighttime (10PM–7AM).
|
||||||
|
# For day sessions: single Daytime bucket.
|
||||||
|
PERIOD_TYPE_IS_DAY = {"weekday_day", "weekend_day"}
|
||||||
|
PERIOD_TYPE_IS_NIGHT = {"weekday_night", "weekend_night"}
|
||||||
|
|
||||||
|
day_rows_data = []
|
||||||
|
evening_rows_data = []
|
||||||
|
night_rows_data = []
|
||||||
|
|
||||||
|
for pt, time_v, lmx, l1, l2 in parsed_rows:
|
||||||
|
if pt in PERIOD_TYPE_IS_DAY:
|
||||||
|
day_rows_data.append((lmx, l1, l2))
|
||||||
|
elif pt in PERIOD_TYPE_IS_NIGHT:
|
||||||
|
# Split by time: Evening = 19:00–21:59, Nighttime = 22:00–06:59
|
||||||
|
hour = 0
|
||||||
|
if time_v and ':' in str(time_v):
|
||||||
|
try:
|
||||||
|
hour = int(str(time_v).split(':')[0])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if 19 <= hour <= 21:
|
||||||
|
evening_rows_data.append((lmx, l1, l2))
|
||||||
|
else:
|
||||||
|
night_rows_data.append((lmx, l1, l2))
|
||||||
|
else:
|
||||||
|
day_rows_data.append((lmx, l1, l2))
|
||||||
|
|
||||||
|
all_candidate_periods = [
|
||||||
|
("Daytime (7AM to 10PM)", day_rows_data),
|
||||||
|
("Evening (7PM to 10PM)", evening_rows_data),
|
||||||
|
("Nighttime (10PM to 7AM)", night_rows_data),
|
||||||
|
]
|
||||||
|
active_periods = [(label, rows) for label, rows in all_candidate_periods if rows]
|
||||||
|
if not active_periods:
|
||||||
|
active_periods = [("Daytime (7AM to 10PM)", [])]
|
||||||
|
|
||||||
|
# --- Stats table — fixed position alongside the chart ---
|
||||||
|
note1 = ws.cell(row=28, column=9,
|
||||||
|
value="Note: Averages are calculated by determining the arithmetic average ")
|
||||||
note1.font = f_data; note1.alignment = left_a
|
note1.font = f_data; note1.alignment = left_a
|
||||||
ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
|
ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
|
||||||
note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.")
|
note2 = ws.cell(row=29, column=9,
|
||||||
|
value="for each specified range of time intervals.")
|
||||||
note2.font = f_data; note2.alignment = left_a
|
note2.font = f_data; note2.alignment = left_a
|
||||||
ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)
|
ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)
|
||||||
|
|
||||||
# Table header row 31
|
for r in [28, 29, 30, 31, 32, 33, 34]:
|
||||||
med = Side(style='medium')
|
ws.row_dimensions[r].height = 15
|
||||||
tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin'))
|
|
||||||
tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin'))
|
|
||||||
tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin'))
|
|
||||||
tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
|
||||||
tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
|
||||||
tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin'))
|
|
||||||
tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med)
|
|
||||||
tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med)
|
|
||||||
tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med)
|
|
||||||
|
|
||||||
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
tbl_hdr_row = 31
|
||||||
|
tbl_data_row = 32
|
||||||
|
|
||||||
# Header row: blank | Evening | Nighttime
|
# Layout: col 9 = row label, then pairs: (10,11), (12,13), (14,15)
|
||||||
c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
|
num_periods = len(active_periods)
|
||||||
c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)")
|
period_start_cols = [10 + i * 2 for i in range(num_periods)]
|
||||||
c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
|
||||||
c.border = tbl_top_mid; c.fill = hdr_fill_tbl
|
|
||||||
ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11)
|
|
||||||
c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)")
|
|
||||||
c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
|
||||||
c.border = tbl_top_right; c.fill = hdr_fill_tbl
|
|
||||||
ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13)
|
|
||||||
ws.row_dimensions[31].height = 15
|
|
||||||
|
|
||||||
evening_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if 19 <= dt.hour < 22]
|
def _hdr_border(i, n):
|
||||||
nighttime_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if dt.hour >= 22 or dt.hour < 7]
|
return Border(
|
||||||
|
left=med if i == 0 else thin,
|
||||||
|
right=med if i == n - 1 else thin,
|
||||||
|
top=med, bottom=thin,
|
||||||
|
)
|
||||||
|
|
||||||
def _avg_p(vals): return round(sum(vals) / len(vals), 1) if vals else None
|
c = ws.cell(row=tbl_hdr_row, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
|
||||||
def _max_p(vals): return round(max(vals), 1) if vals else None
|
|
||||||
|
|
||||||
def write_stat_p(row_num, label, eve_val, night_val, is_last=False):
|
for i, (period_label, _) in enumerate(active_periods):
|
||||||
bl = tbl_bot_left if is_last else tbl_mid_left
|
sc = period_start_cols[i]
|
||||||
bm = tbl_bot_mid if is_last else tbl_mid_mid
|
c = ws.cell(row=tbl_hdr_row, column=sc, value=period_label)
|
||||||
br = tbl_bot_right if is_last else tbl_mid_right
|
c.font = f_bold
|
||||||
lbl = ws.cell(row=row_num, column=9, value=label)
|
c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=False)
|
||||||
lbl.font = f_data; lbl.border = bl
|
c.border = _hdr_border(i, num_periods)
|
||||||
|
c.fill = hdr_fill_tbl
|
||||||
|
ws.merge_cells(start_row=tbl_hdr_row, start_column=sc,
|
||||||
|
end_row=tbl_hdr_row, end_column=sc + 1)
|
||||||
|
|
||||||
|
def write_stat_dynamic(row_num, row_label, period_vals_list, is_last=False):
|
||||||
|
lbl = ws.cell(row=row_num, column=9, value=row_label)
|
||||||
|
lbl.font = f_data; lbl.border = tbl_bot_left if is_last else tbl_mid_left
|
||||||
lbl.alignment = Alignment(horizontal='left', vertical='center')
|
lbl.alignment = Alignment(horizontal='left', vertical='center')
|
||||||
ev_str = f"{eve_val} dBA" if eve_val is not None else ""
|
n = len(period_vals_list)
|
||||||
ev = ws.cell(row=row_num, column=10, value=ev_str)
|
for i, val in enumerate(period_vals_list):
|
||||||
ev.font = f_bold; ev.border = bm
|
sc = period_start_cols[i]
|
||||||
ev.alignment = Alignment(horizontal='center', vertical='center')
|
val_str = f"{val} dBA" if val is not None else ""
|
||||||
ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11)
|
c = ws.cell(row=row_num, column=sc, value=val_str)
|
||||||
ni_str = f"{night_val} dBA" if night_val is not None else ""
|
c.font = f_bold
|
||||||
ni = ws.cell(row=row_num, column=12, value=ni_str)
|
c.alignment = Alignment(horizontal='center', vertical='center')
|
||||||
ni.font = f_bold; ni.border = br
|
c.border = Border(
|
||||||
ni.alignment = Alignment(horizontal='center', vertical='center')
|
left=med if i == 0 else thin,
|
||||||
ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13)
|
right=med if i == n - 1 else thin,
|
||||||
|
top=tbl_bot_mid.top if is_last else tbl_mid_mid.top,
|
||||||
|
bottom=tbl_bot_mid.bottom if is_last else tbl_mid_mid.bottom,
|
||||||
|
)
|
||||||
|
ws.merge_cells(start_row=row_num, start_column=sc,
|
||||||
|
end_row=row_num, end_column=sc + 1)
|
||||||
|
|
||||||
write_stat_p(32, "LAmax", _max_p([v[0] for v in evening_p]), _max_p([v[0] for v in nighttime_p]))
|
write_stat_dynamic(tbl_data_row, "LAmax",
|
||||||
write_stat_p(33, "LA01 Average",_avg_p([v[1] for v in evening_p]), _avg_p([v[1] for v in nighttime_p]))
|
[_max([v[0] for v in rows]) for _, rows in active_periods])
|
||||||
write_stat_p(34, "LA10 Average",_avg_p([v[2] for v in evening_p]), _avg_p([v[2] for v in nighttime_p]), is_last=True)
|
write_stat_dynamic(tbl_data_row + 1, "LA01 Average",
|
||||||
|
[_avg([v[1] for v in rows]) for _, rows in active_periods])
|
||||||
|
write_stat_dynamic(tbl_data_row + 2, "LA10 Average",
|
||||||
|
[_avg([v[2] for v in rows]) for _, rows in active_periods], is_last=True)
|
||||||
|
|
||||||
# Page setup: portrait, letter
|
|
||||||
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
|
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
|
||||||
ws.page_setup.orientation = 'portrait'
|
ws.page_setup.orientation = 'portrait'
|
||||||
ws.page_setup.paperSize = 1
|
ws.page_setup.paperSize = 1
|
||||||
@@ -3618,50 +3856,94 @@ async def generate_combined_from_preview(
|
|||||||
ws.page_margins.header = 0.5
|
ws.page_margins.header = 0.5
|
||||||
ws.page_margins.footer = 0.5
|
ws.page_margins.footer = 0.5
|
||||||
|
|
||||||
all_location_summaries.append({
|
return {
|
||||||
'location': loc_name,
|
'location': loc_name,
|
||||||
'samples': len(rows),
|
'samples': len(day_rows),
|
||||||
'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None,
|
'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,
|
'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,
|
'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None,
|
||||||
})
|
}
|
||||||
|
|
||||||
# Summary sheet
|
def _build_summary_sheet(wb, day_label, project_name, loc_summaries):
|
||||||
thin_border = Border(left=Side(style='thin'), right=Side(style='thin'),
|
summary_ws = wb.create_sheet(title="Summary")
|
||||||
top=Side(style='thin'), bottom=Side(style='thin'))
|
summary_ws['A1'] = f"{report_title} - {project_name} - {day_label}"
|
||||||
summary_ws = wb.create_sheet(title="Summary", index=0)
|
summary_ws['A1'].font = f_title
|
||||||
summary_ws['A1'] = f"{report_title} - {project_name} - Summary"
|
summary_ws.merge_cells('A1:E1')
|
||||||
summary_ws['A1'].font = f_title
|
summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
|
||||||
summary_ws.merge_cells('A1:E1')
|
for col, header in enumerate(summary_headers, 1):
|
||||||
|
cell = summary_ws.cell(row=3, column=col, value=header)
|
||||||
|
cell.font = f_bold; cell.fill = hdr_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, s in enumerate(loc_summaries, 4):
|
||||||
|
summary_ws.cell(row=idx, column=1, value=s['location']).border = thin_border
|
||||||
|
summary_ws.cell(row=idx, column=2, value=s['samples']).border = thin_border
|
||||||
|
summary_ws.cell(row=idx, column=3, value=s['lmax_avg'] or '-').border = thin_border
|
||||||
|
summary_ws.cell(row=idx, column=4, value=s['ln1_avg'] or '-').border = thin_border
|
||||||
|
summary_ws.cell(row=idx, column=5, value=s['ln2_avg'] or '-').border = thin_border
|
||||||
|
|
||||||
summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
|
# ----------------------------------------------------------------
|
||||||
for col, header in enumerate(summary_headers, 1):
|
# Build one workbook per session (each location entry is one session)
|
||||||
cell = summary_ws.cell(row=3, column=col, value=header)
|
# ----------------------------------------------------------------
|
||||||
cell.font = f_bold
|
if not locations:
|
||||||
cell.fill = hdr_fill
|
raise HTTPException(status_code=400, detail="No location data provided")
|
||||||
cell.border = thin_border
|
|
||||||
|
|
||||||
for i, width in enumerate([30, 10, 12, 12, 12], 1):
|
project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip().replace(' ', '_')
|
||||||
summary_ws.column_dimensions[get_column_letter(i)].width = width
|
final_title = f"{report_title} - {project_name}"
|
||||||
|
|
||||||
for idx, loc_summary in enumerate(all_location_summaries, 4):
|
zip_buffer = io.BytesIO()
|
||||||
summary_ws.cell(row=idx, column=1, value=loc_summary['location']).border = thin_border
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
summary_ws.cell(row=idx, column=2, value=loc_summary['samples']).border = thin_border
|
for loc_info in locations:
|
||||||
summary_ws.cell(row=idx, column=3, value=loc_summary['lmax_avg'] or '-').border = thin_border
|
loc_name = loc_info.get("location_name", "Unknown")
|
||||||
summary_ws.cell(row=idx, column=4, value=loc_summary['ln1_avg'] or '-').border = thin_border
|
session_label = loc_info.get("session_label", "")
|
||||||
summary_ws.cell(row=idx, column=5, value=loc_summary['ln2_avg'] or '-').border = thin_border
|
period_type = loc_info.get("period_type", "")
|
||||||
|
started_at_str = loc_info.get("started_at", "")
|
||||||
|
rows = loc_info.get("spreadsheet_data", [])
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
output = io.BytesIO()
|
# Re-number interval # sequentially
|
||||||
wb.save(output)
|
for i, row in enumerate(rows):
|
||||||
output.seek(0)
|
if len(row) > 0:
|
||||||
|
row[0] = i + 1
|
||||||
|
|
||||||
project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip()
|
wb = openpyxl.Workbook()
|
||||||
filename = f"{project_name_clean}_combined_report.xlsx".replace(' ', '_')
|
wb.remove(wb.active)
|
||||||
|
|
||||||
|
safe_sheet = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
|
||||||
|
ws = wb.create_sheet(title=safe_sheet)
|
||||||
|
summary = _build_location_sheet(ws, loc_name, rows, final_title)
|
||||||
|
|
||||||
|
# Derive a date label for the summary sheet from started_at or first row
|
||||||
|
day_label = session_label or loc_name
|
||||||
|
if started_at_str:
|
||||||
|
try:
|
||||||
|
_dt = datetime.fromisoformat(started_at_str)
|
||||||
|
day_label = _dt.strftime('%-m/%-d/%Y')
|
||||||
|
if session_label:
|
||||||
|
day_label = session_label
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_build_summary_sheet(wb, day_label, project_name, [summary])
|
||||||
|
|
||||||
|
xlsx_buf = io.BytesIO()
|
||||||
|
wb.save(xlsx_buf)
|
||||||
|
xlsx_buf.seek(0)
|
||||||
|
|
||||||
|
# Build a clean filename from label or location+date
|
||||||
|
label_clean = session_label or loc_name
|
||||||
|
label_clean = "".join(c for c in label_clean if c.isalnum() or c in (' ', '-', '_', '/')).strip().replace(' ', '_').replace('/', '-')
|
||||||
|
xlsx_name = f"{label_clean}_{project_name_clean}_report.xlsx"
|
||||||
|
zf.writestr(xlsx_name, xlsx_buf.read())
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
zip_filename = f"{project_name_clean}_reports.zip"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
output,
|
zip_buffer,
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
media_type="application/zip",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
12
rebuild-prod.sh
Normal file
12
rebuild-prod.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Production rebuild script — rebuilds and restarts terra-view on :8001
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "Building terra-view production..."
|
||||||
|
docker compose -f docker-compose.yml build terra-view
|
||||||
|
docker compose -f docker-compose.yml up -d terra-view
|
||||||
|
|
||||||
|
echo "Done — terra-view production is running on :8001"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
<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>
|
</svg>
|
||||||
Generate Excel
|
Generate Reports (ZIP)
|
||||||
</button>
|
</button>
|
||||||
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
|
<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">
|
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">
|
||||||
@@ -187,7 +187,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const el = document.getElementById('spreadsheet-' + idx);
|
const el = document.getElementById('spreadsheet-' + idx);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
|
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
|
||||||
spreadsheets[loc.location_name] = jspreadsheet(el, opts);
|
spreadsheets[idx] = jspreadsheet(el, opts);
|
||||||
});
|
});
|
||||||
if (allLocationData.length > 0) {
|
if (allLocationData.length > 0) {
|
||||||
switchTab(0);
|
switchTab(0);
|
||||||
@@ -228,9 +228,8 @@ function switchTab(idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh jspreadsheet rendering after showing panel
|
// Refresh jspreadsheet rendering after showing panel
|
||||||
const loc = allLocationData[idx];
|
if (spreadsheets[idx]) {
|
||||||
if (loc && spreadsheets[loc.location_name]) {
|
try { spreadsheets[idx].updateTable(); } catch(e) {}
|
||||||
try { spreadsheets[loc.location_name].updateTable(); } catch(e) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,13 +237,17 @@ async function downloadCombinedReport() {
|
|||||||
const btn = document.getElementById('download-btn');
|
const btn = document.getElementById('download-btn');
|
||||||
const originalText = btn.innerHTML;
|
const originalText = btn.innerHTML;
|
||||||
btn.disabled = true;
|
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...';
|
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 ZIP...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const locations = allLocationData.map(function(loc) {
|
const locations = allLocationData.map(function(loc, idx) {
|
||||||
return {
|
return {
|
||||||
location_name: loc.location_name,
|
session_id: loc.session_id || '',
|
||||||
spreadsheet_data: spreadsheets[loc.location_name] ? spreadsheets[loc.location_name].getData() : loc.spreadsheet_data,
|
session_label: loc.session_label || '',
|
||||||
|
period_type: loc.period_type || '',
|
||||||
|
started_at: loc.started_at || '',
|
||||||
|
location_name: loc.location_name,
|
||||||
|
spreadsheet_data: spreadsheets[idx] ? spreadsheets[idx].getData() : loc.spreadsheet_data,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,7 +271,7 @@ async function downloadCombinedReport() {
|
|||||||
a.href = url;
|
a.href = url;
|
||||||
|
|
||||||
const contentDisposition = response.headers.get('Content-Disposition');
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
let filename = 'combined_report.xlsx';
|
let filename = 'combined_reports.zip';
|
||||||
if (contentDisposition) {
|
if (contentDisposition) {
|
||||||
const match = contentDisposition.match(/filename="(.+)"/);
|
const match = contentDisposition.match(/filename="(.+)"/);
|
||||||
if (match) filename = match[1];
|
if (match) filename = match[1];
|
||||||
|
|||||||
@@ -74,105 +74,134 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Time Filter Card -->
|
<!-- Sessions 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="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>
|
<div class="flex items-center justify-between mb-1">
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Applied to all locations. Leave blank to include all data.</p>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||||
|
|
||||||
<!-- 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">
|
<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="selectAllSessions()" 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>
|
<button type="button" onclick="deselectAllSessions()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<span id="selected-count">0</span> session(s) selected — each selected session becomes one sheet in the ZIP.
|
||||||
|
Change the period type per session to control how stats are bucketed (Day vs Night).
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if locations %}
|
{% if locations %}
|
||||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
||||||
{% for loc in locations %}
|
{% 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">
|
{% set loc_name = loc.name %}
|
||||||
<input type="checkbox" name="location" value="{{ loc.name }}" checked
|
{% set sessions = loc.sessions %}
|
||||||
onchange="updateSelectedCount()"
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg mb-3 overflow-hidden">
|
||||||
class="h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500">
|
<!-- Location header / toggle -->
|
||||||
<span class="flex-1 text-sm text-gray-900 dark:text-white font-medium">{{ loc.name }}</span>
|
<button type="button"
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ loc.file_count }} file{{ 's' if loc.file_count != 1 else '' }}</span>
|
onclick="toggleLocation('loc-{{ loop.index }}')"
|
||||||
</label>
|
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-slate-700/50 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-left">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg id="chevron-loc-{{ loop.index }}" class="w-4 h-4 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ loc_name }}</span>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ sessions|length }} session{{ 's' if sessions|length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs" onclick="event.stopPropagation()">
|
||||||
|
<button type="button" onclick="selectLocation('loc-{{ loop.index }}')"
|
||||||
|
class="text-emerald-600 dark:text-emerald-400 hover:underline">All</button>
|
||||||
|
<button type="button" onclick="deselectLocation('loc-{{ loop.index }}')"
|
||||||
|
class="text-gray-400 hover:underline">None</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Session rows -->
|
||||||
|
<div id="loc-{{ loop.index }}" class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
|
{% for s in sessions %}
|
||||||
|
{% set pt_colors = {
|
||||||
|
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
} %}
|
||||||
|
{% set pt_labels = {
|
||||||
|
'weekday_day': 'Weekday Day',
|
||||||
|
'weekday_night': 'Weekday Night',
|
||||||
|
'weekend_day': 'Weekend Day',
|
||||||
|
'weekend_night': 'Weekend Night',
|
||||||
|
} %}
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<input type="checkbox"
|
||||||
|
class="session-cb loc-{{ loop.index }}-cb h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500 shrink-0"
|
||||||
|
value="{{ s.session_id }}"
|
||||||
|
checked
|
||||||
|
onchange="updateSelectionStats()">
|
||||||
|
|
||||||
|
<!-- Date/day info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ s.day_of_week }} {{ s.date_display }}
|
||||||
|
</span>
|
||||||
|
{% if s.session_label %}
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 truncate">{{ s.session_label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if s.status == 'recording' %}
|
||||||
|
<span class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||||
|
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{% if s.started_at %}
|
||||||
|
<span>{{ s.started_at }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if s.duration_h is not none %}
|
||||||
|
<span>{{ s.duration_h }}h {{ s.duration_m }}m</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Period type dropdown -->
|
||||||
|
<div class="relative shrink-0" id="wiz-period-wrap-{{ s.session_id }}">
|
||||||
|
<button type="button"
|
||||||
|
onclick="toggleWizPeriodMenu('{{ s.session_id }}')"
|
||||||
|
id="wiz-period-badge-{{ s.session_id }}"
|
||||||
|
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ pt_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||||
|
title="Click to change period type">
|
||||||
|
<span id="wiz-period-label-{{ s.session_id }}">{{ pt_labels.get(s.period_type, 'Set period') }}</span>
|
||||||
|
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="wiz-period-menu-{{ s.session_id }}"
|
||||||
|
class="hidden absolute right-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
|
||||||
|
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
|
||||||
|
<button type="button"
|
||||||
|
onclick="setWizPeriodType('{{ s.session_id }}', '{{ pt }}')"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
|
||||||
|
{{ pt_label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div class="text-center py-10 text-gray-500 dark:text-gray-400">
|
||||||
<p>No Leq measurement files found in this project.</p>
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<p class="text-sm mt-1">Upload RND files with '_Leq_' in the filename to generate reports.</p>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No monitoring sessions found.</p>
|
||||||
|
<p class="text-sm mt-1">Upload data files to create sessions first.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer Buttons -->
|
<!-- Footer Buttons -->
|
||||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
|
||||||
<a href="/api/projects/{{ project_id }}"
|
<a href="/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">
|
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
|
Cancel
|
||||||
</a>
|
</a>
|
||||||
@@ -191,180 +220,173 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let reportTemplates = [];
|
const PROJECT_ID = '{{ project_id }}';
|
||||||
|
|
||||||
// ---- Template management (same as rnd_viewer.html) ----
|
const PERIOD_COLORS = {
|
||||||
|
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
};
|
||||||
|
const PERIOD_LABELS = {
|
||||||
|
weekday_day: 'Weekday Day',
|
||||||
|
weekday_night: 'Weekday Night',
|
||||||
|
weekend_day: 'Weekend Day',
|
||||||
|
weekend_night: 'Weekend Night',
|
||||||
|
};
|
||||||
|
const ALL_PERIOD_BADGE_CLASSES = [
|
||||||
|
'bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400',
|
||||||
|
...new Set(Object.values(PERIOD_COLORS).flatMap(s => s.split(' ')))
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Location accordion ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleLocation(locId) {
|
||||||
|
const body = document.getElementById(locId);
|
||||||
|
const chevron = document.getElementById('chevron-' + locId);
|
||||||
|
body.classList.toggle('hidden');
|
||||||
|
chevron.style.transform = body.classList.contains('hidden') ? 'rotate(-90deg)' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocation(locId) {
|
||||||
|
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = true);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectLocation(locId) {
|
||||||
|
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = false);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllSessions() {
|
||||||
|
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = true);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAllSessions() {
|
||||||
|
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = false);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionStats() {
|
||||||
|
const count = document.querySelectorAll('.session-cb:checked').length;
|
||||||
|
document.getElementById('selected-count').textContent = count;
|
||||||
|
document.getElementById('preview-btn').disabled = count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period type dropdown (wizard) ─────────────────────────────────
|
||||||
|
|
||||||
|
function toggleWizPeriodMenu(sessionId) {
|
||||||
|
const menu = document.getElementById('wiz-period-menu-' + sessionId);
|
||||||
|
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => {
|
||||||
|
if (m.id !== 'wiz-period-menu-' + sessionId) m.classList.add('hidden');
|
||||||
|
});
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('[id^="wiz-period-wrap-"]')) {
|
||||||
|
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setWizPeriodType(sessionId, periodType) {
|
||||||
|
document.getElementById('wiz-period-menu-' + sessionId).classList.add('hidden');
|
||||||
|
const badge = document.getElementById('wiz-period-badge-' + sessionId);
|
||||||
|
badge.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({period_type: periodType}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
ALL_PERIOD_BADGE_CLASSES.forEach(c => badge.classList.remove(c));
|
||||||
|
const colorStr = PERIOD_COLORS[periodType] || 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
badge.classList.add(...colorStr.split(' ').filter(Boolean));
|
||||||
|
document.getElementById('wiz-period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
|
||||||
|
} catch(err) {
|
||||||
|
alert('Failed to update period type: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
badge.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template management ───────────────────────────────────────────
|
||||||
|
|
||||||
|
let reportTemplates = [];
|
||||||
|
|
||||||
async function loadTemplates() {
|
async function loadTemplates() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/report-templates?project_id={{ project_id }}');
|
const resp = await fetch('/api/report-templates?project_id=' + PROJECT_ID);
|
||||||
if (response.ok) {
|
if (resp.ok) {
|
||||||
reportTemplates = await response.json();
|
reportTemplates = await resp.json();
|
||||||
populateTemplateDropdown();
|
populateTemplateDropdown();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch(e) { console.error('Error loading templates:', e); }
|
||||||
console.error('Error loading templates:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateTemplateDropdown() {
|
function populateTemplateDropdown() {
|
||||||
const select = document.getElementById('template-select');
|
const select = document.getElementById('template-select');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
select.innerHTML = '<option value="">-- Select a template --</option>';
|
select.innerHTML = '<option value="">-- Select a template --</option>';
|
||||||
reportTemplates.forEach(template => {
|
reportTemplates.forEach(t => {
|
||||||
const option = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
option.value = template.id;
|
opt.value = t.id;
|
||||||
option.textContent = template.name;
|
opt.textContent = t.name;
|
||||||
option.dataset.config = JSON.stringify(template);
|
opt.dataset.config = JSON.stringify(t);
|
||||||
select.appendChild(option);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTemplate() {
|
function applyTemplate() {
|
||||||
const select = document.getElementById('template-select');
|
const select = document.getElementById('template-select');
|
||||||
const selectedOption = select.options[select.selectedIndex];
|
const opt = select.options[select.selectedIndex];
|
||||||
if (!selectedOption.value) return;
|
if (!opt.value) return;
|
||||||
const template = JSON.parse(selectedOption.dataset.config);
|
const t = JSON.parse(opt.dataset.config);
|
||||||
if (template.report_title) document.getElementById('report-title').value = template.report_title;
|
if (t.report_title) document.getElementById('report-title').value = t.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() {
|
async function saveAsTemplate() {
|
||||||
const name = prompt('Enter a name for this template:');
|
const name = prompt('Enter a name for this template:');
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const templateData = {
|
const data = {
|
||||||
name: name,
|
name,
|
||||||
project_id: '{{ project_id }}',
|
project_id: PROJECT_ID,
|
||||||
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
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 {
|
try {
|
||||||
const response = await fetch('/api/report-templates', {
|
const resp = await fetch('/api/report-templates', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(templateData)
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (resp.ok) { alert('Template saved!'); loadTemplates(); }
|
||||||
alert('Template saved successfully!');
|
else alert('Failed to save template');
|
||||||
loadTemplates();
|
} catch(e) { alert('Error: ' + e.message); }
|
||||||
} else {
|
|
||||||
alert('Failed to save template');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error saving template: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Time preset buttons ----
|
// ── Navigate to preview ───────────────────────────────────────────
|
||||||
|
|
||||||
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() {
|
function gotoPreview() {
|
||||||
const checked = getCheckedLocations();
|
const checked = Array.from(document.querySelectorAll('.session-cb:checked')).map(cb => cb.value);
|
||||||
if (checked.length === 0) {
|
if (checked.length === 0) {
|
||||||
alert('Please select at least one location.');
|
alert('Please select at least one session.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||||||
project_name: document.getElementById('report-project').value || '',
|
project_name: document.getElementById('report-project').value || '',
|
||||||
client_name: document.getElementById('report-client').value || '',
|
client_name: document.getElementById('report-client').value || '',
|
||||||
start_time: document.getElementById('start-time').value || '',
|
selected_sessions: checked.join(','),
|
||||||
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()}`;
|
||||||
window.location.href = `/api/projects/{{ project_id }}/combined-report-preview?${params.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Init ----
|
// ── Init ─────────────────────────────────────────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateSelectionStats();
|
||||||
loadTemplates();
|
loadTemplates();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -385,16 +385,27 @@
|
|||||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||||
<div class="flex items-center gap-3 mt-3">
|
<div class="flex items-center gap-3 mt-3">
|
||||||
<button onclick="submitUpload()"
|
<button id="upload-btn" onclick="submitUpload()"
|
||||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
Import Files
|
Import Files
|
||||||
</button>
|
</button>
|
||||||
<button onclick="toggleUploadPanel()"
|
<button id="upload-cancel-btn" onclick="toggleUploadPanel()"
|
||||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<span id="upload-status" class="text-sm hidden"></span>
|
<span id="upload-status" class="text-sm hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Progress bar (hidden until upload starts) -->
|
||||||
|
<div id="upload-progress-wrap" class="hidden mt-3">
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
<span id="upload-progress-label">Uploading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div id="upload-progress-bar"
|
||||||
|
class="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="data-files-list"
|
<div id="data-files-list"
|
||||||
@@ -629,57 +640,105 @@ function toggleUploadPanel() {
|
|||||||
const panel = document.getElementById('upload-panel');
|
const panel = document.getElementById('upload-panel');
|
||||||
const status = document.getElementById('upload-status');
|
const status = document.getElementById('upload-status');
|
||||||
panel.classList.toggle('hidden');
|
panel.classList.toggle('hidden');
|
||||||
// Reset status when reopening
|
// Reset state when reopening
|
||||||
if (!panel.classList.contains('hidden')) {
|
if (!panel.classList.contains('hidden')) {
|
||||||
status.textContent = '';
|
status.textContent = '';
|
||||||
status.className = 'text-sm hidden';
|
status.className = 'text-sm hidden';
|
||||||
document.getElementById('upload-input').value = '';
|
document.getElementById('upload-input').value = '';
|
||||||
|
document.getElementById('upload-progress-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('upload-progress-bar').style.width = '0%';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitUpload() {
|
function submitUpload() {
|
||||||
const input = document.getElementById('upload-input');
|
const input = document.getElementById('upload-input');
|
||||||
const status = document.getElementById('upload-status');
|
const status = document.getElementById('upload-status');
|
||||||
|
const btn = document.getElementById('upload-btn');
|
||||||
|
const cancelBtn = document.getElementById('upload-cancel-btn');
|
||||||
|
const progressWrap = document.getElementById('upload-progress-wrap');
|
||||||
|
const progressBar = document.getElementById('upload-progress-bar');
|
||||||
|
const progressLabel = document.getElementById('upload-progress-label');
|
||||||
|
|
||||||
if (!input.files.length) {
|
if (!input.files.length) {
|
||||||
alert('Please select files to upload.');
|
alert('Please select files to upload.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileCount = input.files.length;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
formData.append('files', file);
|
formData.append('files', file);
|
||||||
}
|
}
|
||||||
|
|
||||||
status.textContent = 'Uploading\u2026';
|
// Disable controls and show progress bar
|
||||||
status.className = 'text-sm text-gray-500';
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading\u2026';
|
||||||
|
btn.classList.add('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
|
||||||
|
status.className = 'text-sm hidden';
|
||||||
|
progressWrap.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026`;
|
||||||
|
|
||||||
try {
|
const xhr = new XMLHttpRequest();
|
||||||
const response = await fetch(
|
|
||||||
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
|
||||||
{ method: 'POST', body: formData }
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
if (e.lengthComputable) {
|
||||||
if (data.leq_files || data.lp_files) {
|
const pct = Math.round((e.loaded / e.total) * 100);
|
||||||
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
progressBar.style.width = pct + '%';
|
||||||
|
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026 ${pct}%`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('load', () => {
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
progressLabel.textContent = 'Processing files on server\u2026';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Import Files';
|
||||||
|
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
||||||
|
if (data.leq_files || data.lp_files) {
|
||||||
|
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
||||||
|
}
|
||||||
|
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
||||||
|
status.textContent = parts.join(' ');
|
||||||
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
input.value = '';
|
||||||
|
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
||||||
|
} else {
|
||||||
|
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
} catch {
|
||||||
status.textContent = parts.join(' ');
|
status.textContent = 'Error: Unexpected server response';
|
||||||
status.className = 'text-sm text-green-600 dark:text-green-400';
|
|
||||||
input.value = '';
|
|
||||||
// Refresh the file list
|
|
||||||
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
|
||||||
} else {
|
|
||||||
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
});
|
||||||
status.textContent = `Error: ${err.message}`;
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Import Files';
|
||||||
|
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
|
||||||
|
status.textContent = 'Error: Network error during upload';
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/projects/${projectId}/nrl/${locationId}/upload-data`);
|
||||||
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,79 +1,149 @@
|
|||||||
<!-- Monitoring Sessions List -->
|
<!-- Monitoring Sessions List -->
|
||||||
{% if sessions %}
|
{% if sessions %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
{% for item in sessions %}
|
{% for item in sessions %}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
{% set s = item.session %}
|
||||||
<div class="flex items-start justify-between gap-3">
|
{% set loc = item.location %}
|
||||||
|
{% set unit = item.unit %}
|
||||||
|
|
||||||
|
{# Period display maps #}
|
||||||
|
{% set period_labels = {
|
||||||
|
'weekday_day': 'Weekday Day',
|
||||||
|
'weekday_night': 'Weekday Night',
|
||||||
|
'weekend_day': 'Weekend Day',
|
||||||
|
'weekend_night': 'Weekend Night',
|
||||||
|
} %}
|
||||||
|
{% set period_colors = {
|
||||||
|
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||||
|
id="session-card-{{ s.id }}">
|
||||||
|
<div class="flex items-start justify-between gap-3 p-4 pb-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
<!-- Label + badges -->
|
||||||
Session {{ item.session.id[:8] }}...
|
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||||
</h4>
|
<span id="label-display-{{ s.id }}"
|
||||||
{% if item.session.status == 'recording' %}
|
class="font-semibold text-gray-900 dark:text-white text-sm cursor-pointer hover:text-seismo-orange"
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center">
|
title="Click to edit label"
|
||||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span>
|
onclick="startEditLabel('{{ s.id }}')">
|
||||||
Recording
|
{{ s.session_label or ('Session ' + s.id[:8] + '…') }}
|
||||||
</span>
|
</span>
|
||||||
{% elif item.session.status == 'completed' %}
|
<input id="label-input-{{ s.id }}"
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
class="hidden text-sm font-semibold bg-transparent border-b border-seismo-orange text-gray-900 dark:text-white focus:outline-none min-w-[180px]"
|
||||||
Completed
|
value="{{ s.session_label or '' }}"
|
||||||
</span>
|
onblur="saveLabel('{{ s.id }}')"
|
||||||
{% elif item.session.status == 'paused' %}
|
onkeydown="if(event.key==='Enter'){saveLabel('{{ s.id }}');}if(event.key==='Escape'){cancelEditLabel('{{ s.id }}');}">
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
|
||||||
Paused
|
{% if s.status == 'recording' %}
|
||||||
</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||||
{% elif item.session.status == 'failed' %}
|
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
|
||||||
Failed
|
|
||||||
</span>
|
</span>
|
||||||
|
{% elif s.status == 'completed' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
|
||||||
|
{% elif s.status == 'failed' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Period type badge (click to change) -->
|
||||||
|
<div class="relative" id="period-wrap-{{ s.id }}">
|
||||||
|
<button onclick="togglePeriodMenu('{{ s.id }}')"
|
||||||
|
id="period-badge-{{ s.id }}"
|
||||||
|
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||||
|
title="Click to change period type">
|
||||||
|
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
||||||
|
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="period-menu-{{ s.id }}"
|
||||||
|
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
|
||||||
|
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
|
||||||
|
<button onclick="setPeriodType('{{ s.id }}', '{{ pt }}')"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
|
||||||
|
{{ pt_label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
<!-- Info grid -->
|
||||||
{% if item.unit %}
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div>
|
{% if loc %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">Unit:</span>
|
<div class="flex items-center gap-1">
|
||||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{{ item.unit.id }}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
</a>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ loc.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div>
|
{% if s.started_at %}
|
||||||
<span class="text-xs text-gray-500">Started:</span>
|
<div class="flex items-center gap-1">
|
||||||
<span class="ml-1">{{ item.session.started_at|local_datetime if item.session.started_at else 'N/A' }}</span>
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
{% if item.session.stopped_at %}
|
<span>{{ s.started_at|local_datetime }}</span>
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Ended:</span>
|
|
||||||
<span class="ml-1">{{ item.session.stopped_at|local_datetime }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if item.session.duration_seconds %}
|
{% if s.stopped_at %}
|
||||||
<div>
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-xs text-gray-500">Duration:</span>
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span class="ml-1">{{ (item.session.duration_seconds // 3600) }}h {{ ((item.session.duration_seconds % 3600) // 60) }}m</span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Ended {{ s.stopped_at|local_datetime }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if s.duration_seconds %}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ (s.duration_seconds // 3600) }}h {{ ((s.duration_seconds % 3600) // 60) }}m</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit %}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2v-4M9 21H5a2 2 0 01-2-2v-4m0 0h18"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/slm/{{ unit.id }}?from_project={{ project_id }}"
|
||||||
|
class="text-seismo-orange hover:underline font-medium">{{ unit.id }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if s.device_model %}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ s.device_model }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if item.session.notes %}
|
{% if s.notes %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
|
||||||
{{ item.session.notes }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
{% if item.session.status == 'recording' %}
|
{% if s.status == 'recording' %}
|
||||||
<button onclick="stopRecording('{{ item.session.id }}')"
|
<button onclick="stopRecording('{{ s.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button onclick="viewSession('{{ item.session.id }}')"
|
<button onclick="viewSession('{{ s.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
@@ -84,24 +154,107 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-1">No monitoring sessions yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500">Upload data to create sessions</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const PERIOD_COLORS = {
|
||||||
|
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
};
|
||||||
|
const PERIOD_LABELS = {
|
||||||
|
weekday_day: 'Weekday Day',
|
||||||
|
weekday_night: 'Weekday Night',
|
||||||
|
weekend_day: 'Weekend Day',
|
||||||
|
weekend_night: 'Weekend Night',
|
||||||
|
};
|
||||||
|
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
|
||||||
|
const ALL_BADGE_COLORS = [...new Set([
|
||||||
|
...FALLBACK_COLORS,
|
||||||
|
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
|
||||||
|
])];
|
||||||
|
|
||||||
|
function togglePeriodMenu(sessionId) {
|
||||||
|
const menu = document.getElementById('period-menu-' + sessionId);
|
||||||
|
document.querySelectorAll('[id^="period-menu-"]').forEach(m => {
|
||||||
|
if (m.id !== 'period-menu-' + sessionId) m.classList.add('hidden');
|
||||||
|
});
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('[id^="period-wrap-"]')) {
|
||||||
|
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setPeriodType(sessionId, periodType) {
|
||||||
|
document.getElementById('period-menu-' + sessionId).classList.add('hidden');
|
||||||
|
const badge = document.getElementById('period-badge-' + sessionId);
|
||||||
|
badge.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({period_type: periodType}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
||||||
|
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean));
|
||||||
|
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
|
||||||
|
} catch(err) {
|
||||||
|
alert('Failed to update period type: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
badge.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditLabel(sessionId) {
|
||||||
|
document.getElementById('label-display-' + sessionId).classList.add('hidden');
|
||||||
|
const input = document.getElementById('label-input-' + sessionId);
|
||||||
|
input.classList.remove('hidden');
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditLabel(sessionId) {
|
||||||
|
document.getElementById('label-input-' + sessionId).classList.add('hidden');
|
||||||
|
document.getElementById('label-display-' + sessionId).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLabel(sessionId) {
|
||||||
|
const display = document.getElementById('label-display-' + sessionId);
|
||||||
|
const input = document.getElementById('label-input-' + sessionId);
|
||||||
|
const newLabel = input.value.trim();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({session_label: newLabel}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
display.textContent = newLabel || ('Session ' + sessionId.slice(0, 8) + '…');
|
||||||
|
} catch(err) {
|
||||||
|
alert('Failed to save label: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
input.classList.add('hidden');
|
||||||
|
display.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function viewSession(sessionId) {
|
function viewSession(sessionId) {
|
||||||
// TODO: Implement session detail modal or page
|
|
||||||
alert('Session details coming soon: ' + sessionId);
|
alert('Session details coming soon: ' + sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording(sessionId) {
|
function stopRecording(sessionId) {
|
||||||
if (!confirm('Stop this monitoring session?')) return;
|
if (!confirm('Stop this monitoring session?')) return;
|
||||||
|
|
||||||
// TODO: Implement stop recording API call
|
|
||||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -264,16 +264,28 @@
|
|||||||
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||||
<button onclick="submitUploadAll()"
|
<span id="upload-all-file-count" class="text-xs text-gray-500 dark:text-gray-400 hidden"></span>
|
||||||
|
<button id="upload-all-btn" onclick="submitUploadAll()"
|
||||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
<button onclick="toggleUploadAll()"
|
<button id="upload-all-cancel-btn" onclick="toggleUploadAll()"
|
||||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<span id="upload-all-status" class="text-sm hidden"></span>
|
<span id="upload-all-status" class="text-sm hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div id="upload-all-progress-wrap" class="hidden mt-3">
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
<span id="upload-all-progress-label">Uploading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div id="upload-all-progress-bar"
|
||||||
|
class="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Result summary -->
|
<!-- Result summary -->
|
||||||
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
|
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1642,75 +1654,148 @@ function toggleUploadAll() {
|
|||||||
document.getElementById('upload-all-results').classList.add('hidden');
|
document.getElementById('upload-all-results').classList.add('hidden');
|
||||||
document.getElementById('upload-all-results').innerHTML = '';
|
document.getElementById('upload-all-results').innerHTML = '';
|
||||||
document.getElementById('upload-all-input').value = '';
|
document.getElementById('upload-all-input').value = '';
|
||||||
|
document.getElementById('upload-all-file-count').classList.add('hidden');
|
||||||
|
document.getElementById('upload-all-progress-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('upload-all-progress-bar').style.width = '0%';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitUploadAll() {
|
// Show file count and filter info when folder is selected
|
||||||
|
document.getElementById('upload-all-input').addEventListener('change', function() {
|
||||||
|
const countEl = document.getElementById('upload-all-file-count');
|
||||||
|
const total = this.files.length;
|
||||||
|
if (!total) { countEl.classList.add('hidden'); return; }
|
||||||
|
const wanted = Array.from(this.files).filter(_isWantedFile).length;
|
||||||
|
countEl.textContent = `${wanted} of ${total} files will be uploaded (Leq + .rnh only)`;
|
||||||
|
countEl.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
function _isWantedFile(f) {
|
||||||
|
const n = (f.webkitRelativePath || f.name).toLowerCase();
|
||||||
|
const base = n.split('/').pop();
|
||||||
|
if (base.endsWith('.rnh')) return true;
|
||||||
|
if (base.endsWith('.rnd')) {
|
||||||
|
if (base.includes('_leq_')) return true; // NL-43 Leq
|
||||||
|
if (base.startsWith('au2_')) return true; // AU2/NL-23 format
|
||||||
|
if (!base.includes('_lp')) return true; // unknown format — keep
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitUploadAll() {
|
||||||
const input = document.getElementById('upload-all-input');
|
const input = document.getElementById('upload-all-input');
|
||||||
const status = document.getElementById('upload-all-status');
|
const status = document.getElementById('upload-all-status');
|
||||||
const resultsEl = document.getElementById('upload-all-results');
|
const resultsEl = document.getElementById('upload-all-results');
|
||||||
|
const btn = document.getElementById('upload-all-btn');
|
||||||
|
const cancelBtn = document.getElementById('upload-all-cancel-btn');
|
||||||
|
const progressWrap = document.getElementById('upload-all-progress-wrap');
|
||||||
|
const progressBar = document.getElementById('upload-all-progress-bar');
|
||||||
|
const progressLabel = document.getElementById('upload-all-progress-label');
|
||||||
|
|
||||||
if (!input.files.length) {
|
if (!input.files.length) {
|
||||||
alert('Please select a folder to upload.');
|
alert('Please select a folder to upload.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter client-side — only send Leq .rnd and .rnh files
|
||||||
|
const filesToSend = Array.from(input.files).filter(_isWantedFile);
|
||||||
|
if (!filesToSend.length) {
|
||||||
|
alert('No Leq .rnd or .rnh files found in selected folder.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const f of input.files) {
|
for (const f of filesToSend) {
|
||||||
// webkitRelativePath gives the path relative to the selected folder root
|
|
||||||
formData.append('files', f);
|
formData.append('files', f);
|
||||||
formData.append('paths', f.webkitRelativePath || f.name);
|
formData.append('paths', f.webkitRelativePath || f.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
status.textContent = `Uploading ${input.files.length} files\u2026`;
|
// Disable controls and show progress
|
||||||
status.className = 'text-sm text-gray-500';
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading\u2026';
|
||||||
|
btn.classList.add('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
|
||||||
|
status.className = 'text-sm hidden';
|
||||||
resultsEl.classList.add('hidden');
|
resultsEl.classList.add('hidden');
|
||||||
|
progressWrap.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`;
|
||||||
|
|
||||||
try {
|
const xhr = new XMLHttpRequest();
|
||||||
const response = await fetch(
|
|
||||||
`/api/projects/{{ project_id }}/upload-all`,
|
|
||||||
{ method: 'POST', body: formData }
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
const s = data.sessions_created;
|
if (e.lengthComputable) {
|
||||||
const f = data.files_imported;
|
const pct = Math.round((e.loaded / e.total) * 100);
|
||||||
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
|
progressBar.style.width = pct + '%';
|
||||||
status.className = 'text-sm text-green-600 dark:text-green-400';
|
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`;
|
||||||
input.value = '';
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Build results summary
|
xhr.upload.addEventListener('load', () => {
|
||||||
let html = '';
|
progressBar.style.width = '100%';
|
||||||
if (data.sessions && data.sessions.length) {
|
progressLabel.textContent = 'Processing files on server\u2026';
|
||||||
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
|
});
|
||||||
html += '<ul class="space-y-0.5 ml-2">';
|
|
||||||
for (const sess of data.sessions) {
|
function _resetControls() {
|
||||||
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> — ${sess.files} files`;
|
progressWrap.classList.add('hidden');
|
||||||
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
|
btn.disabled = false;
|
||||||
if (sess.store_name) html += ` — ${sess.store_name}`;
|
btn.textContent = 'Import';
|
||||||
html += '</li>';
|
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
_resetControls();
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const s = data.sessions_created;
|
||||||
|
const f = data.files_imported;
|
||||||
|
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
|
||||||
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
input.value = '';
|
||||||
|
document.getElementById('upload-all-file-count').classList.add('hidden');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (data.sessions && data.sessions.length) {
|
||||||
|
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
|
||||||
|
html += '<ul class="space-y-0.5 ml-2">';
|
||||||
|
for (const sess of data.sessions) {
|
||||||
|
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> — ${sess.files} files`;
|
||||||
|
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
|
||||||
|
if (sess.store_name) html += ` — ${sess.store_name}`;
|
||||||
|
html += '</li>';
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
}
|
}
|
||||||
html += '</ul>';
|
if (data.unmatched_folders && data.unmatched_folders.length) {
|
||||||
|
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
|
||||||
|
}
|
||||||
|
if (html) {
|
||||||
|
resultsEl.innerHTML = html;
|
||||||
|
resultsEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
htmx.trigger(document.getElementById('unified-files'), 'refresh');
|
||||||
|
} else {
|
||||||
|
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
if (data.unmatched_folders && data.unmatched_folders.length) {
|
} catch {
|
||||||
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
|
status.textContent = 'Error: Unexpected server response';
|
||||||
}
|
|
||||||
if (html) {
|
|
||||||
resultsEl.innerHTML = html;
|
|
||||||
resultsEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the unified files view
|
|
||||||
htmx.trigger(document.getElementById('unified-files'), 'refresh');
|
|
||||||
} else {
|
|
||||||
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
});
|
||||||
status.textContent = `Error: ${err.message}`;
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
_resetControls();
|
||||||
|
status.textContent = 'Error: Network error during upload';
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/projects/{{ project_id }}/upload-all`);
|
||||||
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load project details on page load and restore active tab from URL hash
|
// Load project details on page load and restore active tab from URL hash
|
||||||
|
|||||||
Reference in New Issue
Block a user