Merge pull request 'Update to v0.7.0' (#30) from dev into main

Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
2026-03-08 03:13:17 -04:00
50 changed files with 5545 additions and 622 deletions

View File

@@ -1,3 +1,5 @@
docker-compose.override.yml
# Python cache / compiled # Python cache / compiled
__pycache__ __pycache__
*.pyc *.pyc
@@ -28,6 +30,7 @@ ENV/
# Runtime data (mounted volumes) # Runtime data (mounted volumes)
data/ data/
data-dev/
# Editors / OS junk # Editors / OS junk
.vscode/ .vscode/

20
.gitignore vendored
View File

@@ -1,3 +1,16 @@
# Terra-View Specifics
# Dev build counter (local only, never commit)
build_number.txt
# SQLite database files
*.db
*.db-journal
data/
data-dev/
.aider*
.aider*
docker-compose.override.yml
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[codz] *.py[codz]
@@ -206,10 +219,3 @@ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
# Seismo Fleet Manager
# SQLite database files
*.db
*.db-journal
data/
.aider*
.aider*

View File

@@ -5,6 +5,60 @@ All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.7.0] - 2026-03-07
### Added
- **Project Status Management**: Projects can now be placed `on_hold` or `archived`, with automatic cancellation of pending scheduled actions
- **Hard Delete Projects**: Support for permanently deleting projects, in addition to soft-delete with auto-pruning
- **Vibration Location Detail**: New dedicated template for vibration project location detail views
- **Vibration Project Isolation**: Vibration projects no longer show SLM-specific project tabs
- **Manual SD Card Data Upload**: Upload offline NRL data directly from SD card via ZIP or multi-file select
- Accepts `.rnd`/`.rnh` files; parses `.rnh` metadata for session start/stop times, serial number, and store name
- Creates `MonitoringSession` and `DataFile` records automatically; no unit assignment required
- Upload panel on NRL detail Data Files tab with inline feedback and auto-refresh via HTMX
- **Standalone SLM Type**: New SLM device mode that operates without a modem (direct IP connection)
- **NL32 Data Support**: Report generator and web viewer now support NL32 measurement data format
- **Combined Report Wizard**: Multi-session combined Excel report generation tool
- Wizard UI grouped by location with period type badges (day/night)
- Each selected session produces one `.xlsx` in a ZIP archive
- Period type filtering: day sessions keep last calendar date (7AM6:59PM); night sessions span both days (7PM6:59AM)
- **Combined Report Preview**: Interactive spreadsheet-style preview before generating combined reports
- **Chart Preview**: Live chart preview in the report generator matching final report styling
- **SLM Model Schemas**: Per-model configuration schemas for NL32, NL43, NL53 devices
- **Data Collection Mode**: Projects now store a data collection mode field with UI controls and migration
### Changed
- **MonitoringSession rename**: `RecordingSession` renamed to `MonitoringSession` throughout codebase; DB table renamed from `recording_sessions` to `monitoring_sessions`
- Migration: `backend/migrate_rename_recording_to_monitoring_sessions.py`
- **Combined Report Split Logic**: Separate days now generate separate `.xlsx` files; NRLs remain one per sheet
- **Mass Upload Parsing**: Smarter file filtering — no longer imports unneeded Lp files or `.xlsx` files
- **SLM Start Time Grace Period**: 15-minute grace window added so data starting at session start time is included
- **NL32 Date Parsing**: Date now read from `start_time` field instead of file metadata
- **Project Data Labels**: Improved Jinja filters and UI label clarity for project data views
### Fixed
- **Dev/Prod Separation**: Dev server now uses Docker Compose override; production deployment no longer affected by dev config
- **SLM Modal**: Bench/deploy toggle now correctly shown in SLM unit modal
- **Auto-Downloaded Files**: Files downloaded by scheduler now appear in project file listings
- **Duplicate Download**: Removed duplicate file download that occurred following a scheduled stop
- **SLMM Environment Variables**: `TCP_IDLE_TTL` and `TCP_MAX_AGE` now correctly passed to SLMM service via docker-compose
### Technical Details
- `session_label` and `period_type` stored on `monitoring_sessions` table (migration: `migrate_add_session_period_type.py`)
- `device_model` stored on `monitoring_sessions` table (migration: `migrate_add_session_device_model.py`)
- Upload endpoint: `POST /api/projects/{project_id}/nrl/{location_id}/upload-data`
- ZIP filename format: `{session_label}_{project_name}_report.xlsx` (label first)
### Migration Notes
Run the following migration scripts once per database before deploying:
```bash
python backend/migrate_rename_recording_to_monitoring_sessions.py
python backend/migrate_add_session_period_type.py
python backend/migrate_add_session_device_model.py
```
---
## [0.6.1] - 2026-02-16 ## [0.6.1] - 2026-02-16
### Added ### Added
@@ -445,6 +499,7 @@ No database migration required for v0.4.0. All new features use existing databas
- Photo management per unit - Photo management per unit
- Automated status categorization (OK/Pending/Missing) - Automated status categorization (OK/Pending/Missing)
[0.7.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.6.1...v0.7.0
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0 [0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1 [0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0 [0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0

View File

@@ -1,5 +1,9 @@
FROM python:3.11-slim FROM python:3.11-slim
# Build number for dev builds (injected via --build-arg)
ARG BUILD_NUMBER=0
ENV BUILD_NUMBER=${BUILD_NUMBER}
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app

View File

@@ -1,4 +1,4 @@
# Terra-View v0.6.1 # Terra-View v0.7.0
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features ## Features
@@ -496,6 +496,16 @@ docker compose down -v
## Release Highlights ## Release Highlights
### v0.7.0 — 2026-03-07
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
- **Combined Report Wizard**: Multi-session Excel report generation with location grouping, period type filtering, and ZIP download
- **NL32 Support**: Report generator and web viewer now handle NL32 measurement data
- **Chart Preview**: Live chart preview in the report generator matching final output styling
- **Standalone SLM Mode**: SLMs can now be configured without a paired modem (direct IP)
- **Vibration Project Isolation**: Vibration project views no longer show SLM-specific tabs
- **MonitoringSession Rename**: `RecordingSession` renamed to `MonitoringSession` throughout; run migration before deploying
### v0.6.1 — 2026-02-16 ### v0.6.1 — 2026-02-16
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes - **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically - **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
@@ -584,11 +594,13 @@ MIT
## Version ## Version
**Current: 0.6.1**One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16) **Current: 0.7.0**Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
Previous: 0.6.0Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06) Previous: 0.6.1One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
Previous: 0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27) 0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23) 0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)

View File

@@ -18,7 +18,7 @@ from backend.models import (
MonitoringLocation, MonitoringLocation,
UnitAssignment, UnitAssignment,
ScheduledAction, ScheduledAction,
RecordingSession, MonitoringSession,
DataFile, DataFile,
) )
from datetime import datetime from datetime import datetime

View File

