3 Commits

34 changed files with 230 additions and 1512 deletions

View File

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

17
.gitignore vendored
View File

@@ -1,13 +1,3 @@
# Terra-View Specifics
# SQLite database files
*.db
*.db-journal
data/
data-dev/
.aider*
.aider*
docker-compose.override.yml
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
@@ -216,3 +206,10 @@ marimo/_static/
marimo/_lsp/
__marimo__/
# Seismo Fleet Manager
# SQLite database files
*.db
*.db-journal
data/
.aider*
.aider*

View File

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

View File

@@ -312,7 +312,7 @@ async def nrl_detail_page(
db: Session = Depends(get_db)
):
"""NRL (Noise Recording Location) detail page with tabs"""
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, MonitoringSession, DataFile
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
from sqlalchemy import and_
# Get project
@@ -348,24 +348,23 @@ async def nrl_detail_page(
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
# Get session count
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
# Get file count (DataFile links to session, not directly to location)
file_count = db.query(DataFile).join(
MonitoringSession,
DataFile.session_id == MonitoringSession.id
).filter(MonitoringSession.location_id == location_id).count()
RecordingSession,
DataFile.session_id == RecordingSession.id
).filter(RecordingSession.location_id == location_id).count()
# Check for active session
active_session = db.query(MonitoringSession).filter(
active_session = db.query(RecordingSession).filter(
and_(
MonitoringSession.location_id == location_id,
MonitoringSession.status == "recording"
RecordingSession.location_id == location_id,
RecordingSession.status == "recording"
)
).first()
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
return templates.TemplateResponse(template, {
return templates.TemplateResponse("nrl_detail.html", {
"request": request,
"project_id": project_id,
"location_id": location_id,

View File

@@ -1,56 +0,0 @@
"""
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

@@ -1,54 +0,0 @@
"""
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,7 @@ class Project(Base):
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
description = Column(Text, nullable=True)
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
status = Column(String, default="active") # active, completed, archived
# Project metadata
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
@@ -166,7 +166,6 @@ class Project(Base):
created_at = Column(DateTime, default=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):
@@ -245,17 +244,17 @@ class ScheduledAction(Base):
created_at = Column(DateTime, default=datetime.utcnow)
class MonitoringSession(Base):
class RecordingSession(Base):
"""
Monitoring sessions: tracks actual monitoring sessions.
Created when monitoring starts, updated when it stops.
Recording sessions: tracks actual monitoring sessions.
Created when recording starts, updated when it stops.
"""
__tablename__ = "monitoring_sessions"
__tablename__ = "recording_sessions"
id = Column(String, primary_key=True, index=True) # UUID
project_id = Column(String, nullable=False, index=True) # FK to Project.id
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
session_type = Column(String, nullable=False) # sound | vibration
started_at = Column(DateTime, nullable=False)
@@ -278,7 +277,7 @@ class DataFile(Base):
__tablename__ = "data_files"
id = Column(String, primary_key=True, index=True) # UUID
session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id
session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id
file_path = Column(String, nullable=False) # Relative to data/Projects/
file_type = Column(String, nullable=False) # wav, csv, mseed, json

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter, Request, Depends
from sqlalchemy.orm import Session
from sqlalchemy import and_
from datetime import datetime, timedelta
from backend.database import get_db
@@ -49,18 +48,10 @@ def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
today_start_utc = today_start_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
actions = db.query(ScheduledAction).filter(
ScheduledAction.scheduled_time >= today_start_utc,
ScheduledAction.scheduled_time < today_end_utc,
ScheduledAction.project_id.notin_(paused_project_ids),
).order_by(ScheduledAction.scheduled_time.asc()).all()
# Enrich with location/project info and parse results

View File

@@ -14,12 +14,6 @@ from typing import Optional
import uuid
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.models import (
Project,
@@ -27,8 +21,7 @@ from backend.models import (
MonitoringLocation,
UnitAssignment,
RosterUnit,
MonitoringSession,
DataFile,
RecordingSession,
)
from backend.templates_config import templates
@@ -77,8 +70,8 @@ async def get_project_locations(
if assignment:
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
# Count monitoring sessions
session_count = db.query(MonitoringSession).filter_by(
# Count recording sessions
session_count = db.query(RecordingSession).filter_by(
location_id=location.id
).count()
@@ -377,19 +370,19 @@ async def unassign_unit(
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check if there are active monitoring sessions
active_sessions = db.query(MonitoringSession).filter(
# Check if there are active recording sessions
active_sessions = db.query(RecordingSession).filter(
and_(
MonitoringSession.location_id == assignment.location_id,
MonitoringSession.unit_id == assignment.unit_id,
MonitoringSession.status == "recording",
RecordingSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id,
RecordingSession.status == "recording",
)
).count()
if active_sessions > 0:
raise HTTPException(
status_code=400,
detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.",
detail="Cannot unassign unit with active recording sessions. Stop recording first.",
)
assignment.status = "completed"
@@ -458,12 +451,14 @@ async def get_nrl_sessions(
db: Session = Depends(get_db),
):
"""
Get monitoring sessions for a specific NRL.
Get recording sessions for a specific NRL.
Returns HTML partial with session list.
"""
sessions = db.query(MonitoringSession).filter_by(
from backend.models import RecordingSession, RosterUnit
sessions = db.query(RecordingSession).filter_by(
location_id=location_id
).order_by(MonitoringSession.started_at.desc()).all()
).order_by(RecordingSession.started_at.desc()).all()
# Enrich with unit details
sessions_data = []
@@ -496,12 +491,14 @@ async def get_nrl_files(
Get data files for a specific NRL.
Returns HTML partial with file list.
"""
# Join DataFile with MonitoringSession to filter by location_id
from backend.models import DataFile, RecordingSession
# Join DataFile with RecordingSession to filter by location_id
files = db.query(DataFile).join(
MonitoringSession,
DataFile.session_id == MonitoringSession.id
RecordingSession,
DataFile.session_id == RecordingSession.id
).filter(
MonitoringSession.location_id == location_id
RecordingSession.location_id == location_id
).order_by(DataFile.created_at.desc()).all()
# Enrich with session details
@@ -509,7 +506,7 @@ async def get_nrl_files(
for file in files:
session = None
if file.session_id:
session = db.query(MonitoringSession).filter_by(id=file.session_id).first()
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
files_data.append({
"file": file,
@@ -522,217 +519,3 @@ async def get_nrl_files(
"location_id": location_id,
"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 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 ---
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_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,
}

View File

@@ -28,7 +28,7 @@ from backend.models import (
ProjectType,
MonitoringLocation,
UnitAssignment,
MonitoringSession,
RecordingSession,
ScheduledAction,
RecurringSchedule,
RosterUnit,
@@ -57,11 +57,9 @@ async def get_projects_list(
"""
query = db.query(Project)
# Filter by status if provided; otherwise exclude soft-deleted projects
# Filter by status if provided
if status:
query = query.filter(Project.status == status)
else:
query = query.filter(Project.status != "deleted")
# Filter by project type if provided
if project_type_id:
@@ -89,10 +87,10 @@ async def get_projects_list(
).scalar()
# Count active sessions
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
active_session_count = db.query(func.count(RecordingSession.id)).filter(
and_(
MonitoringSession.project_id == project.id,
MonitoringSession.status == "recording",
RecordingSession.project_id == project.id,
RecordingSession.status == "recording",
)
).scalar()
@@ -120,10 +118,9 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
Get summary statistics for projects overview.
Returns HTML partial with stat cards.
"""
# Count projects by status (exclude deleted)
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
# Count projects by status
total_projects = db.query(func.count(Project.id)).scalar()
active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar()
on_hold_projects = db.query(func.count(Project.id)).filter_by(status="on_hold").scalar()
completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()
# Count total locations across all projects
@@ -135,7 +132,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
).scalar()
# Count active recording sessions
active_sessions = db.query(func.count(MonitoringSession.id)).filter_by(
active_sessions = db.query(func.count(RecordingSession.id)).filter_by(
status="recording"
).scalar()
@@ -143,7 +140,6 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
"request": request,
"total_projects": total_projects,
"active_projects": active_projects,
"on_hold_projects": on_hold_projects,
"completed_projects": completed_projects,
"total_locations": total_locations,
"assigned_units": assigned_units,
@@ -182,13 +178,13 @@ async def search_projects(
if not q.strip():
# Return recent active projects when no search term
projects = db.query(Project).filter(
Project.status.notin_(["archived", "deleted"])
Project.status != "archived"
).order_by(Project.updated_at.desc()).limit(limit).all()
else:
search_term = f"%{q}%"
projects = db.query(Project).filter(
and_(
Project.status.notin_(["archived", "deleted"]),
Project.status != "archived",
or_(
Project.project_number.ilike(search_term),
Project.client_name.ilike(search_term),
@@ -227,13 +223,13 @@ async def search_projects_json(
"""
if not q.strip():
projects = db.query(Project).filter(
Project.status.notin_(["archived", "deleted"])
Project.status != "archived"
).order_by(Project.updated_at.desc()).limit(limit).all()
else:
search_term = f"%{q}%"
projects = db.query(Project).filter(
and_(
Project.status.notin_(["archived", "deleted"]),
Project.status != "archived",
or_(
Project.project_number.ilike(search_term),
Project.client_name.ilike(search_term),
@@ -342,14 +338,6 @@ async def update_project(
project.description = data["description"]
if "status" in data:
project.status = data["status"]
# Cancel pending scheduled actions when archiving
if data["status"] == "archived":
db.query(ScheduledAction).filter(
and_(
ScheduledAction.project_id == project_id,
ScheduledAction.execution_status == "pending",
)
).update({"execution_status": "cancelled"})
if "client_name" in data:
project.client_name = data["client_name"]
if "site_address" in data:
@@ -371,93 +359,18 @@ async def update_project(
@router.delete("/{project_id}")
async def delete_project(project_id: str, db: Session = Depends(get_db)):
"""
Soft-delete a project. Sets status='deleted' and records deleted_at timestamp.
Data will be permanently removed after 60 days (or via /permanent endpoint).
Delete a project (soft delete by archiving).
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.status = "deleted"
project.deleted_at = datetime.utcnow()
project.status = "archived"
project.updated_at = datetime.utcnow()
# Cancel all pending scheduled actions
db.query(ScheduledAction).filter(
and_(
ScheduledAction.project_id == project_id,
ScheduledAction.execution_status == "pending",
)
).update({"execution_status": "cancelled"})
db.commit()
return {"success": True, "message": "Project deleted. Data will be permanently removed after 60 days."}
@router.delete("/{project_id}/permanent")
async def permanently_delete_project(project_id: str, db: Session = Depends(get_db)):
"""
Hard-delete a project and all related data. Only allowed when status='deleted'.
Removes: locations, assignments, sessions, scheduled actions, recurring schedules.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.status != "deleted":
raise HTTPException(status_code=400, detail="Project must be soft-deleted before permanent deletion.")
# Delete related data
db.query(RecurringSchedule).filter_by(project_id=project_id).delete()
db.query(ScheduledAction).filter_by(project_id=project_id).delete()
db.query(MonitoringSession).filter_by(project_id=project_id).delete()
db.query(UnitAssignment).filter_by(project_id=project_id).delete()
db.query(MonitoringLocation).filter_by(project_id=project_id).delete()
db.delete(project)
db.commit()
return {"success": True, "message": "Project permanently deleted."}
@router.post("/{project_id}/hold")
async def hold_project(project_id: str, db: Session = Depends(get_db)):
"""
Put a project on hold. Pauses without archiving; assignments and schedules remain.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.status = "on_hold"
project.updated_at = datetime.utcnow()
# Cancel pending scheduled actions so they don't appear in dashboards or fire
db.query(ScheduledAction).filter(
and_(
ScheduledAction.project_id == project_id,
ScheduledAction.execution_status == "pending",
)
).update({"execution_status": "cancelled"})
db.commit()
return {"success": True, "message": "Project put on hold."}
@router.post("/{project_id}/unhold")
async def unhold_project(project_id: str, db: Session = Depends(get_db)):
"""
Resume a project that was on hold.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.status = "active"
project.updated_at = datetime.utcnow()
db.commit()
return {"success": True, "message": "Project resumed."}
return {"success": True, "message": "Project archived successfully"}
# ============================================================================
@@ -501,18 +414,18 @@ async def get_project_dashboard(
})
# Get active recording sessions
active_sessions = db.query(MonitoringSession).filter(
active_sessions = db.query(RecordingSession).filter(
and_(
MonitoringSession.project_id == project_id,
MonitoringSession.status == "recording",
RecordingSession.project_id == project_id,
RecordingSession.status == "recording",
)
).all()
# Get completed sessions count
completed_sessions_count = db.query(func.count(MonitoringSession.id)).filter(
completed_sessions_count = db.query(func.count(RecordingSession.id)).filter(
and_(
MonitoringSession.project_id == project_id,
MonitoringSession.status == "completed",
RecordingSession.project_id == project_id,
RecordingSession.status == "completed",
)
).scalar()
@@ -591,26 +504,26 @@ async def get_project_units(
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
# Count sessions for this assignment
session_count = db.query(func.count(MonitoringSession.id)).filter_by(
session_count = db.query(func.count(RecordingSession.id)).filter_by(
location_id=assignment.location_id,
unit_id=assignment.unit_id,
).scalar()
# Count files from sessions
file_count = db.query(func.count(DataFile.id)).join(
MonitoringSession,
DataFile.session_id == MonitoringSession.id
RecordingSession,
DataFile.session_id == RecordingSession.id
).filter(
MonitoringSession.location_id == assignment.location_id,
MonitoringSession.unit_id == assignment.unit_id,
RecordingSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id,
).scalar()
# Check if currently recording
active_session = db.query(MonitoringSession).filter(
active_session = db.query(RecordingSession).filter(
and_(
MonitoringSession.location_id == assignment.location_id,
MonitoringSession.unit_id == assignment.unit_id,
MonitoringSession.status == "recording",
RecordingSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id,
RecordingSession.status == "recording",
)
).first()
@@ -697,14 +610,10 @@ async def get_project_schedules(
"result": result_data,
})
project = db.query(Project).filter_by(id=project_id).first()
project_status = project.status if project else "active"
return templates.TemplateResponse("partials/projects/schedule_list.html", {
"request": request,
"project_id": project_id,
"schedules_by_date": schedules_by_date,
"project_status": project_status,
})
@@ -797,13 +706,13 @@ async def get_project_sessions(
Returns HTML partial with session list.
Optional status filter: recording, completed, paused, failed
"""
query = db.query(MonitoringSession).filter_by(project_id=project_id)
query = db.query(RecordingSession).filter_by(project_id=project_id)
# Filter by status if provided
if status:
query = query.filter(MonitoringSession.status == status)
query = query.filter(RecordingSession.status == status)
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
sessions = query.order_by(RecordingSession.started_at.desc()).all()
# Enrich with unit and location details
sessions_data = []
@@ -895,18 +804,18 @@ async def ftp_download_to_server(
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
# Get or create active session for this location/unit
session = db.query(MonitoringSession).filter(
session = db.query(RecordingSession).filter(
and_(
MonitoringSession.project_id == project_id,
MonitoringSession.location_id == location_id,
MonitoringSession.unit_id == unit_id,
MonitoringSession.status.in_(["recording", "paused"])
RecordingSession.project_id == project_id,
RecordingSession.location_id == location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status.in_(["recording", "paused"])
)
).first()
# If no active session, create one
if not session:
session = MonitoringSession(
session = RecordingSession(
id=str(uuid.uuid4()),
project_id=project_id,
location_id=location_id,
@@ -1060,18 +969,18 @@ async def ftp_download_folder_to_server(
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
# Get or create active session for this location/unit
session = db.query(MonitoringSession).filter(
session = db.query(RecordingSession).filter(
and_(
MonitoringSession.project_id == project_id,
MonitoringSession.location_id == location_id,
MonitoringSession.unit_id == unit_id,
MonitoringSession.status.in_(["recording", "paused"])
RecordingSession.project_id == project_id,
RecordingSession.location_id == location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status.in_(["recording", "paused"])
)
).first()
# If no active session, create one
if not session:
session = MonitoringSession(
session = RecordingSession(
id=str(uuid.uuid4()),
project_id=project_id,
location_id=location_id,
@@ -1231,9 +1140,9 @@ async def get_unified_files(
import json
# Get all sessions for this project
sessions = db.query(MonitoringSession).filter_by(
sessions = db.query(RecordingSession).filter_by(
project_id=project_id
).order_by(MonitoringSession.started_at.desc()).all()
).order_by(RecordingSession.started_at.desc()).all()
sessions_data = []
for session in sessions:
@@ -1310,7 +1219,7 @@ async def download_project_file(
raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1344,7 +1253,7 @@ async def download_session_files(
import zipfile
# Verify session belongs to this project
session = db.query(MonitoringSession).filter_by(id=session_id).first()
session = db.query(RecordingSession).filter_by(id=session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id:
@@ -1412,7 +1321,7 @@ async def delete_project_file(
raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1442,7 +1351,7 @@ async def delete_session(
from pathlib import Path
# Verify session belongs to this project
session = db.query(MonitoringSession).filter_by(id=session_id).first()
session = db.query(RecordingSession).filter_by(id=session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id:
@@ -1491,7 +1400,7 @@ async def view_rnd_file(
raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1557,7 +1466,7 @@ async def get_rnd_data(
raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1695,7 +1604,7 @@ async def generate_excel_report(
raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -2101,7 +2010,7 @@ async def preview_report_data(
raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -2309,7 +2218,7 @@ async def generate_report_from_preview(
if not file_record:
raise HTTPException(status_code=404, detail="File not found")
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -2471,7 +2380,7 @@ async def generate_combined_excel_report(
raise HTTPException(status_code=404, detail="Project not found")
# Get all sessions with measurement files
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
sessions = db.query(RecordingSession).filter_by(project_id=project_id).all()
# Collect all Leq RND files grouped by location
# Only include files with '_Leq_' in the path (15-minute averaged data)

View File

@@ -497,9 +497,6 @@ async def get_schedule_list_partial(
"""
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(
project_id=project_id
).order_by(RecurringSchedule.created_at.desc()).all()
@@ -518,5 +515,4 @@ async def get_schedule_list_partial(
"request": request,
"project_id": project_id,
"schedules": schedule_data,
"project_status": project_status,
})

View File

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

View File

@@ -15,7 +15,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from sqlalchemy import and_
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment, Project
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment
logger = logging.getLogger(__name__)
@@ -332,12 +332,10 @@ class RecurringScheduleService:
)
actions.append(start_action)
# Create STOP action (stop_cycle handles download when include_download is True)
# Create STOP action
stop_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"schedule_type": "weekly_calendar",
"include_download": schedule.include_download,
})
stop_action = ScheduledAction(
id=str(uuid.uuid4()),
@@ -352,6 +350,27 @@ class RecurringScheduleService:
)
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
def _generate_interval_actions(
@@ -594,16 +613,8 @@ class RecurringScheduleService:
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
def get_enabled_schedules(self) -> List[RecurringSchedule]:
"""Get all enabled recurring schedules for projects that are not on hold or deleted."""
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()
"""Get all enabled recurring schedules."""
return self.db.query(RecurringSchedule).filter_by(enabled=True).all()
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:

View File

@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import and_
from backend.database import SessionLocal
from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
from backend.services.device_controller import get_device_controller, DeviceControllerError
from backend.services.alert_service import get_alert_service
import uuid
@@ -107,19 +107,10 @@ class SchedulerService:
try:
# Find pending actions that are due
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(
and_(
ScheduledAction.execution_status == "pending",
ScheduledAction.scheduled_time <= now,
ScheduledAction.project_id.in_(active_project_ids),
)
).order_by(ScheduledAction.scheduled_time).all()
@@ -272,7 +263,7 @@ class SchedulerService:
)
# Create recording session
session = MonitoringSession(
session = RecordingSession(
id=str(uuid.uuid4()),
project_id=action.project_id,
location_id=action.location_id,
@@ -304,20 +295,9 @@ class SchedulerService:
stop_cycle handles:
1. Stop measurement
2. Enable FTP
3. Download measurement folder to SLMM local storage
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.
3. Download measurement folder
4. Verify download
"""
import hashlib
import io
import os
import zipfile
import httpx
from pathlib import Path
from backend.models import DataFile
# Parse notes for download preference
include_download = True
try:
@@ -328,7 +308,7 @@ class SchedulerService:
pass # Notes is plain text, not JSON
# Execute the full stop cycle via device controller
# SLMM handles stop, FTP enable, and download to SLMM-local storage
# SLMM handles stop, FTP enable, and download
cycle_response = await self.device_controller.stop_cycle(
unit_id,
action.device_type,
@@ -336,11 +316,11 @@ class SchedulerService:
)
# Find and update the active recording session
active_session = db.query(MonitoringSession).filter(
active_session = db.query(RecordingSession).filter(
and_(
MonitoringSession.location_id == action.location_id,
MonitoringSession.unit_id == unit_id,
MonitoringSession.status == "recording",
RecordingSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status == "recording",
)
).first()
@@ -360,81 +340,10 @@ class SchedulerService:
except json.JSONDecodeError:
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 {
"status": "stopped",
"session_id": active_session.id if active_session else None,
"cycle_response": cycle_response,
"files_created": files_created,
}
async def _execute_download(
@@ -617,11 +526,11 @@ class SchedulerService:
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
# Close out the old recording session
active_session = db.query(MonitoringSession).filter(
active_session = db.query(RecordingSession).filter(
and_(
MonitoringSession.location_id == action.location_id,
MonitoringSession.unit_id == unit_id,
MonitoringSession.status == "recording",
RecordingSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status == "recording",
)
).first()
@@ -648,7 +557,7 @@ class SchedulerService:
result["steps"]["start"] = {"success": True, "response": cycle_response}
# Create new recording session
new_session = MonitoringSession(
new_session = RecordingSession(
id=str(uuid.uuid4()),
project_id=action.project_id,
location_id=action.location_id,

View File

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

View File

@@ -0,0 +1,10 @@
{
"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

@@ -0,0 +1,9 @@
{
"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,6 +24,30 @@ services:
retries: 3
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:
build:
@@ -37,8 +61,6 @@ services:
- PYTHONUNBUFFERED=1
- PORT=8100
- CORS_ORIGINS=*
- TCP_IDLE_TTL=-1
- TCP_MAX_AGE=-1
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
@@ -49,3 +71,4 @@ services:
volumes:
data:
data-dev:

View File

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

View File

@@ -80,7 +80,7 @@
<button onclick="switchTab('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">
Monitoring Sessions
Recording Sessions
</button>
<button onclick="switchTab('data')"
data-tab="data"
@@ -302,11 +302,11 @@
</div>
{% endif %}
<!-- Monitoring Sessions Tab -->
<!-- Recording Sessions Tab -->
<div id="sessions-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
{% if assigned_unit %}
<button onclick="openScheduleModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
@@ -329,40 +329,8 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-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>
<div class="flex items-center gap-3">
<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 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 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 class="text-sm text-gray-500">
<span class="font-medium">{{ file_count }}</span> files
</div>
</div>
@@ -591,64 +559,5 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
closeAssignModal();
}
});
// ── Upload Data ─────────────────────────────────────────────────────────────
function toggleUploadPanel() {
const panel = document.getElementById('upload-panel');
const status = document.getElementById('upload-status');
panel.classList.toggle('hidden');
// Reset status when reopening
if (!panel.classList.contains('hidden')) {
status.textContent = '';
status.className = 'text-sm hidden';
document.getElementById('upload-input').value = '';
}
}
async function submitUpload() {
const input = document.getElementById('upload-input');
const status = document.getElementById('upload-status');
if (!input.files.length) {
alert('Please select files to upload.');
return;
}
const formData = new FormData();
for (const file of input.files) {
formData.append('files', file);
}
status.textContent = 'Uploading\u2026';
status.className = 'text-sm text-gray-500';
try {
const response = await fetch(
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
{ method: 'POST', body: formData }
);
const data = await response.json();
if (response.ok) {
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 = '';
// Refresh the file list
htmx.trigger(document.getElementById('data-files-list'), 'load');
} else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
status.className = 'text-sm text-red-600 dark:text-red-400';
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
status.className = 'text-sm text-red-600 dark:text-red-400';
}
}
</script>
{% endblock %}

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">
<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>
<p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">
Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.
Files will appear here once they are downloaded from the sound level meter
</p>
</div>
{% endif %}

View File

@@ -13,8 +13,6 @@
</div>
{% 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>
{% 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' %}
<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' %}

View File

@@ -34,10 +34,6 @@
<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
</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' %}
<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

View File

@@ -16,8 +16,6 @@
{% 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>
{% 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' %}
<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' %}

View File

@@ -27,20 +27,6 @@
</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="flex items-center justify-between">
<div>

View File

@@ -5,7 +5,7 @@
{% if 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
{% if project_status == 'on_hold' or not item.schedule.enabled %}opacity-60{% endif %}">
{% if not item.schedule.enabled %}opacity-60{% endif %}">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2">
@@ -29,15 +29,7 @@
{% endif %}
<!-- Status badge -->
{% 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 %}
{% if 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">
Active
</span>
@@ -106,8 +98,7 @@
</div>
</div>
<!-- Actions (hidden when project is on hold or archived) -->
{% if project_status not in ('on_hold', 'archived') %}
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
{% if item.schedule.enabled %}
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
@@ -140,7 +131,6 @@
</svg>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -19,8 +19,7 @@
<!-- Actions for this date -->
<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 %}
<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="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="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3 mb-2">
@@ -55,11 +54,6 @@
<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
</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' %}
<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
@@ -163,8 +157,7 @@
{% endif %}
</div>
<!-- Actions (hidden when project is on hold or archived) -->
{% if project_status not in ('on_hold', 'archived') %}
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
{% if item.schedule.execution_status == 'pending' %}
<button onclick="executeSchedule('{{ item.schedule.id }}')"
@@ -184,7 +177,6 @@
</button>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -5,7 +5,7 @@
<div class="mb-4">
<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">
Schedule a single monitoring session with a specific start and end time.
Schedule a single recording session with a specific start and end time.
Duration can be between 15 minutes and 24 hours.
</p>
</div>

View File

@@ -1,4 +1,4 @@
<!-- Monitoring Sessions List -->
<!-- Recording Sessions List -->
{% if sessions %}
<div class="space-y-4">
{% for item in sessions %}
@@ -87,7 +87,7 @@
<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="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 class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
</div>
{% endif %}
@@ -99,7 +99,7 @@ function viewSession(sessionId) {
}
function stopRecording(sessionId) {
if (!confirm('Stop this monitoring session?')) return;
if (!confirm('Stop this recording session?')) return;
// TODO: Implement stop recording API call
alert('Stop recording API coming soon for session: ' + sessionId);

View File

@@ -163,25 +163,6 @@
</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 -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<label class="flex items-center justify-between cursor-pointer">
@@ -283,9 +264,6 @@ async function openSLMSettingsModal(unitId) {
// FTP enabled from SLMM
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
// Deploy/bench status from Terra-View
updateDeployButton(unitData.deployed !== false);
} catch (error) {
console.error('Failed to load SLM settings:', error);
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
@@ -547,77 +525,6 @@ async function saveFTPSettings(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
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
if (e.target === this) {

View File

@@ -50,12 +50,12 @@
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
</button>
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
<button onclick="switchTab('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">
Monitoring Sessions
Recording Sessions
</button>
<button id="data-tab-btn" onclick="switchTab('data')"
<button onclick="switchTab('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">
Data Files
@@ -185,11 +185,11 @@
</div>
</div>
<!-- Monitoring Sessions Tab -->
<!-- Recording Sessions Tab -->
<div id="sessions-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
<div class="flex items-center gap-4">
<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">
@@ -279,7 +279,6 @@
<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">
<option value="active">Active</option>
<option value="on_hold">On Hold</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
@@ -330,39 +329,14 @@
<!-- Danger Zone -->
<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>
<div class="space-y-3">
<!-- On Hold -->
<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">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Put Project On Hold</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Pause this project without archiving. Assignments and schedules remain in place.</p>
</div>
<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 class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
Archive this project to remove it from active listings. All data will be preserved.
</p>
<button onclick="archiveProject()"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
Archive Project
</button>
</div>
</div>
</div>
@@ -521,7 +495,7 @@
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
Single recording session with a specific start and end date/time (15 min - 24 hrs).
</p>
</div>
</label>
@@ -622,40 +596,6 @@
</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>
const projectId = "{{ project_id }}";
let editingLocationId = null;
@@ -714,19 +654,14 @@ async function loadProjectDetails() {
document.getElementById('settings-start-date').value = formatDate(data.start_date);
document.getElementById('settings-end-date').value = formatDate(data.end_date);
// Update tab labels and visibility based on project type
const isSoundProject = projectTypeId === 'sound_monitoring';
if (isSoundProject) {
// Update tab labels based on project type
if (projectTypeId === 'sound_monitoring') {
document.getElementById('locations-tab-label').textContent = 'NRLs';
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
document.getElementById('add-location-label').textContent = 'Add NRL';
}
// Monitoring Sessions and Data Files tabs are SLM-only
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('settings-error').classList.add('hidden');
updateDangerZone();
} catch (err) {
console.error('Failed to load project details:', err);
}
@@ -808,10 +743,6 @@ function openLocationModal(defaultType) {
locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else {
locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
@@ -836,10 +767,6 @@ function openEditLocationModal(button) {
locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else {
locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
@@ -863,8 +790,6 @@ document.getElementById('location-form').addEventListener('submit', async functi
let locationType = document.getElementById('location-type').value;
if (projectTypeId === 'sound_monitoring') {
locationType = 'sound';
} else if (projectTypeId === 'vibration_monitoring') {
locationType = 'vibration';
}
try {
@@ -1102,78 +1027,6 @@ function archiveProject() {
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
// ============================================================================

View File

@@ -18,7 +18,7 @@
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8"
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
hx-get="/api/projects/stats"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
@@ -27,7 +27,6 @@
<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>
<!-- Tabs -->
@@ -44,11 +43,6 @@
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
</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')"
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">
@@ -78,16 +72,9 @@
<!-- Create Project Modal -->
<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="p-6 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
<div>
<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 class="p-6 border-b border-gray-200 dark:border-gray-700">
<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>
<div class="p-6" id="createProjectContent">
@@ -104,12 +91,6 @@
<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 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>
<!-- Step 2: Project Details Form (hidden initially) -->

View File

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

View File

@@ -1,415 +0,0 @@
{% 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 %}