@@ -30,7 +30,11 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.6.1" VERSION = "0.7.0"
if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0":
VERSION = f"{VERSION}-{_build}"
app = FastAPI( app = FastAPI(
title="Seismo Fleet Manager", title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status", description="Backend API for managing seismograph fleet status",
@@ -312,7 +316,7 @@ async def nrl_detail_page(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""NRL (Noise Recording Location) detail page with tabs""" """NRL (Noise Recording Location) detail page with tabs"""
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, MonitoringSession, DataFile
from sqlalchemy import and_ from sqlalchemy import and_
# Get project # Get project
@@ -348,23 +352,33 @@ async def nrl_detail_page(
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
# Get session count # Get session count
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count() session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
# Get file count (DataFile links to session, not directly to location) # Get file count (DataFile links to session, not directly to location)
file_count = db.query(DataFile).join( file_count = db.query(DataFile).join(
RecordingSession, MonitoringSession,
DataFile.session_id == RecordingSession.id DataFile.session_id == MonitoringSession.id
).filter(RecordingSession.location_id == location_id).count() ).filter(MonitoringSession.location_id == location_id).count()
# Check for active session # Check for active session
active_session = db.query(RecordingSession).filter( active_session = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.location_id == location_id, MonitoringSession.location_id == location_id,
RecordingSession.status == "recording" MonitoringSession.status == "recording"
) )
).first() ).first()
return templates.TemplateResponse("nrl_detail.html", { # Parse connection_mode from location_metadata JSON
import json as _json
connection_mode = "connected"
try:
meta = _json.loads(location.location_metadata or "{}")
connection_mode = meta.get("connection_mode", "connected")
except Exception:
pass
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
return templates.TemplateResponse(template, {
"request": request, "request": request,
"project_id": project_id, "project_id": project_id,
"location_id": location_id, "location_id": location_id,
@@ -375,6 +389,7 @@ async def nrl_detail_page(
"session_count": session_count, "session_count": session_count,
"file_count": file_count, "file_count": file_count,
"active_session": active_session, "active_session": active_session,
"connection_mode": connection_mode,
}) })

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Migration: Add data_collection_mode column to projects table.
Values:
"remote" — units have modems; data pulled via FTP/scheduler automatically
"manual" — no modem; SD cards retrieved daily and uploaded by hand
All existing projects are backfilled to "manual" (safe conservative default).
Run once inside the Docker container:
docker exec terra-view python3 backend/migrate_add_project_data_collection_mode.py
"""
from pathlib import Path
DB_PATH = Path("data/seismo_fleet.db")
def migrate():
import sqlite3
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# ── 1. Add column (idempotent) ───────────────────────────────────────────
cur.execute("PRAGMA table_info(projects)")
existing_cols = {row["name"] for row in cur.fetchall()}
if "data_collection_mode" not in existing_cols:
cur.execute("ALTER TABLE projects ADD COLUMN data_collection_mode TEXT DEFAULT 'manual'")
conn.commit()
print("✓ Added column data_collection_mode to projects")
else:
print("○ Column data_collection_mode already exists — skipping ALTER TABLE")
# ── 2. Backfill NULLs to 'manual' ────────────────────────────────────────
cur.execute("UPDATE projects SET data_collection_mode = 'manual' WHERE data_collection_mode IS NULL")
updated = cur.rowcount
conn.commit()
conn.close()
if updated:
print(f"✓ Backfilled {updated} project(s) to data_collection_mode='manual'.")
print("Migration complete.")
if __name__ == "__main__":
migrate()

View File

@@ -0,0 +1,56 @@
"""
Migration: Add deleted_at column to projects table
Adds columns:
- projects.deleted_at: Timestamp set when status='deleted'; data hard-deleted after 60 days
"""
import sqlite3
import sys
from pathlib import Path
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
if not cursor.fetchone():
print("projects table does not exist. Skipping migration.")
return
cursor.execute("PRAGMA table_info(projects)")
existing_cols = {row[1] for row in cursor.fetchall()}
if 'deleted_at' not in existing_cols:
print("Adding deleted_at column to projects...")
cursor.execute("ALTER TABLE projects ADD COLUMN deleted_at DATETIME")
else:
print("deleted_at column already exists. Skipping.")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
db_path = "./data/terra-view.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Migration: Add device_model column to monitoring_sessions table.
Records which physical SLM model produced each session's data (e.g. "NL-43",
"NL-53", "NL-32"). Used by report generation to apply the correct parsing
logic without re-opening files to detect format.
Run once inside the Docker container:
docker exec terra-view python3 backend/migrate_add_session_device_model.py
Backfill strategy for existing rows:
1. If session.unit_id is set, use roster.slm_model for that unit.
2. Else, peek at the first .rnd file in the session: presence of the 'LAeq'
column header identifies AU2 / NL-32 format.
Sessions where neither hint is available remain NULL — the file-content
fallback in report code handles them transparently.
"""
import csv
import io
from pathlib import Path
DB_PATH = Path("data/seismo_fleet.db")
def _peek_first_row(abs_path: Path) -> dict:
"""Read only the header + first data row of an RND file. Very cheap."""
try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
reader = csv.DictReader(f)
return next(reader, None) or {}
except Exception:
return {}
def _detect_model_from_rnd(abs_path: Path) -> str | None:
"""Return 'NL-32' if file uses AU2 column format, else None."""
row = _peek_first_row(abs_path)
if "LAeq" in row:
return "NL-32"
return None
def migrate():
import sqlite3
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# ── 1. Add column (idempotent) ───────────────────────────────────────────
cur.execute("PRAGMA table_info(monitoring_sessions)")
existing_cols = {row["name"] for row in cur.fetchall()}
if "device_model" not in existing_cols:
cur.execute("ALTER TABLE monitoring_sessions ADD COLUMN device_model TEXT")
conn.commit()
print("✓ Added column device_model to monitoring_sessions")
else:
print("○ Column device_model already exists — skipping ALTER TABLE")
# ── 2. Backfill existing NULL rows ───────────────────────────────────────
cur.execute(
"SELECT id, unit_id FROM monitoring_sessions WHERE device_model IS NULL"
)
sessions = cur.fetchall()
print(f"Backfilling {len(sessions)} session(s) with device_model=NULL...")
updated = skipped = 0
for row in sessions:
session_id = row["id"]
unit_id = row["unit_id"]
device_model = None
# Strategy A: look up unit's slm_model from the roster
if unit_id:
cur.execute(
"SELECT slm_model FROM roster WHERE id = ?", (unit_id,)
)
unit_row = cur.fetchone()
if unit_row and unit_row["slm_model"]:
device_model = unit_row["slm_model"]
# Strategy B: detect from first .rnd file in the session
if device_model is None:
cur.execute(
"""SELECT file_path FROM data_files
WHERE session_id = ?
AND lower(file_path) LIKE '%.rnd'
LIMIT 1""",
(session_id,),
)
file_row = cur.fetchone()
if file_row:
abs_path = Path("data") / file_row["file_path"]
device_model = _detect_model_from_rnd(abs_path)
# None here means NL-43/NL-53 format (or unreadable file) —
# leave as NULL so the existing fallback applies.
if device_model:
cur.execute(
"UPDATE monitoring_sessions SET device_model = ? WHERE id = ?",
(device_model, session_id),
)
updated += 1
else:
skipped += 1
conn.commit()
conn.close()
print(f"✓ Backfilled {updated} session(s) with a device_model.")
if skipped:
print(
f" {skipped} session(s) left as NULL "
"(no unit link and no AU2 file hint — NL-43/NL-53 or unknown; "
"file-content detection applies at report time)."
)
print("Migration complete.")
if __name__ == "__main__":
migrate()

View 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()

View File

@@ -0,0 +1,54 @@
"""
Migration: Rename recording_sessions table to monitoring_sessions
Renames the table and updates the model name from RecordingSession to MonitoringSession.
Run once per database: python backend/migrate_rename_recording_to_monitoring_sessions.py
"""
import sqlite3
import sys
from pathlib import Path
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='recording_sessions'")
if not cursor.fetchone():
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='monitoring_sessions'")
if cursor.fetchone():
print("monitoring_sessions table already exists. Skipping migration.")
else:
print("recording_sessions table does not exist. Skipping migration.")
return
print("Renaming recording_sessions -> monitoring_sessions...")
cursor.execute("ALTER TABLE recording_sessions RENAME TO monitoring_sessions")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
db_path = "./data/terra-view.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)

View File

@@ -155,7 +155,12 @@ class Project(Base):
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall") name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
project_type_id = Column(String, nullable=False) # FK to ProjectType.id project_type_id = Column(String, nullable=False) # FK to ProjectType.id
status = Column(String, default="active") # active, completed, archived status = Column(String, default="active") # active, on_hold, completed, archived, deleted
# Data collection mode: how field data reaches Terra-View.
# "remote" — units have modems; data pulled via FTP/scheduler automatically
# "manual" — no modem; SD cards retrieved daily and uploaded by hand
data_collection_mode = Column(String, default="manual") # remote | manual
# Project metadata # Project metadata
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick") client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
@@ -166,6 +171,7 @@ class Project(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
class MonitoringLocation(Base): class MonitoringLocation(Base):
@@ -244,17 +250,21 @@ class ScheduledAction(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
class RecordingSession(Base): class MonitoringSession(Base):
""" """
Recording sessions: tracks actual monitoring sessions. Monitoring sessions: tracks actual monitoring sessions.
Created when recording starts, updated when it stops. Created when monitoring starts, updated when it stops.
""" """
__tablename__ = "recording_sessions" __tablename__ = "monitoring_sessions"
id = Column(String, primary_key=True, index=True) # UUID id = Column(String, primary_key=True, index=True) # UUID
project_id = Column(String, nullable=False, index=True) # FK to Project.id project_id = Column(String, nullable=False, index=True) # FK to Project.id
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
# Physical device model that produced this session's data (e.g. "NL-43", "NL-53", "NL-32").
# Null for older records; report code falls back to file-content detection when null.
device_model = Column(String, nullable=True)
session_type = Column(String, nullable=False) # sound | vibration session_type = Column(String, nullable=False) # sound | vibration
started_at = Column(DateTime, nullable=False) started_at = Column(DateTime, nullable=False)
@@ -262,6 +272,14 @@ class RecordingSession(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
@@ -277,7 +295,7 @@ class DataFile(Base):
__tablename__ = "data_files" __tablename__ = "data_files"
id = Column(String, primary_key=True, index=True) # UUID id = Column(String, primary_key=True, index=True) # UUID
session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id
file_path = Column(String, nullable=False) # Relative to data/Projects/ file_path = Column(String, nullable=False) # Relative to data/Projects/
file_type = Column(String, nullable=False) # wav, csv, mseed, json file_type = Column(String, nullable=False) # wav, csv, mseed, json

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_
from datetime import datetime, timedelta from datetime import datetime, timedelta
from backend.database import get_db from backend.database import get_db
@@ -48,10 +49,18 @@ def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# Exclude actions from paused/removed projects
paused_project_ids = [
p.id for p in db.query(Project.id).filter(
Project.status.in_(["on_hold", "archived", "deleted"])
).all()
]
# Query today's actions # Query today's actions
actions = db.query(ScheduledAction).filter( actions = db.query(ScheduledAction).filter(
ScheduledAction.scheduled_time >= today_start_utc, ScheduledAction.scheduled_time >= today_start_utc,
ScheduledAction.scheduled_time < today_end_utc, ScheduledAction.scheduled_time < today_end_utc,
ScheduledAction.project_id.notin_(paused_project_ids),
).order_by(ScheduledAction.scheduled_time.asc()).all() ).order_by(ScheduledAction.scheduled_time.asc()).all()
# Enrich with location/project info and parse results # Enrich with location/project info and parse results

View File

@@ -14,6 +14,12 @@ from typing import Optional
import uuid import uuid
import json import json
from fastapi import UploadFile, File
import zipfile
import hashlib
import io
from pathlib import Path
from backend.database import get_db from backend.database import get_db
from backend.models import ( from backend.models import (
Project, Project,
@@ -21,13 +27,47 @@ from backend.models import (
MonitoringLocation, MonitoringLocation,
UnitAssignment, UnitAssignment,
RosterUnit, RosterUnit,
RecordingSession, MonitoringSession,
DataFile,
) )
from backend.templates_config import templates 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:0007:00, Day = 07:0022: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
# ============================================================================ # ============================================================================
@@ -70,8 +110,8 @@ async def get_project_locations(
if assignment: if assignment:
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
# Count recording sessions # Count monitoring sessions
session_count = db.query(RecordingSession).filter_by( session_count = db.query(MonitoringSession).filter_by(
location_id=location.id location_id=location.id
).count() ).count()
@@ -370,19 +410,19 @@ async def unassign_unit(
if not assignment: if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
# Check if there are active recording sessions # Check if there are active monitoring sessions
active_sessions = db.query(RecordingSession).filter( active_sessions = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.location_id == assignment.location_id, MonitoringSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id, MonitoringSession.unit_id == assignment.unit_id,
RecordingSession.status == "recording", MonitoringSession.status == "recording",
) )
).count() ).count()
if active_sessions > 0: if active_sessions > 0:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Cannot unassign unit with active recording sessions. Stop recording first.", detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.",
) )
assignment.status = "completed" assignment.status = "completed"
@@ -451,14 +491,12 @@ async def get_nrl_sessions(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Get recording sessions for a specific NRL. Get monitoring sessions for a specific NRL.
Returns HTML partial with session list. Returns HTML partial with session list.
""" """
from backend.models import RecordingSession, RosterUnit sessions = db.query(MonitoringSession).filter_by(
sessions = db.query(RecordingSession).filter_by(
location_id=location_id location_id=location_id
).order_by(RecordingSession.started_at.desc()).all() ).order_by(MonitoringSession.started_at.desc()).all()
# Enrich with unit details # Enrich with unit details
sessions_data = [] sessions_data = []
@@ -491,14 +529,12 @@ async def get_nrl_files(
Get data files for a specific NRL. Get data files for a specific NRL.
Returns HTML partial with file list. Returns HTML partial with file list.
""" """
from backend.models import DataFile, RecordingSession # Join DataFile with MonitoringSession to filter by location_id
# Join DataFile with RecordingSession to filter by location_id
files = db.query(DataFile).join( files = db.query(DataFile).join(
RecordingSession, MonitoringSession,
DataFile.session_id == RecordingSession.id DataFile.session_id == MonitoringSession.id
).filter( ).filter(
RecordingSession.location_id == location_id MonitoringSession.location_id == location_id
).order_by(DataFile.created_at.desc()).all() ).order_by(DataFile.created_at.desc()).all()
# Enrich with session details # Enrich with session details
@@ -506,7 +542,7 @@ async def get_nrl_files(
for file in files: for file in files:
session = None session = None
if file.session_id: if file.session_id:
session = db.query(RecordingSession).filter_by(id=file.session_id).first() session = db.query(MonitoringSession).filter_by(id=file.session_id).first()
files_data.append({ files_data.append({
"file": file, "file": file,
@@ -519,3 +555,310 @@ async def get_nrl_files(
"location_id": location_id, "location_id": location_id,
"files": files_data, "files": files_data,
}) })
# ============================================================================
# Manual SD Card Data Upload
# ============================================================================
def _parse_rnh(content: bytes) -> dict:
"""
Parse a Rion .rnh metadata file (INI-style with [Section] headers).
Returns a dict of key metadata fields.
"""
result = {}
try:
text = content.decode("utf-8", errors="replace")
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("["):
continue
if "," in line:
key, _, value = line.partition(",")
key = key.strip()
value = value.strip()
if key == "Serial Number":
result["serial_number"] = value
elif key == "Store Name":
result["store_name"] = value
elif key == "Index Number":
result["index_number"] = value
elif key == "Measurement Start Time":
result["start_time_str"] = value
elif key == "Measurement Stop Time":
result["stop_time_str"] = value
elif key == "Total Measurement Time":
result["total_time_str"] = value
except Exception:
pass
return result
def _parse_rnh_datetime(s: str):
"""Parse RNH datetime string: '2026/02/17 19:00:19' -> datetime"""
from datetime import datetime
if not s:
return None
try:
return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
except Exception:
return None
def _classify_file(filename: str) -> str:
"""Classify a file by name into a DataFile file_type."""
name = filename.lower()
if name.endswith(".rnh"):
return "log"
if name.endswith(".rnd"):
return "measurement"
if name.endswith(".zip"):
return "archive"
return "data"
@router.post("/nrl/{location_id}/upload-data")
async def upload_nrl_data(
project_id: str,
location_id: str,
db: Session = Depends(get_db),
files: list[UploadFile] = File(...),
):
"""
Manually upload SD card data for an offline NRL.
Accepts either:
- A single .zip file (the Auto_#### folder zipped) — auto-extracted
- Multiple .rnd / .rnh files selected directly from the SD card folder
Creates a MonitoringSession from .rnh metadata and DataFile records
for each measurement file. No unit assignment required.
"""
from datetime import datetime
# Verify project and location exist
location = db.query(MonitoringLocation).filter_by(
id=location_id, project_id=project_id
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
# --- Step 1: Normalize to (filename, bytes) list ---
file_entries: list[tuple[str, bytes]] = []
if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
raw = await files[0].read()
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
for info in zf.infolist():
if info.is_dir():
continue
name = Path(info.filename).name # strip folder path
if not name:
continue
file_entries.append((name, zf.read(info)))
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.")
else:
for uf in files:
data = await uf.read()
file_entries.append((uf.filename, data))
if not file_entries:
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 ---
rnh_meta = {}
for fname, fbytes in file_entries:
if fname.lower().endswith(".rnh"):
rnh_meta = _parse_rnh(fbytes)
break
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
duration_seconds = None
if started_at and stopped_at:
duration_seconds = int((stopped_at - started_at).total_seconds())
store_name = rnh_meta.get("store_name", "")
serial_number = rnh_meta.get("serial_number", "")
index_number = rnh_meta.get("index_number", "")
# --- 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())
monitoring_session = MonitoringSession(
id=session_id,
project_id=project_id,
location_id=location_id,
unit_id=None,
session_type="sound",
started_at=started_at,
stopped_at=stopped_at,
duration_seconds=duration_seconds,
status="completed",
session_label=session_label,
period_type=period_type,
session_metadata=json.dumps({
"source": "manual_upload",
"store_name": store_name,
"serial_number": serial_number,
"index_number": index_number,
}),
)
db.add(monitoring_session)
db.commit()
db.refresh(monitoring_session)
# --- Step 4: Write files to disk and create DataFile records ---
output_dir = Path("data/Projects") / project_id / session_id
output_dir.mkdir(parents=True, exist_ok=True)
leq_count = 0
lp_count = 0
metadata_count = 0
files_imported = 0
for fname, fbytes in file_entries:
file_type = _classify_file(fname)
fname_lower = fname.lower()
# Track counts for summary
if fname_lower.endswith(".rnd"):
if "_leq_" in fname_lower:
leq_count += 1
elif "_lp" in fname_lower:
lp_count += 1
elif fname_lower.endswith(".rnh"):
metadata_count += 1
# Write to disk
dest = output_dir / fname
dest.write_bytes(fbytes)
# Compute checksum
checksum = hashlib.sha256(fbytes).hexdigest()
# Store relative path from data/ dir
rel_path = str(dest.relative_to("data"))
data_file = DataFile(
id=str(uuid.uuid4()),
session_id=session_id,
file_path=rel_path,
file_type=file_type,
file_size_bytes=len(fbytes),
downloaded_at=datetime.utcnow(),
checksum=checksum,
file_metadata=json.dumps({
"source": "manual_upload",
"original_filename": fname,
"store_name": store_name,
}),
)
db.add(data_file)
files_imported += 1
db.commit()
return {
"success": True,
"session_id": session_id,
"files_imported": files_imported,
"leq_files": leq_count,
"lp_files": lp_count,
"metadata_files": metadata_count,
"store_name": store_name,
"started_at": started_at.isoformat() if started_at else None,
"stopped_at": stopped_at.isoformat() if stopped_at else None,
}
# ============================================================================
# NRL Live Status (connected NRLs only)
# ============================================================================
@router.get("/nrl/{location_id}/live-status", response_class=HTMLResponse)
async def get_nrl_live_status(
project_id: str,
location_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Fetch cached status from SLMM for the unit assigned to this NRL and
return a compact HTML status card. Used in the NRL overview tab for
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
"""
import os
import httpx
# Find the assigned unit
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
)
).first()
if not assignment:
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
"request": request,
"status": None,
"error": "No unit assigned",
})
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if not unit:
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
"request": request,
"status": None,
"error": "Assigned unit not found",
})
slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
status_data = None
error_msg = None
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{slmm_base}/api/nl43/{unit.id}/status")
if resp.status_code == 200:
status_data = resp.json()
else:
error_msg = f"SLMM returned {resp.status_code}"
except Exception as e:
error_msg = "SLMM unreachable"
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
"request": request,
"unit": unit,
"status": status_data,
"error": error_msg,
})

File diff suppressed because it is too large Load Diff

View File

@@ -497,6 +497,9 @@ async def get_schedule_list_partial(
""" """
Return HTML partial for schedule list. Return HTML partial for schedule list.
""" """
project = db.query(Project).filter_by(id=project_id).first()
project_status = project.status if project else "active"
schedules = db.query(RecurringSchedule).filter_by( schedules = db.query(RecurringSchedule).filter_by(
project_id=project_id project_id=project_id
).order_by(RecurringSchedule.created_at.desc()).all() ).order_by(RecurringSchedule.created_at.desc()).all()
@@ -515,4 +518,5 @@ async def get_schedule_list_partial(
"request": request, "request": request,
"project_id": project_id, "project_id": project_id,
"schedules": schedule_data, "schedules": schedule_data,
"project_status": project_status,
}) })

View File

@@ -92,15 +92,15 @@ async def rename_unit(
except Exception as e: except Exception as e:
logger.warning(f"Could not update unit_assignments: {e}") logger.warning(f"Could not update unit_assignments: {e}")
# Update recording_sessions table (if exists) # Update monitoring_sessions table (if exists)
try: try:
from backend.models import RecordingSession from backend.models import MonitoringSession
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update( db.query(MonitoringSession).filter(MonitoringSession.unit_id == old_id).update(
{"unit_id": new_id}, {"unit_id": new_id},
synchronize_session=False synchronize_session=False
) )
except Exception as e: except Exception as e:
logger.warning(f"Could not update recording_sessions: {e}") logger.warning(f"Could not update monitoring_sessions: {e}")
# Commit all changes # Commit all changes
db.commit() db.commit()

View File

@@ -15,7 +15,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_ from sqlalchemy import and_
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment, Project
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -332,10 +332,12 @@ class RecurringScheduleService:
) )
actions.append(start_action) actions.append(start_action)
# Create STOP action # Create STOP action (stop_cycle handles download when include_download is True)
stop_notes = json.dumps({ stop_notes = json.dumps({
"schedule_name": schedule.name, "schedule_name": schedule.name,
"schedule_id": schedule.id, "schedule_id": schedule.id,
"schedule_type": "weekly_calendar",
"include_download": schedule.include_download,
}) })
stop_action = ScheduledAction( stop_action = ScheduledAction(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -350,27 +352,6 @@ class RecurringScheduleService:
) )
actions.append(stop_action) actions.append(stop_action)
# Create DOWNLOAD action if enabled (1 minute after stop)
if schedule.include_download:
download_time = end_utc + timedelta(minutes=1)
download_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"schedule_type": "weekly_calendar",
})
download_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="download",
device_type=schedule.device_type,
scheduled_time=download_time,
execution_status="pending",
notes=download_notes,
)
actions.append(download_action)
return actions return actions
def _generate_interval_actions( def _generate_interval_actions(
@@ -613,8 +594,16 @@ class RecurringScheduleService:
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all() return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
def get_enabled_schedules(self) -> List[RecurringSchedule]: def get_enabled_schedules(self) -> List[RecurringSchedule]:
"""Get all enabled recurring schedules.""" """Get all enabled recurring schedules for projects that are not on hold or deleted."""
return self.db.query(RecurringSchedule).filter_by(enabled=True).all() active_project_ids = [
p.id for p in self.db.query(Project.id).filter(
Project.status.notin_(["on_hold", "archived", "deleted"])
).all()
]
return self.db.query(RecurringSchedule).filter(
RecurringSchedule.enabled == True,
RecurringSchedule.project_id.in_(active_project_ids),
).all()
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService: def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:

View File

@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import and_ from sqlalchemy import and_
from backend.database import SessionLocal from backend.database import SessionLocal
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule
from backend.services.device_controller import get_device_controller, DeviceControllerError from backend.services.device_controller import get_device_controller, DeviceControllerError
from backend.services.alert_service import get_alert_service from backend.services.alert_service import get_alert_service
import uuid import uuid
@@ -107,10 +107,19 @@ class SchedulerService:
try: try:
# Find pending actions that are due # Find pending actions that are due
now = datetime.utcnow() now = datetime.utcnow()
# Only execute actions for active/completed projects (not on_hold, archived, or deleted)
active_project_ids = [
p.id for p in db.query(Project.id).filter(
Project.status.notin_(["on_hold", "archived", "deleted"])
).all()
]
pending_actions = db.query(ScheduledAction).filter( pending_actions = db.query(ScheduledAction).filter(
and_( and_(
ScheduledAction.execution_status == "pending", ScheduledAction.execution_status == "pending",
ScheduledAction.scheduled_time <= now, ScheduledAction.scheduled_time <= now,
ScheduledAction.project_id.in_(active_project_ids),
) )
).order_by(ScheduledAction.scheduled_time).all() ).order_by(ScheduledAction.scheduled_time).all()
@@ -263,7 +272,7 @@ class SchedulerService:
) )
# Create recording session # Create recording session
session = RecordingSession( session = MonitoringSession(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=action.project_id, project_id=action.project_id,
location_id=action.location_id, location_id=action.location_id,
@@ -295,9 +304,20 @@ class SchedulerService:
stop_cycle handles: stop_cycle handles:
1. Stop measurement 1. Stop measurement
2. Enable FTP 2. Enable FTP
3. Download measurement folder 3. Download measurement folder to SLMM local storage
4. Verify download
After stop_cycle, if download succeeded, this method fetches the ZIP
from SLMM and extracts it into Terra-View's project directory, creating
DataFile records for each file.
""" """
import hashlib
import io
import os
import zipfile
import httpx
from pathlib import Path
from backend.models import DataFile
# Parse notes for download preference # Parse notes for download preference
include_download = True include_download = True
try: try:
@@ -308,7 +328,7 @@ class SchedulerService:
pass # Notes is plain text, not JSON pass # Notes is plain text, not JSON
# Execute the full stop cycle via device controller # Execute the full stop cycle via device controller
# SLMM handles stop, FTP enable, and download # SLMM handles stop, FTP enable, and download to SLMM-local storage
cycle_response = await self.device_controller.stop_cycle( cycle_response = await self.device_controller.stop_cycle(
unit_id, unit_id,
action.device_type, action.device_type,
@@ -316,11 +336,11 @@ class SchedulerService:
) )
# Find and update the active recording session # Find and update the active recording session
active_session = db.query(RecordingSession).filter( active_session = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.location_id == action.location_id, MonitoringSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id, MonitoringSession.unit_id == unit_id,
RecordingSession.status == "recording", MonitoringSession.status == "recording",
) )
).first() ).first()
@@ -340,10 +360,81 @@ class SchedulerService:
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
db.commit()
# If SLMM downloaded the folder successfully, fetch the ZIP from SLMM
# and extract it into Terra-View's project directory, creating DataFile records
files_created = 0
if include_download and cycle_response.get("download_success") and active_session:
folder_name = cycle_response.get("downloaded_folder") # e.g. "Auto_0058"
remote_path = f"/NL-43/{folder_name}"
try:
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
async with httpx.AsyncClient(timeout=600.0) as client:
zip_response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder",
json={"remote_path": remote_path}
)
if zip_response.is_success and len(zip_response.content) > 22:
base_dir = Path(f"data/Projects/{action.project_id}/{active_session.id}/{folder_name}")
base_dir.mkdir(parents=True, exist_ok=True)
file_type_map = {
'.wav': 'audio', '.mp3': 'audio',
'.csv': 'data', '.txt': 'data', '.json': 'data', '.dat': 'data',
'.rnd': 'data', '.rnh': 'data',
'.log': 'log',
'.zip': 'archive',
'.jpg': 'image', '.jpeg': 'image', '.png': 'image',
'.pdf': 'document',
}
with zipfile.ZipFile(io.BytesIO(zip_response.content)) as zf:
for zip_info in zf.filelist:
if zip_info.is_dir():
continue
file_data = zf.read(zip_info.filename)
file_path = base_dir / zip_info.filename
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, 'wb') as f:
f.write(file_data)
checksum = hashlib.sha256(file_data).hexdigest()
ext = os.path.splitext(zip_info.filename)[1].lower()
data_file = DataFile(
id=str(uuid.uuid4()),
session_id=active_session.id,
file_path=str(file_path.relative_to("data")),
file_type=file_type_map.get(ext, 'data'),
file_size_bytes=len(file_data),
downloaded_at=datetime.utcnow(),
checksum=checksum,
file_metadata=json.dumps({
"source": "stop_cycle",
"remote_path": remote_path,
"unit_id": unit_id,
"folder_name": folder_name,
"relative_path": zip_info.filename,
}),
)
db.add(data_file)
files_created += 1
db.commit()
logger.info(f"Created {files_created} DataFile records for session {active_session.id} from {folder_name}")
else:
logger.warning(f"ZIP from SLMM for {folder_name} was empty or failed, skipping DataFile creation")
except Exception as e:
logger.error(f"Failed to extract ZIP and create DataFile records for {folder_name}: {e}")
# Don't fail the stop action — the device was stopped successfully
return { return {
"status": "stopped", "status": "stopped",
"session_id": active_session.id if active_session else None, "session_id": active_session.id if active_session else None,
"cycle_response": cycle_response, "cycle_response": cycle_response,
"files_created": files_created,
} }
async def _execute_download( async def _execute_download(
@@ -526,11 +617,11 @@ class SchedulerService:
result["steps"]["download"] = {"success": False, "error": "Project or location not found"} result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
# Close out the old recording session # Close out the old recording session
active_session = db.query(RecordingSession).filter( active_session = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.location_id == action.location_id, MonitoringSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id, MonitoringSession.unit_id == unit_id,
RecordingSession.status == "recording", MonitoringSession.status == "recording",
) )
).first() ).first()
@@ -557,7 +648,7 @@ class SchedulerService:
result["steps"]["start"] = {"success": True, "response": cycle_response} result["steps"]["start"] = {"success": True, "response": cycle_response}
# Create new recording session # Create new recording session
new_session = RecordingSession( new_session = MonitoringSession(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=action.project_id, project_id=action.project_id,
location_id=action.location_id, location_id=action.location_id,

View File

@@ -659,7 +659,7 @@ class SLMMClient:
# Format as Auto_XXXX folder name # Format as Auto_XXXX folder name
folder_name = f"Auto_{index_number:04d}" folder_name = f"Auto_{index_number:04d}"
remote_path = f"/NL43_DATA/{folder_name}" remote_path = f"/NL-43/{folder_name}"
# Download the folder # Download the folder
result = await self.download_folder(unit_id, remote_path) result = await self.download_folder(unit_id, remote_path)

View File

@@ -5,6 +5,7 @@ All routers should import `templates` from this module to get consistent
filter and global function registration. filter and global function registration.
""" """
import json as _json
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
# Import timezone utilities # Import timezone utilities
@@ -32,8 +33,38 @@ def jinja_timezone_abbr():
# Create templates instance # Create templates instance
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
def jinja_local_date(dt, fmt="%m-%d-%y"):
"""Jinja filter: format a UTC datetime as a local date string (e.g. 02-19-26)."""
return format_local_datetime(dt, fmt)
def jinja_fromjson(s):
"""Jinja filter: parse a JSON string into a dict (returns {} on failure)."""
if not s:
return {}
try:
return _json.loads(s)
except Exception:
return {}
def jinja_same_date(dt1, dt2) -> bool:
"""Jinja global: True if two datetimes fall on the same local date."""
if not dt1 or not dt2:
return False
try:
d1 = format_local_datetime(dt1, "%Y-%m-%d")
d2 = format_local_datetime(dt2, "%Y-%m-%d")
return d1 == d2
except Exception:
return False
# Register Jinja filters and globals # Register Jinja filters and globals
templates.env.filters["local_datetime"] = jinja_local_datetime templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time templates.env.filters["local_time"] = jinja_local_time
templates.env.filters["local_date"] = jinja_local_date
templates.env.filters["fromjson"] = jinja_fromjson
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone templates.env.globals["get_user_timezone"] = get_user_timezone
templates.env.globals["same_date"] = jinja_same_date

View File

@@ -1,10 +0,0 @@
{
"filename": "snapshot_20251216_201738.db",
"created_at": "20251216_201738",
"created_at_iso": "2025-12-16T20:17:38.638982",
"description": "Auto-backup before restore",
"size_bytes": 57344,
"size_mb": 0.05,
"original_db_size_bytes": 57344,
"type": "manual"
}

View File

@@ -1,9 +0,0 @@
{
"filename": "snapshot_uploaded_20251216_201732.db",
"created_at": "20251216_201732",
"created_at_iso": "2025-12-16T20:17:32.574205",
"description": "Uploaded: snapshot_20251216_200259.db",
"size_bytes": 77824,
"size_mb": 0.07,
"type": "uploaded"
}

View File

@@ -24,30 +24,6 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
# --- TERRA-VIEW DEVELOPMENT ---
terra-view-dev:
build: .
container_name: terra-view-dev
ports:
- "1001:8001"
volumes:
- ./data-dev:/app/data
environment:
- PYTHONUNBUFFERED=1
- ENVIRONMENT=development
- SLMM_BASE_URL=http://host.docker.internal:8100
restart: unless-stopped
depends_on:
- slmm
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# --- SLMM (Sound Level Meter Manager) --- # --- SLMM (Sound Level Meter Manager) ---
slmm: slmm:
build: build:
@@ -61,6 +37,8 @@ services:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- PORT=8100 - PORT=8100
- CORS_ORIGINS=* - CORS_ORIGINS=*
- TCP_IDLE_TTL=-1
- TCP_MAX_AGE=-1
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8100/health"] test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
@@ -71,4 +49,3 @@ services:
volumes: volumes:
data: data:
data-dev:

19
rebuild-dev.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Dev rebuild script — increments build number, rebuilds and restarts terra-view
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_FILE="$SCRIPT_DIR/build_number.txt"
# Read and increment build number
BUILD_NUMBER=$(cat "$BUILD_FILE" 2>/dev/null || echo "0")
BUILD_NUMBER=$((BUILD_NUMBER + 1))
echo "$BUILD_NUMBER" > "$BUILD_FILE"
echo "Building terra-view dev (build #$BUILD_NUMBER)..."
cd "$SCRIPT_DIR"
docker compose build --build-arg BUILD_NUMBER="$BUILD_NUMBER" terra-view
docker compose up -d terra-view
echo "Done — terra-view v0.6.1-$BUILD_NUMBER is running on :1001"

12
rebuild-prod.sh Normal file
View 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"

View File

@@ -90,14 +90,14 @@ def rename_unit(old_id: str, new_id: str):
except Exception: except Exception:
pass # Table may not exist pass # Table may not exist
# Update recording_sessions table (if exists) # Update monitoring_sessions table (if exists)
try: try:
result = session.execute( result = session.execute(
text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"), text("UPDATE monitoring_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
{"new_id": new_id, "old_id": old_id} {"new_id": new_id, "old_id": old_id}
) )
if result.rowcount > 0: if result.rowcount > 0:
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)") print(f" ✓ Updated monitoring_sessions ({result.rowcount} rows)")
except Exception: except Exception:
pass # Table may not exist pass # Table may not exist

View File

@@ -0,0 +1,315 @@
{% extends "base.html" %}
{% block title %}Combined Report Preview - {{ project.name }}{% endblock %}
{% block content %}
<!-- jspreadsheet CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/jspreadsheet.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.css" />
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
<!-- Header -->
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Combined Report Preview & Editor</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ location_data|length }} location{{ 's' if location_data|length != 1 else '' }}
{% if time_filter_desc %} | {{ time_filter_desc }}{% endif %}
| {{ total_rows }} total row{{ 's' if total_rows != 1 else '' }}
</p>
</div>
<div class="flex items-center gap-3">
<button onclick="downloadCombinedReport()" id="download-btn"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm font-medium">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Generate Reports (ZIP)
</button>
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
← Back to Config
</a>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
<!-- Report Metadata -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report Title</label>
<input type="text" id="edit-report-title" value="{{ report_title }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
<input type="text" id="edit-project-name" value="{{ project_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client Name</label>
<input type="text" id="edit-client-name" value="{{ client_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
</div>
</div>
<!-- Location Tabs + Spreadsheet -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<!-- Tab Bar -->
<div class="border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
<div class="flex min-w-max" id="tab-bar">
{% for loc in location_data %}
<button onclick="switchTab({{ loop.index0 }})"
id="tab-btn-{{ loop.index0 }}"
class="tab-btn px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
{% if loop.first %}border-emerald-500 text-emerald-600 dark:text-emerald-400
{% else %}border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300{% endif %}">
{{ loc.location_name }}
<span class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full
{% if loop.first %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400
{% else %}bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400{% endif %}"
id="tab-count-{{ loop.index0 }}">
{{ loc.filtered_count }}
</span>
</button>
{% endfor %}
</div>
</div>
<!-- Spreadsheet Panels -->
<div class="p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-semibold text-gray-900 dark:text-white" id="active-tab-title">
{{ location_data[0].location_name if location_data else '' }}
</h3>
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Right-click for options</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>Double-click to edit</span>
</div>
</div>
{% for loc in location_data %}
<div id="panel-{{ loop.index0 }}" class="tab-panel {% if not loop.first %}hidden{% endif %} overflow-x-auto">
<div id="spreadsheet-{{ loop.index0 }}"></div>
</div>
{% endfor %}
</div>
</div>
<!-- Help -->
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">Editing Tips</h3>
<ul class="text-sm text-blue-700 dark:text-blue-400 list-disc list-inside space-y-1">
<li>Double-click any cell to edit its value</li>
<li>Use the Comments column to add notes about specific measurements</li>
<li>Right-click a row to insert or delete rows</li>
<li>Press Enter to confirm edits, Escape to cancel</li>
<li>Switch between location tabs to edit each location's data independently</li>
</ul>
</div>
</div>
</div>
<!-- jspreadsheet JS -->
<script src="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/index.min.js"></script>
<script>
const allLocationData = {{ locations_json | safe }};
const spreadsheets = {};
let activeTabIdx = 0;
const columnDef = [
{ title: 'Test #', width: 80, type: 'numeric' },
{ title: 'Date', width: 110, type: 'text' },
{ title: 'Time', width: 90, type: 'text' },
{ title: 'LAmax (dBA)', width: 110, type: 'numeric' },
{ title: 'LA01 (dBA)', width: 110, type: 'numeric' },
{ title: 'LA10 (dBA)', width: 110, type: 'numeric' },
{ title: 'Comments', width: 250, type: 'text' },
];
const jssOptions = {
columns: columnDef,
allowInsertRow: true,
allowDeleteRow: true,
allowInsertColumn: false,
allowDeleteColumn: false,
rowDrag: true,
columnSorting: true,
search: true,
pagination: 50,
paginationOptions: [25, 50, 100, 200],
defaultColWidth: 100,
minDimensions: [7, 1],
tableOverflow: true,
tableWidth: '100%',
contextMenu: function(instance, col, row, e) {
const items = [];
if (row !== null) {
items.push({
title: 'Insert row above',
onclick: function() { instance.insertRow(1, row, true); }
});
items.push({
title: 'Insert row below',
onclick: function() { instance.insertRow(1, row + 1, false); }
});
items.push({
title: 'Delete this row',
onclick: function() { instance.deleteRow(row); }
});
}
return items;
},
style: {
A: 'text-align: center;',
B: 'text-align: center;',
C: 'text-align: center;',
D: 'text-align: right;',
E: 'text-align: right;',
F: 'text-align: right;',
}
};
document.addEventListener('DOMContentLoaded', function() {
allLocationData.forEach(function(loc, idx) {
const el = document.getElementById('spreadsheet-' + idx);
if (!el) return;
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
spreadsheets[idx] = jspreadsheet(el, opts);
});
if (allLocationData.length > 0) {
switchTab(0);
}
});
function switchTab(idx) {
activeTabIdx = idx;
// Update panels
document.querySelectorAll('.tab-panel').forEach(function(panel, i) {
panel.classList.toggle('hidden', i !== idx);
});
// Update tab button styles
document.querySelectorAll('.tab-btn').forEach(function(btn, i) {
const countBadge = document.getElementById('tab-count-' + i);
if (i === idx) {
btn.classList.add('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
if (countBadge) {
countBadge.classList.add('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
countBadge.classList.remove('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
}
} else {
btn.classList.remove('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
if (countBadge) {
countBadge.classList.remove('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
countBadge.classList.add('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
}
}
});
// Update title
if (allLocationData[idx]) {
document.getElementById('active-tab-title').textContent = allLocationData[idx].location_name;
}
// Refresh jspreadsheet rendering after showing panel
if (spreadsheets[idx]) {
try { spreadsheets[idx].updateTable(); } catch(e) {}
}
}
async function downloadCombinedReport() {
const btn = document.getElementById('download-btn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating ZIP...';
try {
const locations = allLocationData.map(function(loc, idx) {
return {
session_id: loc.session_id || '',
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,
};
});
const payload = {
report_title: document.getElementById('edit-report-title').value || 'Background Noise Study',
project_name: document.getElementById('edit-project-name').value || '',
client_name: document.getElementById('edit-client-name').value || '',
locations: locations,
};
const response = await fetch('/api/projects/{{ project_id }}/generate-combined-from-preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'combined_reports.zip';
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) filename = match[1];
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} else {
const error = await response.json();
alert('Error generating report: ' + (error.detail || 'Unknown error'));
}
} catch (error) {
alert('Error generating report: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
</script>
<style>
/* Dark mode jspreadsheet styles */
.dark .jexcel { background-color: #1e293b; color: #e2e8f0; }
.dark .jexcel thead td { background-color: #334155 !important; color: #e2e8f0 !important; border-color: #475569 !important; }
.dark .jexcel tbody td { background-color: #1e293b; color: #e2e8f0; border-color: #475569; }
.dark .jexcel tbody td:hover { background-color: #334155; }
.dark .jexcel tbody tr:nth-child(even) td { background-color: #0f172a; }
.dark .jexcel_pagination { background-color: #1e293b; color: #e2e8f0; border-color: #475569; }
.dark .jexcel_pagination a { color: #e2e8f0; }
.dark .jexcel_search { background-color: #1e293b; color: #e2e8f0; border-color: #475569; }
.dark .jexcel_search input { background-color: #334155; color: #e2e8f0; border-color: #475569; }
.dark .jexcel_content { background-color: #1e293b; }
.dark .jexcel_contextmenu { background-color: #1e293b; border-color: #475569; }
.dark .jexcel_contextmenu a { color: #e2e8f0; }
.dark .jexcel_contextmenu a:hover { background-color: #334155; }
.jexcel_content { max-height: 600px; overflow: auto; }
</style>
{% endblock %}

View File

@@ -0,0 +1,393 @@
{% extends "base.html" %}
{% block title %}Combined Report Wizard - {{ project.name }}{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
<!-- Header -->
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Combined Report Wizard</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ project.name }}</p>
</div>
<a href="/projects/{{ project_id }}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm w-fit">
← Back to Project
</a>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
<!-- Report Settings Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Report Settings</h2>
<!-- Template Selection -->
<div class="flex items-end gap-2 mb-4">
<div class="flex-1">
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Load Template
</label>
<select id="template-select" onchange="applyTemplate()"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
<option value="">-- Select a template --</option>
</select>
</div>
<button type="button" onclick="saveAsTemplate()"
class="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
title="Save current settings as template">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
</svg>
</button>
</div>
<!-- Report Title -->
<div class="mb-4">
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Report Title
</label>
<input type="text" id="report-title" value="Background Noise Study"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<!-- Project and Client -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project Name
</label>
<input type="text" id="report-project" value="{{ project.name }}"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="report-client" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client Name
</label>
<input type="text" id="report-client" value="{{ project.client_name if project.client_name else '' }}"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
</div>
<!-- Sessions Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 overflow-hidden">
<div class="flex items-center justify-between mb-1">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
<div class="flex gap-3 text-sm">
<button type="button" onclick="selectAllSessions()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button>
<button type="button" onclick="deselectAllSessions()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
</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 %}
{% for loc in locations %}
{% set loc_name = loc.name %}
{% set sessions = loc.sessions %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg mb-3 overflow-hidden">
<!-- Location header / toggle -->
<button type="button"
onclick="toggleLocation('loc-{{ loop.index }}')"
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 %}
{% else %}
<div class="text-center py-10 text-gray-500 dark:text-gray-400">
<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">
<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>
{% endif %}
</div>
<!-- Footer Buttons -->
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
<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">
Cancel
</a>
<button type="button" onclick="gotoPreview()" id="preview-btn"
{% if not locations %}disabled{% endif %}
class="w-full sm:w-auto px-6 py-2.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
Preview & Edit →
</button>
</div>
</div>
</div>
<script>
const PROJECT_ID = '{{ project_id }}';
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() {
try {
const resp = await fetch('/api/report-templates?project_id=' + PROJECT_ID);
if (resp.ok) {
reportTemplates = await resp.json();
populateTemplateDropdown();
}
} catch(e) { console.error('Error loading templates:', e); }
}
function populateTemplateDropdown() {
const select = document.getElementById('template-select');
if (!select) return;
select.innerHTML = '<option value="">-- Select a template --</option>';
reportTemplates.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
opt.dataset.config = JSON.stringify(t);
select.appendChild(opt);
});
}
function applyTemplate() {
const select = document.getElementById('template-select');
const opt = select.options[select.selectedIndex];
if (!opt.value) return;
const t = JSON.parse(opt.dataset.config);
if (t.report_title) document.getElementById('report-title').value = t.report_title;
}
async function saveAsTemplate() {
const name = prompt('Enter a name for this template:');
if (!name) return;
const data = {
name,
project_id: PROJECT_ID,
report_title: document.getElementById('report-title').value || 'Background Noise Study',
};
try {
const resp = await fetch('/api/report-templates', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
if (resp.ok) { alert('Template saved!'); loadTemplates(); }
else alert('Failed to save template');
} catch(e) { alert('Error: ' + e.message); }
}
// ── Navigate to preview ───────────────────────────────────────────
function gotoPreview() {
const checked = Array.from(document.querySelectorAll('.session-cb:checked')).map(cb => cb.value);
if (checked.length === 0) {
alert('Please select at least one session.');
return;
}
const params = new URLSearchParams({
report_title: document.getElementById('report-title').value || 'Background Noise Study',
project_name: document.getElementById('report-project').value || '',
client_name: document.getElementById('report-client').value || '',
selected_sessions: checked.join(','),
});
window.location.href = `/api/projects/${PROJECT_ID}/combined-report-preview?${params.toString()}`;
}
// ── Init ─────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function() {
updateSelectionStats();
loadTemplates();
});
</script>
{% endblock %}

View File

@@ -70,7 +70,7 @@
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Settings Settings
</button> </button>
{% if assigned_unit %} {% if assigned_unit and connection_mode == 'connected' %}
<button onclick="switchTab('command')" <button onclick="switchTab('command')"
data-tab="command" data-tab="command"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
@@ -80,7 +80,7 @@
<button onclick="switchTab('sessions')" <button onclick="switchTab('sessions')"
data-tab="sessions" data-tab="sessions"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Recording Sessions Monitoring Sessions
</button> </button>
<button onclick="switchTab('data')" <button onclick="switchTab('data')"
data-tab="data" data-tab="data"
@@ -214,23 +214,54 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
{% if connection_mode == 'connected' %}
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p> <p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2"> <p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
{% if active_session %} {% if active_session %}
<span class="text-green-600 dark:text-green-400">Recording</span> <span class="text-green-600 dark:text-green-400">Monitoring</span>
{% else %} {% else %}
<span class="text-gray-500">Idle</span> <span class="text-gray-500">Idle</span>
{% endif %} {% endif %}
</p> </p>
{% else %}
<p class="text-sm text-gray-600 dark:text-gray-400">Mode</p>
<p class="text-lg font-semibold mt-2">
<span class="text-amber-600 dark:text-amber-400">Offline / Manual</span>
</p>
{% endif %}
</div> </div>
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
{% if connection_mode == 'connected' %}
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-purple-600 dark:text-purple-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> <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> </svg>
{% else %}
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" 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-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% if connection_mode == 'connected' and assigned_unit %}
<!-- Live Status Row (connected NRLs only) -->
<div class="mt-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Live Status</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ assigned_unit.id }}</span>
</div>
<div id="nrl-live-status"
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/live-status"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="text-center py-4 text-gray-500 text-sm">Loading status&hellip;</div>
</div>
</div>
</div>
{% endif %}
</div> </div>
<!-- Settings Tab --> <!-- Settings Tab -->
@@ -281,8 +312,8 @@
</div> </div>
</div> </div>
<!-- Command Center Tab --> <!-- Command Center Tab (connected NRLs only) -->
{% if assigned_unit %} {% if assigned_unit and connection_mode == 'connected' %}
<div id="command-tab" class="tab-panel hidden"> <div id="command-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
@@ -302,11 +333,11 @@
</div> </div>
{% endif %} {% endif %}
<!-- Recording Sessions Tab --> <!-- Monitoring Sessions Tab -->
<div id="sessions-tab" class="tab-panel hidden"> <div id="sessions-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
{% if assigned_unit %} {% if assigned_unit %}
<button onclick="openScheduleModal()" <button onclick="openScheduleModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"> class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
@@ -329,8 +360,51 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
<div class="text-sm text-gray-500"> <div class="flex items-center gap-3">
<span class="font-medium">{{ file_count }}</span> files <span class="text-sm text-gray-500"><span class="font-medium">{{ file_count }}</span> files</span>
<button onclick="toggleUploadPanel()"
class="px-3 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
Upload Data
</button>
</div>
</div>
<!-- Upload Panel -->
<div id="upload-panel" class="hidden mb-6 p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload SD Card Data</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Select a ZIP file, or select all files from inside an <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">Auto_####</code> folder. File types (.rnd, .rnh) are auto-detected.
</p>
<input type="file" id="upload-input" multiple
accept=".zip,.rnd,.rnh,.RND,.RNH"
class="block w-full text-sm text-gray-500 dark:text-gray-400
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
hover:file:bg-seismo-navy file:cursor-pointer" />
<div class="flex items-center gap-3 mt-3">
<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">
Import Files
</button>
<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">
Cancel
</button>
<span id="upload-status" class="text-sm hidden"></span>
</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>
@@ -559,5 +633,112 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
closeAssignModal(); closeAssignModal();
} }
}); });
// ── Upload Data ─────────────────────────────────────────────────────────────
function toggleUploadPanel() {
const panel = document.getElementById('upload-panel');
const status = document.getElementById('upload-status');
panel.classList.toggle('hidden');
// Reset state when reopening
if (!panel.classList.contains('hidden')) {
status.textContent = '';
status.className = 'text-sm hidden';
document.getElementById('upload-input').value = '';
document.getElementById('upload-progress-wrap').classList.add('hidden');
document.getElementById('upload-progress-bar').style.width = '0%';
}
}
function submitUpload() {
const input = document.getElementById('upload-input');
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) {
alert('Please select files to upload.');
return;
}
const fileCount = input.files.length;
const formData = new FormData();
for (const file of input.files) {
formData.append('files', file);
}
// Disable controls and show progress bar
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`;
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
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';
}
} catch {
status.textContent = 'Error: Unexpected server response';
status.className = 'text-sm text-red-600 dark:text-red-400';
}
});
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';
});
xhr.open('POST', `/api/projects/${projectId}/nrl/${locationId}/upload-data`);
xhr.send(formData);
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -75,6 +75,32 @@ Include this modal in pages that use the project picker.
</select> </select>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Data Collection <span class="text-red-500">*</span>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange bg-orange-50 dark:bg-orange-900/20 rounded-lg cursor-pointer" id="qcp-mode-manual-label">
<input type="radio" name="data_collection_mode" value="manual" checked
onchange="qcpUpdateModeStyles()"
class="mt-0.5 accent-seismo-orange shrink-0">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Manual</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SD card retrieved daily</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="qcp-mode-remote-label">
<input type="radio" name="data_collection_mode" value="remote"
onchange="qcpUpdateModeStyles()"
class="mt-0.5 accent-seismo-orange shrink-0">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Remote</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Modem, data pulled via FTP</p>
</div>
</label>
</div>
</div>
<div id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm"> <div id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
</div> </div>
@@ -98,6 +124,24 @@ Include this modal in pages that use the project picker.
</div> </div>
<script> <script>
function qcpUpdateModeStyles() {
const manualChecked = document.querySelector('input[name="data_collection_mode"][value="manual"]')?.checked;
const manualLabel = document.getElementById('qcp-mode-manual-label');
const remoteLabel = document.getElementById('qcp-mode-remote-label');
if (!manualLabel || !remoteLabel) return;
if (manualChecked) {
manualLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
remoteLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.add('border-gray-300', 'dark:border-gray-600');
} else {
remoteLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
manualLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.add('border-gray-300', 'dark:border-gray-600');
}
}
// Quick create project modal functions // Quick create project modal functions
if (typeof openCreateProjectModal === 'undefined') { if (typeof openCreateProjectModal === 'undefined') {
function openCreateProjectModal(searchQuery, pickerId = '') { function openCreateProjectModal(searchQuery, pickerId = '') {
@@ -113,6 +157,7 @@ if (typeof openCreateProjectModal === 'undefined') {
// Reset form // Reset form
document.getElementById('quickCreateProjectForm').reset(); document.getElementById('quickCreateProjectForm').reset();
qcpUpdateModeStyles();
if (errorDiv) errorDiv.classList.add('hidden'); if (errorDiv) errorDiv.classList.add('hidden');
// Try to parse the search query intelligently // Try to parse the search query intelligently

View File

@@ -151,9 +151,9 @@
<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-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg> </svg>
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p> <p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500"> <p class="text-sm text-gray-400 dark:text-gray-500">
Files will appear here once they are downloaded from the sound level meter Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.
</p> </p>
</div> </div>
{% endif %} {% endif %}

View File

@@ -0,0 +1,89 @@
<!-- Live Status Card content for connected NRLs -->
{% if error and not status %}
<div class="flex items-center gap-3 text-gray-500 dark:text-gray-400">
<svg class="w-5 h-5 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<span class="text-sm">{{ error }}</span>
</div>
{% elif status %}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<!-- Measurement State -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">State</span>
{% set state = status.get('measurement_state', 'unknown') if status is mapping else 'unknown' %}
{% if state in ('measuring', 'recording') %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-green-600 dark:text-green-400">
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
Measuring
</span>
{% elif state == 'paused' %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-600 dark:text-yellow-400">
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
Paused
</span>
{% elif state == 'stopped' %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-gray-600 dark:text-gray-400">
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
Stopped
</span>
{% else %}
<span class="text-sm text-gray-500 dark:text-gray-400 capitalize">{{ state }}</span>
{% endif %}
</div>
<!-- Lp (instantaneous) -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Lp (dB)</span>
{% set lp = status.get('lp') if status is mapping else None %}
<span class="text-xl font-bold text-gray-900 dark:text-white">
{% if lp is not none %}{{ "%.1f"|format(lp) }}{% else %}&mdash;{% endif %}
</span>
</div>
<!-- Battery -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Battery</span>
{% set batt = status.get('battery_level') if status is mapping else None %}
{% if batt is not none %}
<span class="text-sm font-semibold
{% if batt >= 60 %}text-green-600 dark:text-green-400
{% elif batt >= 30 %}text-yellow-600 dark:text-yellow-400
{% else %}text-red-600 dark:text-red-400{% endif %}">
{{ batt }}%
</span>
{% else %}
<span class="text-sm text-gray-500">&mdash;</span>
{% endif %}
</div>
<!-- Last Seen -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Seen</span>
{% set last_seen = status.get('last_seen') if status is mapping else None %}
{% if last_seen %}
<span class="text-sm text-gray-700 dark:text-gray-300">{{ last_seen|local_datetime }}</span>
{% else %}
<span class="text-sm text-gray-500">&mdash;</span>
{% endif %}
</div>
</div>
{% if unit %}
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
<span class="text-xs text-gray-400 dark:text-gray-500">
Unit: {{ unit.id }}
{% if unit.slm_model %} &bull; {{ unit.slm_model }}{% endif %}
</span>
<a href="/slm/{{ unit.id }}"
class="text-xs text-seismo-orange hover:text-seismo-navy transition-colors">
Open Unit &rarr;
</a>
</div>
{% endif %}
{% else %}
<div class="text-sm text-gray-500 dark:text-gray-400">No status data available.</div>
{% endif %}

View File

@@ -13,6 +13,8 @@
</div> </div>
{% if project.status == 'active' %} {% if project.status == 'active' %}
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span> <span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
{% elif project.status == 'on_hold' %}
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
{% elif project.status == 'completed' %} {% elif project.status == 'completed' %}
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span> <span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
{% elif project.status == 'archived' %} {% elif project.status == 'archived' %}

View File

@@ -12,12 +12,27 @@
{% if project_type %} {% if project_type %}
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span> <span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
{% endif %} {% endif %}
{% if project.data_collection_mode == 'remote' %}
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
Remote
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
</svg>
Manual
</span>
{% endif %}
</div> </div>
</div> </div>
<!-- Project Actions --> <!-- Project Actions -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{% if project_type and project_type.id == 'sound_monitoring' %} {% if project_type and project_type.id == 'sound_monitoring' %}
<a href="/api/projects/{{ project.id }}/generate-combined-report" <a href="/api/projects/{{ project.id }}/combined-report-wizard"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm"> class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>

View File

@@ -34,6 +34,10 @@
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full"> <span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
Active Active
</span> </span>
{% elif item.project.status == 'on_hold' %}
<span class="px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
On Hold
</span>
{% elif item.project.status == 'completed' %} {% elif item.project.status == 'completed' %}
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full"> <span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
Completed Completed

View File

@@ -16,6 +16,8 @@
{% if item.project.status == 'active' %} {% if item.project.status == 'active' %}
<span class="shrink-0 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">Active</span> <span class="shrink-0 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">Active</span>
{% elif item.project.status == 'on_hold' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
{% elif item.project.status == 'completed' %} {% elif item.project.status == 'completed' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span> <span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
{% elif item.project.status == 'archived' %} {% elif item.project.status == 'archived' %}

View File

@@ -27,6 +27,20 @@
</div> </div>
</div> </div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">On Hold</p>
<p class="text-3xl font-bold text-amber-600 dark:text-amber-400">{{ on_hold_projects }}</p>
</div>
<div class="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<svg class="w-8 h-8 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>

View File

@@ -5,7 +5,7 @@
{% if schedules %} {% if schedules %}
{% for item in schedules %} {% for item in schedules %}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
{% if not item.schedule.enabled %}opacity-60{% endif %}"> {% if project_status == 'on_hold' or not item.schedule.enabled %}opacity-60{% endif %}">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
@@ -29,7 +29,15 @@
{% endif %} {% endif %}
<!-- Status badge --> <!-- Status badge -->
{% if item.schedule.enabled %} {% if project_status == 'on_hold' %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
On Hold
</span>
{% elif project_status == 'archived' %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Archived
</span>
{% elif item.schedule.enabled %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300"> <span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
Active Active
</span> </span>
@@ -98,7 +106,8 @@
</div> </div>
</div> </div>
<!-- Actions --> <!-- Actions (hidden when project is on hold or archived) -->
{% if project_status not in ('on_hold', 'archived') %}
<div class="flex items-center gap-2 flex-shrink-0"> <div class="flex items-center gap-2 flex-shrink-0">
{% if item.schedule.enabled %} {% if item.schedule.enabled %}
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable" <button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
@@ -131,6 +140,7 @@
</svg> </svg>
</button> </button>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -19,7 +19,8 @@
<!-- Actions for this date --> <!-- Actions for this date -->
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700"> <div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
{% for item in date_group.actions %} {% for item in date_group.actions %}
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow"> <div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow
{% if project_status == 'on_hold' and item.schedule.execution_status == 'pending' %}opacity-60{% endif %}">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
@@ -54,6 +55,11 @@
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full"> <span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
Pending Pending
</span> </span>
{% if project_status == 'on_hold' %}
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
On Hold
</span>
{% endif %}
{% elif item.schedule.execution_status == 'completed' %} {% elif item.schedule.execution_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"> <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 Completed
@@ -157,7 +163,8 @@
{% endif %} {% endif %}
</div> </div>
<!-- Actions --> <!-- Actions (hidden when project is on hold or archived) -->
{% if project_status not in ('on_hold', 'archived') %}
<div class="flex items-center gap-2 flex-shrink-0"> <div class="flex items-center gap-2 flex-shrink-0">
{% if item.schedule.execution_status == 'pending' %} {% if item.schedule.execution_status == 'pending' %}
<button onclick="executeSchedule('{{ item.schedule.id }}')" <button onclick="executeSchedule('{{ item.schedule.id }}')"
@@ -177,6 +184,7 @@
</button> </button>
{% endif %} {% endif %}
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -5,7 +5,7 @@
<div class="mb-4"> <div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4> <h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
Schedule a single recording session with a specific start and end time. Schedule a single monitoring session with a specific start and end time.
Duration can be between 15 minutes and 24 hours. Duration can be between 15 minutes and 24 hours.
</p> </p>
</div> </div>

View File

@@ -1,79 +1,149 @@
<!-- Recording 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 recording 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 recording 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>

View File

@@ -23,12 +23,26 @@
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path> <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
</svg> </svg>
<div> <div>
{% set meta = session.session_metadata|fromjson if session.session_metadata else {} %}
{% set is_manual = meta.get('source') in ('manual_upload', 'bulk_upload') %}
<div class="font-semibold text-gray-900 dark:text-white"> <div class="font-semibold text-gray-900 dark:text-white">
{{ session.started_at|local_datetime if session.started_at else 'Unknown Date' }} {% if location %}{{ location.name }}{% else %}Unknown Location{% endif %}
{% if session.started_at %}
&mdash;
{% if session.stopped_at and not same_date(session.started_at, session.stopped_at) %}
{{ session.started_at|local_date }} to {{ session.stopped_at|local_date }}
{% else %}
{{ session.started_at|local_date }}
{% endif %}
{% endif %}
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %} {% if is_manual %}
{% if location %} @ {{ location.name }}{% endif %} {% set store = meta.get('store_name') %}
Manual upload{% if store %} &mdash; Store {{ store }}{% endif %}
{% elif unit %}
{{ unit.id }}
{% endif %}
<span class="mx-2"></span> <span class="mx-2"></span>
{{ files|length }} file{{ 's' if files|length != 1 else '' }} {{ files|length }} file{{ 's' if files|length != 1 else '' }}
</div> </div>

View File

@@ -163,6 +163,25 @@
</div> </div>
</div> </div>
<!-- Deploy / Bench Toggle -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
</svg>
<div>
<span class="font-medium text-gray-900 dark:text-white">Deployment Status</span>
<p id="slm-settings-deploy-desc" class="text-xs text-gray-500 dark:text-gray-400">Unit is currently deployed in the field</p>
</div>
</div>
<button type="button" id="slm-settings-deploy-btn"
onclick="toggleSLMDeployed()"
class="px-3 py-1.5 text-sm rounded-lg font-medium transition-colors">
</button>
</div>
</div>
<!-- FTP Enable Toggle --> <!-- FTP Enable Toggle -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4"> <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<label class="flex items-center justify-between cursor-pointer"> <label class="flex items-center justify-between cursor-pointer">
@@ -264,6 +283,9 @@ async function openSLMSettingsModal(unitId) {
// FTP enabled from SLMM // FTP enabled from SLMM
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true; document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
// Deploy/bench status from Terra-View
updateDeployButton(unitData.deployed !== false);
} catch (error) { } catch (error) {
console.error('Failed to load SLM settings:', error); console.error('Failed to load SLM settings:', error);
errorDiv.textContent = 'Failed to load configuration: ' + error.message; errorDiv.textContent = 'Failed to load configuration: ' + error.message;
@@ -525,6 +547,77 @@ async function saveFTPSettings(event) {
return saveSLMSettings(event); return saveSLMSettings(event);
} }
// Update the deploy/bench button appearance based on current deployed state
function updateDeployButton(isDeployed) {
const btn = document.getElementById('slm-settings-deploy-btn');
const desc = document.getElementById('slm-settings-deploy-desc');
const icon = btn.closest('.border').querySelector('svg');
if (isDeployed) {
btn.textContent = 'Bench Unit';
btn.className = 'px-3 py-1.5 text-sm rounded-lg font-medium transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600';
desc.textContent = 'Unit is currently deployed in the field';
icon.classList.remove('text-gray-400');
icon.classList.add('text-green-500');
} else {
btn.textContent = 'Deploy Unit';
btn.className = 'px-3 py-1.5 text-sm rounded-lg font-medium transition-colors bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50';
desc.textContent = 'Unit is currently benched (not in field)';
icon.classList.remove('text-green-500');
icon.classList.add('text-gray-400');
}
// Store current state on button for toggle reference
btn.dataset.deployed = isDeployed ? '1' : '0';
}
// Toggle deploy/bench status
async function toggleSLMDeployed() {
const unitId = document.getElementById('slm-settings-unit-id').value;
const btn = document.getElementById('slm-settings-deploy-btn');
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
const currentlyDeployed = btn.dataset.deployed === '1';
const newDeployed = !currentlyDeployed;
btn.disabled = true;
btn.textContent = newDeployed ? 'Deploying...' : 'Benching...';
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
try {
const formData = new FormData();
formData.append('deployed', newDeployed ? 'true' : 'false');
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.detail || 'Failed to update deployment status');
}
updateDeployButton(newDeployed);
successDiv.textContent = newDeployed ? 'Unit marked as deployed.' : 'Unit marked as benched.';
successDiv.classList.remove('hidden');
setTimeout(() => successDiv.classList.add('hidden'), 3000);
// Refresh any SLM list on the page
if (typeof htmx !== 'undefined') {
htmx.trigger('#slm-list', 'load');
}
} catch (error) {
btn.disabled = false;
updateDeployButton(currentlyDeployed); // restore button state
errorDiv.textContent = 'Error: ' + error.message;
errorDiv.classList.remove('hidden');
} finally {
btn.disabled = false;
}
}
// Close modal on background click // Close modal on background click
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) { document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) {

View File

@@ -40,22 +40,22 @@
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
<span id="locations-tab-label">Locations</span> <span id="locations-tab-label">Locations</span>
</button> </button>
<button onclick="switchTab('units')" <button id="units-tab-btn" onclick="switchTab('units')"
data-tab="units" data-tab="units"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
Assigned Units Assigned Units
</button> </button>
<button onclick="switchTab('schedules')" <button id="schedules-tab-btn" onclick="switchTab('schedules')"
data-tab="schedules" data-tab="schedules"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
Schedules Schedules
</button> </button>
<button onclick="switchTab('sessions')" <button id="sessions-tab-btn" onclick="switchTab('sessions')"
data-tab="sessions" data-tab="sessions"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
Recording Sessions Monitoring Sessions
</button> </button>
<button onclick="switchTab('data')" <button id="data-tab-btn" onclick="switchTab('data')"
data-tab="data" data-tab="data"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
Data Files Data Files
@@ -185,11 +185,11 @@
</div> </div>
</div> </div>
<!-- Recording Sessions Tab --> <!-- Monitoring Sessions Tab -->
<div id="sessions-tab" class="tab-panel hidden"> <div id="sessions-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<select id="sessions-filter" onchange="filterSessions()" <select id="sessions-filter" onchange="filterSessions()"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"> class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
@@ -230,6 +230,13 @@
Project Files Project Files
</h2> </h2>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button onclick="toggleUploadAll()"
class="px-3 py-2 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
Upload Data
</button>
<button onclick="htmx.trigger('#unified-files', 'refresh')" <button onclick="htmx.trigger('#unified-files', 'refresh')"
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"> class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -241,6 +248,49 @@
</div> </div>
</div> </div>
<!-- Upload Data Panel -->
<div id="upload-all-panel" class="hidden border-b border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bulk Import — Select Folder</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Select your data folder directly — no zipping needed. Expected structure:
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">[date]/[NRL name]/[Auto_####]/</code>.
NRL folders are matched to locations by name. MP3s are stored; Excel exports are skipped.
</p>
<div class="flex flex-wrap items-center gap-3">
<input type="file" id="upload-all-input"
webkitdirectory directory multiple
class="block text-sm text-gray-500 dark:text-gray-400
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
hover:file:bg-seismo-navy file:cursor-pointer" />
<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">
Import
</button>
<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">
Cancel
</button>
<span id="upload-all-status" class="text-sm hidden"></span>
</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 -->
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
</div>
</div>
<div id="unified-files" <div id="unified-files"
hx-get="/api/projects/{{ project_id }}/files-unified" hx-get="/api/projects/{{ project_id }}/files-unified"
hx-trigger="load, refresh from:#unified-files" hx-trigger="load, refresh from:#unified-files"
@@ -279,12 +329,37 @@
<select name="status" id="settings-status" <select name="status" id="settings-status"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"> class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="active">Active</option> <option value="active">Active</option>
<option value="on_hold">On Hold</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
</div> </div>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Data Collection</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-manual-label">
<input type="radio" name="data_collection_mode" id="settings-mode-manual" value="manual"
onchange="settingsUpdateModeStyles()"
class="mt-0.5 accent-seismo-orange shrink-0">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Manual</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SD card retrieved daily</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-remote-label">
<input type="radio" name="data_collection_mode" id="settings-mode-remote" value="remote"
onchange="settingsUpdateModeStyles()"
class="mt-0.5 accent-seismo-orange shrink-0">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Remote</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Modem, data pulled via FTP</p>
</div>
</label>
</div>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Address</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Address</label>
<input type="text" name="site_address" id="settings-site-address" <input type="text" name="site_address" id="settings-site-address"
@@ -329,14 +404,39 @@
<!-- Danger Zone --> <!-- Danger Zone -->
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700"> <div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3> <h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"> <div class="space-y-3">
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3"> <!-- On Hold -->
Archive this project to remove it from active listings. All data will be preserved. <div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 flex items-center justify-between gap-4">
</p> <div>
<button onclick="archiveProject()" <p class="text-sm font-medium text-gray-900 dark:text-white">Put Project On Hold</p>
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"> <p class="text-sm text-gray-600 dark:text-gray-400">Pause this project without archiving. Assignments and schedules remain in place.</p>
Archive Project </div>
</button> <div id="hold-btn-container" class="shrink-0">
<!-- Rendered by updateDangerZone() based on current status -->
</div>
</div>
<!-- Archive -->
<div class="bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 rounded-lg p-4 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Archive Project</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Remove from active listings. All data is preserved and can be restored.</p>
</div>
<button onclick="archiveProject()"
class="shrink-0 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm">
Archive
</button>
</div>
<!-- Delete -->
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Delete Project</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Permanently removes all project data after a 60-day grace period. This action is difficult to undo.</p>
</div>
<button onclick="openDeleteModal()"
class="shrink-0 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
Delete
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -376,7 +476,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label>
<select name="location_type" id="location-type" <select name="location_type" id="location-type" onchange="updateConnectionModeVisibility()"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"> class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="sound">Sound</option> <option value="sound">Sound</option>
<option value="vibration">Vibration</option> <option value="vibration">Vibration</option>
@@ -389,6 +489,29 @@
</div> </div>
</div> </div>
<!-- Connection Mode — sound locations only -->
<div id="connection-mode-field">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connection Mode</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange rounded-lg cursor-pointer bg-orange-50 dark:bg-orange-900/10" id="mode-connected-label">
<input type="radio" name="connection_mode" value="connected" checked
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
<div>
<div class="font-medium text-gray-900 dark:text-white text-sm">Connected</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Remote unit accessible via modem. Supports live control and FTP download.</div>
</div>
</label>
<label class="flex items-start gap-3 p-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="mode-offline-label">
<input type="radio" name="connection_mode" value="offline"
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
<div>
<div class="font-medium text-gray-900 dark:text-white text-sm">Offline / Manual Upload</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No network access. Data collected from SD card and uploaded manually.</div>
</div>
</label>
</div>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="address" id="location-address" <input type="text" name="address" id="location-address"
@@ -495,7 +618,7 @@
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span> <span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
Single recording session with a specific start and end date/time (15 min - 24 hrs). Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
</p> </p>
</div> </div>
</label> </label>
@@ -596,6 +719,40 @@
</div> </div>
</div> </div>
<!-- Delete Project Confirmation Modal -->
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-100 dark:bg-red-900/40 rounded-lg">
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
</svg>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Delete Project</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
This project will be soft-deleted and <strong class="text-gray-900 dark:text-white">permanently removed after 60 days</strong>. All associated locations, assignments, and sessions will be lost.
</p>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
Type <span class="font-mono font-bold text-red-600 dark:text-red-400">delete</span> to confirm:
</p>
<input type="text" id="delete-confirm-input"
placeholder="type delete"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4 focus:outline-none focus:ring-2 focus:ring-red-500"
autocomplete="off">
<div class="flex gap-3 justify-end">
<button onclick="closeDeleteModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
Cancel
</button>
<button id="confirm-delete-btn" disabled onclick="executeDeleteProject()"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm disabled:opacity-40 disabled:cursor-not-allowed">
Delete Project
</button>
</div>
</div>
</div>
<script> <script>
const projectId = "{{ project_id }}"; const projectId = "{{ project_id }}";
let editingLocationId = null; let editingLocationId = null;
@@ -654,14 +811,32 @@ async function loadProjectDetails() {
document.getElementById('settings-start-date').value = formatDate(data.start_date); document.getElementById('settings-start-date').value = formatDate(data.start_date);
document.getElementById('settings-end-date').value = formatDate(data.end_date); document.getElementById('settings-end-date').value = formatDate(data.end_date);
// Update tab labels based on project type // Update data collection mode radio
if (projectTypeId === 'sound_monitoring') { const mode = data.data_collection_mode || 'manual';
const modeRadio = document.getElementById('settings-mode-' + mode);
if (modeRadio) modeRadio.checked = true;
settingsUpdateModeStyles();
// Update tab labels and visibility based on project type
const isSoundProject = projectTypeId === 'sound_monitoring';
if (isSoundProject) {
document.getElementById('locations-tab-label').textContent = 'NRLs'; document.getElementById('locations-tab-label').textContent = 'NRLs';
document.getElementById('locations-header').textContent = 'Noise Recording Locations'; document.getElementById('locations-header').textContent = 'Noise Recording Locations';
document.getElementById('add-location-label').textContent = 'Add NRL'; document.getElementById('add-location-label').textContent = 'Add NRL';
} }
// Monitoring Sessions and Data Files tabs are sound-only
// Data Files also hides the FTP browser section for manual projects
const isRemote = mode === 'remote';
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
// Schedules and Assigned Units are remote-only (manual projects collect data by hand)
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
// FTP browser within Data Files tab
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
document.getElementById('settings-error').classList.add('hidden'); document.getElementById('settings-error').classList.add('hidden');
updateDangerZone();
} catch (err) { } catch (err) {
console.error('Failed to load project details:', err); console.error('Failed to load project details:', err);
} }
@@ -674,6 +849,24 @@ function formatDate(value) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function settingsUpdateModeStyles() {
const manualChecked = document.getElementById('settings-mode-manual')?.checked;
const manualLabel = document.getElementById('settings-mode-manual-label');
const remoteLabel = document.getElementById('settings-mode-remote-label');
if (!manualLabel || !remoteLabel) return;
if (manualChecked) {
manualLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
remoteLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.add('border-gray-300', 'dark:border-gray-600');
} else {
remoteLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
manualLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.add('border-gray-300', 'dark:border-gray-600');
}
}
// Project settings form submission // Project settings form submission
document.getElementById('project-settings-form').addEventListener('submit', async function(e) { document.getElementById('project-settings-form').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
@@ -686,7 +879,8 @@ document.getElementById('project-settings-form').addEventListener('submit', asyn
site_address: document.getElementById('settings-site-address').value.trim() || null, site_address: document.getElementById('settings-site-address').value.trim() || null,
site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null, site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null,
start_date: document.getElementById('settings-start-date').value || null, start_date: document.getElementById('settings-start-date').value || null,
end_date: document.getElementById('settings-end-date').value || null end_date: document.getElementById('settings-end-date').value || null,
data_collection_mode: document.querySelector('input[name="data_collection_mode"]:checked')?.value || 'manual'
}; };
try { try {
@@ -729,6 +923,33 @@ function refreshProjectDashboard() {
} }
// Location modal functions // Location modal functions
function updateConnectionModeVisibility() {
const locType = document.getElementById('location-type').value;
const field = document.getElementById('connection-mode-field');
if (field) field.classList.toggle('hidden', locType !== 'sound');
}
function updateModeLabels() {
const connected = document.querySelector('input[name="connection_mode"][value="connected"]');
const offline = document.querySelector('input[name="connection_mode"][value="offline"]');
const connLabel = document.getElementById('mode-connected-label');
const offLabel = document.getElementById('mode-offline-label');
if (!connected || !connLabel || !offLabel) return;
const activeClasses = ['border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/10'];
const inactiveClasses = ['border-gray-300', 'dark:border-gray-600'];
if (connected.checked) {
connLabel.classList.add(...activeClasses);
connLabel.classList.remove(...inactiveClasses);
offLabel.classList.remove(...activeClasses);
offLabel.classList.add(...inactiveClasses);
} else {
offLabel.classList.add(...activeClasses);
offLabel.classList.remove(...inactiveClasses);
connLabel.classList.remove(...activeClasses);
connLabel.classList.add(...inactiveClasses);
}
}
function openLocationModal(defaultType) { function openLocationModal(defaultType) {
editingLocationId = null; editingLocationId = null;
document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-modal-title').textContent = 'Add Location';
@@ -737,17 +958,25 @@ function openLocationModal(defaultType) {
document.getElementById('location-description').value = ''; document.getElementById('location-description').value = '';
document.getElementById('location-address').value = ''; document.getElementById('location-address').value = '';
document.getElementById('location-coordinates').value = ''; document.getElementById('location-coordinates').value = '';
// Reset connection mode to connected
const connectedRadio = document.querySelector('input[name="connection_mode"][value="connected"]');
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type'); const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div'); const locationTypeWrapper = locationTypeSelect.closest('div');
if (projectTypeId === 'sound_monitoring') { if (projectTypeId === 'sound_monitoring') {
locationTypeSelect.value = 'sound'; locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true; locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else { } else {
locationTypeSelect.disabled = false; locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
locationTypeSelect.value = defaultType || 'sound'; locationTypeSelect.value = defaultType || 'sound';
} }
updateConnectionModeVisibility();
document.getElementById('location-error').classList.add('hidden'); document.getElementById('location-error').classList.add('hidden');
document.getElementById('location-modal').classList.remove('hidden'); document.getElementById('location-modal').classList.remove('hidden');
} }
@@ -761,17 +990,27 @@ function openEditLocationModal(button) {
document.getElementById('location-description').value = data.description || ''; document.getElementById('location-description').value = data.description || '';
document.getElementById('location-address').value = data.address || ''; document.getElementById('location-address').value = data.address || '';
document.getElementById('location-coordinates').value = data.coordinates || ''; document.getElementById('location-coordinates').value = data.coordinates || '';
// Restore connection mode from metadata
let savedMode = 'connected';
try { savedMode = JSON.parse(data.location_metadata || '{}').connection_mode || 'connected'; } catch(e) {}
const modeRadio = document.querySelector(`input[name="connection_mode"][value="${savedMode}"]`);
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type'); const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div'); const locationTypeWrapper = locationTypeSelect.closest('div');
if (projectTypeId === 'sound_monitoring') { if (projectTypeId === 'sound_monitoring') {
locationTypeSelect.value = 'sound'; locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true; locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else { } else {
locationTypeSelect.disabled = false; locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
locationTypeSelect.value = data.location_type || 'sound'; locationTypeSelect.value = data.location_type || 'sound';
} }
updateConnectionModeVisibility();
document.getElementById('location-error').classList.add('hidden'); document.getElementById('location-error').classList.add('hidden');
document.getElementById('location-modal').classList.remove('hidden'); document.getElementById('location-modal').classList.remove('hidden');
} }
@@ -790,8 +1029,12 @@ document.getElementById('location-form').addEventListener('submit', async functi
let locationType = document.getElementById('location-type').value; let locationType = document.getElementById('location-type').value;
if (projectTypeId === 'sound_monitoring') { if (projectTypeId === 'sound_monitoring') {
locationType = 'sound'; locationType = 'sound';
} else if (projectTypeId === 'vibration_monitoring') {
locationType = 'vibration';
} }
const connectionMode = document.querySelector('input[name="connection_mode"]:checked')?.value || 'connected';
try { try {
if (editingLocationId) { if (editingLocationId) {
const payload = { const payload = {
@@ -799,7 +1042,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
description: description || null, description: description || null,
address: address || null, address: address || null,
coordinates: coordinates || null, coordinates: coordinates || null,
location_type: locationType location_type: locationType,
location_metadata: JSON.stringify({ connection_mode: connectionMode }),
}; };
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, { const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
method: 'PUT', method: 'PUT',
@@ -817,6 +1061,7 @@ document.getElementById('location-form').addEventListener('submit', async functi
formData.append('address', address); formData.append('address', address);
formData.append('coordinates', coordinates); formData.append('coordinates', coordinates);
formData.append('location_type', locationType); formData.append('location_type', locationType);
formData.append('location_metadata', JSON.stringify({ connection_mode: connectionMode }));
const response = await fetch(`/api/projects/${projectId}/locations/create`, { const response = await fetch(`/api/projects/${projectId}/locations/create`, {
method: 'POST', method: 'POST',
body: formData body: formData
@@ -1027,6 +1272,78 @@ function archiveProject() {
document.getElementById('project-settings-form').dispatchEvent(new Event('submit')); document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
} }
async function holdProject() {
try {
const response = await fetch(`/api/projects/${projectId}/hold`, { method: 'POST' });
if (!response.ok) throw new Error('Failed to put project on hold');
await loadProjectDetails();
updateDangerZone();
htmx.trigger('#project-header', 'load');
} catch (err) {
alert('Failed to put project on hold: ' + err.message);
}
}
async function unholdProject() {
try {
const response = await fetch(`/api/projects/${projectId}/unhold`, { method: 'POST' });
if (!response.ok) throw new Error('Failed to resume project');
await loadProjectDetails();
updateDangerZone();
htmx.trigger('#project-header', 'load');
} catch (err) {
alert('Failed to resume project: ' + err.message);
}
}
function updateDangerZone() {
const status = document.getElementById('settings-status').value;
const container = document.getElementById('hold-btn-container');
if (!container) return;
if (status === 'on_hold') {
container.innerHTML = `<button onclick="unholdProject()"
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors text-sm">
Resume Project
</button>`;
} else {
container.innerHTML = `<button onclick="holdProject()"
class="px-4 py-2 border border-amber-500 text-amber-600 dark:text-amber-400 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors text-sm">
Put On Hold
</button>`;
}
}
function openDeleteModal() {
document.getElementById('delete-confirm-input').value = '';
document.getElementById('confirm-delete-btn').disabled = true;
document.getElementById('delete-project-modal').classList.remove('hidden');
document.getElementById('delete-confirm-input').focus();
}
function closeDeleteModal() {
document.getElementById('delete-project-modal').classList.add('hidden');
}
async function executeDeleteProject() {
try {
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete project');
closeDeleteModal();
window.location.href = '/projects';
} catch (err) {
alert('Failed to delete project: ' + err.message);
}
}
document.addEventListener('DOMContentLoaded', function() {
const input = document.getElementById('delete-confirm-input');
if (input) {
input.addEventListener('input', function() {
document.getElementById('confirm-delete-btn').disabled = this.value !== 'delete';
});
}
});
// ============================================================================ // ============================================================================
// Schedule Modal Functions // Schedule Modal Functions
// ============================================================================ // ============================================================================
@@ -1326,6 +1643,161 @@ document.getElementById('schedule-modal')?.addEventListener('click', function(e)
} }
}); });
// ── Upload Data ───────────────────────────────────────────────────────────────
function toggleUploadAll() {
const panel = document.getElementById('upload-all-panel');
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) {
document.getElementById('upload-all-status').textContent = '';
document.getElementById('upload-all-status').className = 'text-sm hidden';
document.getElementById('upload-all-results').classList.add('hidden');
document.getElementById('upload-all-results').innerHTML = '';
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%';
}
}
// 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 status = document.getElementById('upload-all-status');
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) {
alert('Please select a folder to upload.');
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();
for (const f of filesToSend) {
formData.append('files', f);
formData.append('paths', f.webkitRelativePath || f.name);
}
// Disable controls and show progress
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');
progressWrap.classList.remove('hidden');
progressBar.style.width = '0%';
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`;
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`;
}
});
xhr.upload.addEventListener('load', () => {
progressBar.style.width = '100%';
progressLabel.textContent = 'Processing files on server\u2026';
});
function _resetControls() {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import';
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> &mdash; ${sess.files} files`;
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`;
html += '</li>';
}
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';
}
} catch {
status.textContent = 'Error: Unexpected server response';
status.className = 'text-sm text-red-600 dark:text-red-400';
}
});
xhr.addEventListener('error', () => {
_resetControls();
status.textContent = 'Error: Network error during upload';
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
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadProjectDetails(); loadProjectDetails();

View File

@@ -18,7 +18,7 @@
</div> </div>
<!-- Summary Stats --> <!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" <div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8"
hx-get="/api/projects/stats" hx-get="/api/projects/stats"
hx-trigger="load, every 30s" hx-trigger="load, every 30s"
hx-swap="innerHTML"> hx-swap="innerHTML">
@@ -27,6 +27,7 @@
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div> <div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div> <div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div> <div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
@@ -43,6 +44,11 @@
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium"> class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
Active Active
</button> </button>
<button onclick="switchTab('on_hold')"
id="tab-on_hold"
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
On Hold
</button>
<button onclick="switchTab('completed')" <button onclick="switchTab('completed')"
id="tab-completed" id="tab-completed"
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium"> class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
@@ -72,9 +78,16 @@
<!-- Create Project Modal --> <!-- Create Project Modal -->
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center"> <div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200 dark:border-gray-700"> <div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2> <div>
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p> <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
</div>
<button onclick="hideCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 ml-4">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div> </div>
<div class="p-6" id="createProjectContent"> <div class="p-6" id="createProjectContent">
@@ -91,6 +104,12 @@
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div> <div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div> <div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
</div> </div>
<div class="mt-6 flex justify-end">
<button type="button" onclick="hideCreateProjectModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
</div>
</div> </div>
<!-- Step 2: Project Details Form (hidden initially) --> <!-- Step 2: Project Details Form (hidden initially) -->

View File

@@ -60,7 +60,7 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #} {# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
{% if file and '_Leq_' in file.file_path %} {% if is_leq %}
<!-- Generate Excel Report Button --> <!-- Generate Excel Report Button -->
<button onclick="openReportModal()" <button onclick="openReportModal()"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2"> class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">

View File

@@ -1504,7 +1504,7 @@
`• Unit roster entry\n` + `• Unit roster entry\n` +
`• All history records\n` + `• All history records\n` +
`• Project assignments\n` + `• Project assignments\n` +
`Recording sessions\n` + `Monitoring sessions\n` +
`• Modem references\n\n` + `• Modem references\n\n` +
`This action cannot be undone.` `This action cannot be undone.`
); );

View File

@@ -0,0 +1,415 @@
{% extends "base.html" %}
{% block title %}{{ location.name }} - Monitoring Location{% endblock %}
{% block content %}
<!-- Breadcrumb Navigation -->
<div class="mb-6">
<nav class="flex items-center space-x-2 text-sm">
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Projects
</a>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<a href="/projects/{{ project_id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ project.name }}
</a>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-gray-900 dark:text-white font-medium">{{ location.name }}</span>
</nav>
</div>
<!-- Header -->
<div class="mb-8">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<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>
{{ location.name }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Monitoring Location • {{ project.name }}
</p>
</div>
<div class="flex gap-2">
{% if assigned_unit %}
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Unit Assigned
</span>
{% else %}
<span class="px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
No Unit Assigned
</span>
{% endif %}
</div>
</div>
</div>
<!-- Tab Navigation -->
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav class="flex space-x-6">
<button onclick="switchTab('overview')"
data-tab="overview"
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
Overview
</button>
<button onclick="switchTab('settings')"
data-tab="settings"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Settings
</button>
</nav>
</div>
<!-- Tab Content -->
<div id="tab-content">
<!-- Overview Tab -->
<div id="overview-tab" class="tab-panel">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Location Details Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location Details</h2>
<div class="space-y-4">
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Name</div>
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ location.name }}</div>
</div>
{% if location.description %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Description</div>
<div class="text-gray-900 dark:text-white">{{ location.description }}</div>
</div>
{% endif %}
{% if location.address %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Address</div>
<div class="text-gray-900 dark:text-white">{{ location.address }}</div>
</div>
{% endif %}
{% if location.coordinates %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Coordinates</div>
<div class="text-gray-900 dark:text-white font-mono text-sm">{{ location.coordinates }}</div>
</div>
{% endif %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
<div class="text-gray-900 dark:text-white">{{ location.created_at|local_datetime if location.created_at else 'N/A' }}</div>
</div>
</div>
</div>
<!-- Assignment Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
{% if assigned_unit %}
<div class="space-y-4">
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
<div class="text-lg font-medium text-gray-900 dark:text-white">
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ assigned_unit.id }}
</a>
</div>
</div>
{% if assigned_unit.device_type %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
</div>
{% endif %}
{% if assignment %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at|local_datetime if assignment.assigned_at else 'N/A' }}</div>
</div>
{% if assignment.notes %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
</div>
{% endif %}
{% endif %}
<div class="pt-2">
<button onclick="unassignUnit('{{ assignment.id }}')"
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
Unassign Unit
</button>
</div>
</div>
{% else %}
<div class="text-center py-8">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
<button onclick="openAssignModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Assign a Unit
</button>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Location Settings</h2>
<form id="location-settings-form" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
<input type="text" id="settings-name" value="{{ location.name }}"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<textarea id="settings-description" rows="3"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">{{ location.description or '' }}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" id="settings-address" value="{{ location.address or '' }}"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
placeholder="40.7128,-74.0060"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
</div>
<div id="settings-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Assign Unit Modal -->
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p>
</div>
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="assign-form" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
<select id="assign-unit-id" name="unit_id"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
<option value="">Loading units...</option>
</select>
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="assign-notes" name="notes" rows="2"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
</div>
<div id="assign-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeAssignModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Assign Unit
</button>
</div>
</form>
</div>
</div>
<script>
const projectId = "{{ project_id }}";
const locationId = "{{ location_id }}";
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.classList.add('hidden');
});
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
});
const panel = document.getElementById(`${tabName}-tab`);
if (panel) panel.classList.remove('hidden');
const button = document.querySelector(`[data-tab="${tabName}"]`);
if (button) {
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
button.classList.add('border-seismo-orange', 'text-seismo-orange');
}
}
// Location settings form submission
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
const payload = {
name: document.getElementById('settings-name').value.trim(),
description: document.getElementById('settings-description').value.trim() || null,
address: document.getElementById('settings-address').value.trim() || null,
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
};
try {
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to update location');
}
window.location.reload();
} catch (err) {
const errorEl = document.getElementById('settings-error');
errorEl.textContent = err.message || 'Failed to update location.';
errorEl.classList.remove('hidden');
}
});
// Assign modal
function openAssignModal() {
document.getElementById('assign-modal').classList.remove('hidden');
loadAvailableUnits();
}
function closeAssignModal() {
document.getElementById('assign-modal').classList.add('hidden');
}
async function loadAvailableUnits() {
try {
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
if (!response.ok) throw new Error('Failed to load available units');
const data = await response.json();
const select = document.getElementById('assign-unit-id');
select.innerHTML = '<option value="">Select a unit</option>';
if (!data.length) {
document.getElementById('assign-empty').classList.remove('hidden');
return;
}
data.forEach(unit => {
const option = document.createElement('option');
option.value = unit.id;
option.textContent = `${unit.id}${unit.model || unit.device_type}`;
select.appendChild(option);
});
} catch (err) {
const errorEl = document.getElementById('assign-error');
errorEl.textContent = err.message || 'Failed to load units.';
errorEl.classList.remove('hidden');
}
}
document.getElementById('assign-form').addEventListener('submit', async function(e) {
e.preventDefault();
const unitId = document.getElementById('assign-unit-id').value;
const notes = document.getElementById('assign-notes').value.trim();
if (!unitId) {
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
document.getElementById('assign-error').classList.remove('hidden');
return;
}
try {
const formData = new FormData();
formData.append('unit_id', unitId);
formData.append('notes', notes);
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to assign unit');
}
window.location.reload();
} catch (err) {
const errorEl = document.getElementById('assign-error');
errorEl.textContent = err.message || 'Failed to assign unit.';
errorEl.classList.remove('hidden');
}
});
async function unassignUnit(assignmentId) {
if (!confirm('Unassign this unit from the location?')) return;
try {
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
method: 'POST'
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to unassign unit');
}
window.location.reload();
} catch (err) {
alert(err.message || 'Failed to unassign unit.');
}
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAssignModal();
});
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeAssignModal();
});
</script>
{% endblock %}