feat: add manual SD card data upload for offline NRLs; rename RecordingSession to MonitoringSession

- Add POST /api/projects/{project_id}/nrl/{location_id}/upload-data endpoint
  accepting a ZIP or multi-file select of .rnd/.rnh files from an SD card.
  Parses .rnh metadata for session start/stop times, serial number, and store
  name. Creates a MonitoringSession (no unit assignment required) and DataFile
  records for each measurement file.

- Add Upload Data button and collapsible upload panel to the NRL detail Data
  Files tab, with inline success/error feedback and automatic file list refresh
  via HTMX after import.

- Rename RecordingSession -> MonitoringSession throughout the codebase
  (models.py, projects.py, project_locations.py, scheduler.py, roster_rename.py,
  main.py, init_projects_db.py, scripts/rename_unit.py). DB table renamed from
  recording_sessions to monitoring_sessions; old indexes dropped and recreated.

- Update all template UI copy from Recording Sessions to Monitoring Sessions
  (nrl_detail, projects/detail, session_list, schedule_oneoff, roster).

- Add backend/migrate_rename_recording_to_monitoring_sessions.py for applying
  the table rename on production databases before deploying this build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 19:54:40 +00:00
parent da4e5f66c5
commit 7516bbea70
16 changed files with 509 additions and 123 deletions

View File

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

View File

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

View File

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

View File

@@ -245,17 +245,17 @@ class ScheduledAction(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
class RecordingSession(Base): class MonitoringSession(Base):
""" """
Recording sessions: tracks actual monitoring sessions. Monitoring sessions: tracks actual monitoring sessions.
Created when recording starts, updated when it stops. Created when monitoring starts, updated when it stops.
""" """
__tablename__ = "recording_sessions" __tablename__ = "monitoring_sessions"
id = Column(String, primary_key=True, index=True) # UUID id = Column(String, primary_key=True, index=True) # UUID
project_id = Column(String, nullable=False, index=True) # FK to Project.id project_id = Column(String, nullable=False, index=True) # FK to Project.id
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
session_type = Column(String, nullable=False) # sound | vibration session_type = Column(String, nullable=False) # sound | vibration
started_at = Column(DateTime, nullable=False) started_at = Column(DateTime, nullable=False)
@@ -278,7 +278,7 @@ class DataFile(Base):
__tablename__ = "data_files" __tablename__ = "data_files"
id = Column(String, primary_key=True, index=True) # UUID id = Column(String, primary_key=True, index=True) # UUID
session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id
file_path = Column(String, nullable=False) # Relative to data/Projects/ file_path = Column(String, nullable=False) # Relative to data/Projects/
file_type = Column(String, nullable=False) # wav, csv, mseed, json file_type = Column(String, nullable=False) # wav, csv, mseed, json

View File

@@ -14,6 +14,12 @@ from typing import Optional
import uuid import uuid
import json import json
from fastapi import UploadFile, File
import zipfile
import hashlib
import io
from pathlib import Path
from backend.database import get_db from backend.database import get_db
from backend.models import ( from backend.models import (
Project, Project,
@@ -21,7 +27,8 @@ from backend.models import (
MonitoringLocation, MonitoringLocation,
UnitAssignment, UnitAssignment,
RosterUnit, RosterUnit,
RecordingSession, MonitoringSession,
DataFile,
) )
from backend.templates_config import templates from backend.templates_config import templates
@@ -70,8 +77,8 @@ async def get_project_locations(
if assignment: if assignment:
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
# Count recording sessions # Count monitoring sessions
session_count = db.query(RecordingSession).filter_by( session_count = db.query(MonitoringSession).filter_by(
location_id=location.id location_id=location.id
).count() ).count()
@@ -370,19 +377,19 @@ async def unassign_unit(
if not assignment: if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
# Check if there are active recording sessions # Check if there are active monitoring sessions
active_sessions = db.query(RecordingSession).filter( active_sessions = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.location_id == assignment.location_id, MonitoringSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id, MonitoringSession.unit_id == assignment.unit_id,
RecordingSession.status == "recording", MonitoringSession.status == "recording",
) )
).count() ).count()
if active_sessions > 0: if active_sessions > 0:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Cannot unassign unit with active recording sessions. Stop recording first.", detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.",
) )
assignment.status = "completed" assignment.status = "completed"
@@ -451,14 +458,12 @@ async def get_nrl_sessions(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Get recording sessions for a specific NRL. Get monitoring sessions for a specific NRL.
Returns HTML partial with session list. Returns HTML partial with session list.
""" """
from backend.models import RecordingSession, RosterUnit sessions = db.query(MonitoringSession).filter_by(
sessions = db.query(RecordingSession).filter_by(
location_id=location_id location_id=location_id
).order_by(RecordingSession.started_at.desc()).all() ).order_by(MonitoringSession.started_at.desc()).all()
# Enrich with unit details # Enrich with unit details
sessions_data = [] sessions_data = []
@@ -491,14 +496,12 @@ async def get_nrl_files(
Get data files for a specific NRL. Get data files for a specific NRL.
Returns HTML partial with file list. Returns HTML partial with file list.
""" """
from backend.models import DataFile, RecordingSession # Join DataFile with MonitoringSession to filter by location_id
# Join DataFile with RecordingSession to filter by location_id
files = db.query(DataFile).join( files = db.query(DataFile).join(
RecordingSession, MonitoringSession,
DataFile.session_id == RecordingSession.id DataFile.session_id == MonitoringSession.id
).filter( ).filter(
RecordingSession.location_id == location_id MonitoringSession.location_id == location_id
).order_by(DataFile.created_at.desc()).all() ).order_by(DataFile.created_at.desc()).all()
# Enrich with session details # Enrich with session details
@@ -506,7 +509,7 @@ async def get_nrl_files(
for file in files: for file in files:
session = None session = None
if file.session_id: if file.session_id:
session = db.query(RecordingSession).filter_by(id=file.session_id).first() session = db.query(MonitoringSession).filter_by(id=file.session_id).first()
files_data.append({ files_data.append({
"file": file, "file": file,
@@ -519,3 +522,217 @@ async def get_nrl_files(
"location_id": location_id, "location_id": location_id,
"files": files_data, "files": files_data,
}) })
# ============================================================================
# Manual SD Card Data Upload
# ============================================================================
def _parse_rnh(content: bytes) -> dict:
"""
Parse a Rion .rnh metadata file (INI-style with [Section] headers).
Returns a dict of key metadata fields.
"""
result = {}
try:
text = content.decode("utf-8", errors="replace")
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("["):
continue
if "," in line:
key, _, value = line.partition(",")
key = key.strip()
value = value.strip()
if key == "Serial Number":
result["serial_number"] = value
elif key == "Store Name":
result["store_name"] = value
elif key == "Index Number":
result["index_number"] = value
elif key == "Measurement Start Time":
result["start_time_str"] = value
elif key == "Measurement Stop Time":
result["stop_time_str"] = value
elif key == "Total Measurement Time":
result["total_time_str"] = value
except Exception:
pass
return result
def _parse_rnh_datetime(s: str):
"""Parse RNH datetime string: '2026/02/17 19:00:19' -> datetime"""
from datetime import datetime
if not s:
return None
try:
return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
except Exception:
return None
def _classify_file(filename: str) -> str:
"""Classify a file by name into a DataFile file_type."""
name = filename.lower()
if name.endswith(".rnh"):
return "log"
if name.endswith(".rnd"):
return "measurement"
if name.endswith(".zip"):
return "archive"
return "data"
@router.post("/nrl/{location_id}/upload-data")
async def upload_nrl_data(
project_id: str,
location_id: str,
db: Session = Depends(get_db),
files: list[UploadFile] = File(...),
):
"""
Manually upload SD card data for an offline NRL.
Accepts either:
- A single .zip file (the Auto_#### folder zipped) — auto-extracted
- Multiple .rnd / .rnh files selected directly from the SD card folder
Creates a MonitoringSession from .rnh metadata and DataFile records
for each measurement file. No unit assignment required.
"""
from datetime import datetime
# Verify project and location exist
location = db.query(MonitoringLocation).filter_by(
id=location_id, project_id=project_id
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
# --- Step 1: Normalize to (filename, bytes) list ---
file_entries: list[tuple[str, bytes]] = []
if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
raw = await files[0].read()
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
for info in zf.infolist():
if info.is_dir():
continue
name = Path(info.filename).name # strip folder path
if not name:
continue
file_entries.append((name, zf.read(info)))
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.")
else:
for uf in files:
data = await uf.read()
file_entries.append((uf.filename, data))
if not file_entries:
raise HTTPException(status_code=400, detail="No usable files found in upload.")
# --- Step 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, ProjectType,
MonitoringLocation, MonitoringLocation,
UnitAssignment, UnitAssignment,
RecordingSession, MonitoringSession,
ScheduledAction, ScheduledAction,
RecurringSchedule, RecurringSchedule,
RosterUnit, RosterUnit,
@@ -89,10 +89,10 @@ async def get_projects_list(
).scalar() ).scalar()
# Count active sessions # Count active sessions
active_session_count = db.query(func.count(RecordingSession.id)).filter( active_session_count = db.query(func.count(MonitoringSession.id)).filter(
and_( and_(
RecordingSession.project_id == project.id, MonitoringSession.project_id == project.id,
RecordingSession.status == "recording", MonitoringSession.status == "recording",
) )
).scalar() ).scalar()
@@ -135,7 +135,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
).scalar() ).scalar()
# Count active recording sessions # Count active recording sessions
active_sessions = db.query(func.count(RecordingSession.id)).filter_by( active_sessions = db.query(func.count(MonitoringSession.id)).filter_by(
status="recording" status="recording"
).scalar() ).scalar()
@@ -410,7 +410,7 @@ async def permanently_delete_project(project_id: str, db: Session = Depends(get_
# Delete related data # Delete related data
db.query(RecurringSchedule).filter_by(project_id=project_id).delete() db.query(RecurringSchedule).filter_by(project_id=project_id).delete()
db.query(ScheduledAction).filter_by(project_id=project_id).delete() db.query(ScheduledAction).filter_by(project_id=project_id).delete()
db.query(RecordingSession).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(UnitAssignment).filter_by(project_id=project_id).delete()
db.query(MonitoringLocation).filter_by(project_id=project_id).delete() db.query(MonitoringLocation).filter_by(project_id=project_id).delete()
db.delete(project) db.delete(project)
@@ -501,18 +501,18 @@ async def get_project_dashboard(
}) })
# Get active recording sessions # Get active recording sessions
active_sessions = db.query(RecordingSession).filter( active_sessions = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.project_id == project_id, MonitoringSession.project_id == project_id,
RecordingSession.status == "recording", MonitoringSession.status == "recording",
) )
).all() ).all()
# Get completed sessions count # Get completed sessions count
completed_sessions_count = db.query(func.count(RecordingSession.id)).filter( completed_sessions_count = db.query(func.count(MonitoringSession.id)).filter(
and_( and_(
RecordingSession.project_id == project_id, MonitoringSession.project_id == project_id,
RecordingSession.status == "completed", MonitoringSession.status == "completed",
) )
).scalar() ).scalar()
@@ -591,26 +591,26 @@ async def get_project_units(
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first() location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
# Count sessions for this assignment # Count sessions for this assignment
session_count = db.query(func.count(RecordingSession.id)).filter_by( session_count = db.query(func.count(MonitoringSession.id)).filter_by(
location_id=assignment.location_id, location_id=assignment.location_id,
unit_id=assignment.unit_id, unit_id=assignment.unit_id,
).scalar() ).scalar()
# Count files from sessions # Count files from sessions
file_count = db.query(func.count(DataFile.id)).join( file_count = db.query(func.count(DataFile.id)).join(
RecordingSession, MonitoringSession,
DataFile.session_id == RecordingSession.id DataFile.session_id == MonitoringSession.id
).filter( ).filter(
RecordingSession.location_id == assignment.location_id, MonitoringSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id, MonitoringSession.unit_id == assignment.unit_id,
).scalar() ).scalar()
# Check if currently recording # Check if currently recording
active_session = db.query(RecordingSession).filter( active_session = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.location_id == assignment.location_id, MonitoringSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id, MonitoringSession.unit_id == assignment.unit_id,
RecordingSession.status == "recording", MonitoringSession.status == "recording",
) )
).first() ).first()
@@ -797,13 +797,13 @@ async def get_project_sessions(
Returns HTML partial with session list. Returns HTML partial with session list.
Optional status filter: recording, completed, paused, failed Optional status filter: recording, completed, paused, failed
""" """
query = db.query(RecordingSession).filter_by(project_id=project_id) query = db.query(MonitoringSession).filter_by(project_id=project_id)
# Filter by status if provided # Filter by status if provided
if status: if status:
query = query.filter(RecordingSession.status == status) query = query.filter(MonitoringSession.status == status)
sessions = query.order_by(RecordingSession.started_at.desc()).all() sessions = query.order_by(MonitoringSession.started_at.desc()).all()
# Enrich with unit and location details # Enrich with unit and location details
sessions_data = [] sessions_data = []
@@ -895,18 +895,18 @@ async def ftp_download_to_server(
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path") raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
# Get or create active session for this location/unit # Get or create active session for this location/unit
session = db.query(RecordingSession).filter( session = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.project_id == project_id, MonitoringSession.project_id == project_id,
RecordingSession.location_id == location_id, MonitoringSession.location_id == location_id,
RecordingSession.unit_id == unit_id, MonitoringSession.unit_id == unit_id,
RecordingSession.status.in_(["recording", "paused"]) MonitoringSession.status.in_(["recording", "paused"])
) )
).first() ).first()
# If no active session, create one # If no active session, create one
if not session: if not session:
session = RecordingSession( session = MonitoringSession(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=project_id, project_id=project_id,
location_id=location_id, location_id=location_id,
@@ -1060,18 +1060,18 @@ async def ftp_download_folder_to_server(
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path") raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
# Get or create active session for this location/unit # Get or create active session for this location/unit
session = db.query(RecordingSession).filter( session = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.project_id == project_id, MonitoringSession.project_id == project_id,
RecordingSession.location_id == location_id, MonitoringSession.location_id == location_id,
RecordingSession.unit_id == unit_id, MonitoringSession.unit_id == unit_id,
RecordingSession.status.in_(["recording", "paused"]) MonitoringSession.status.in_(["recording", "paused"])
) )
).first() ).first()
# If no active session, create one # If no active session, create one
if not session: if not session:
session = RecordingSession( session = MonitoringSession(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=project_id, project_id=project_id,
location_id=location_id, location_id=location_id,
@@ -1231,9 +1231,9 @@ async def get_unified_files(
import json import json
# Get all sessions for this project # Get all sessions for this project
sessions = db.query(RecordingSession).filter_by( sessions = db.query(MonitoringSession).filter_by(
project_id=project_id project_id=project_id
).order_by(RecordingSession.started_at.desc()).all() ).order_by(MonitoringSession.started_at.desc()).all()
sessions_data = [] sessions_data = []
for session in sessions: for session in sessions:
@@ -1310,7 +1310,7 @@ async def download_project_file(
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project # Verify file belongs to this project
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id: if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project") raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1344,7 +1344,7 @@ async def download_session_files(
import zipfile import zipfile
# Verify session belongs to this project # Verify session belongs to this project
session = db.query(RecordingSession).filter_by(id=session_id).first() session = db.query(MonitoringSession).filter_by(id=session_id).first()
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id: if session.project_id != project_id:
@@ -1412,7 +1412,7 @@ async def delete_project_file(
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project # Verify file belongs to this project
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id: if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project") raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1442,7 +1442,7 @@ async def delete_session(
from pathlib import Path from pathlib import Path
# Verify session belongs to this project # Verify session belongs to this project
session = db.query(RecordingSession).filter_by(id=session_id).first() session = db.query(MonitoringSession).filter_by(id=session_id).first()
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id: if session.project_id != project_id:
@@ -1491,7 +1491,7 @@ async def view_rnd_file(
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project # Verify file belongs to this project
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id: if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project") raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1557,7 +1557,7 @@ async def get_rnd_data(
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project # Verify file belongs to this project
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id: if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project") raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1695,7 +1695,7 @@ async def generate_excel_report(
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project # Verify file belongs to this project
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id: if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project") raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -2101,7 +2101,7 @@ async def preview_report_data(
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project # Verify file belongs to this project
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id: if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project") raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -2309,7 +2309,7 @@ async def generate_report_from_preview(
if not file_record: if not file_record:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id: if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project") raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -2471,7 +2471,7 @@ async def generate_combined_excel_report(
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
# Get all sessions with measurement files # Get all sessions with measurement files
sessions = db.query(RecordingSession).filter_by(project_id=project_id).all() sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
# Collect all Leq RND files grouped by location # Collect all Leq RND files grouped by location
# Only include files with '_Leq_' in the path (15-minute averaged data) # Only include files with '_Leq_' in the path (15-minute averaged data)

View File

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

View File

@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import and_ from sqlalchemy import and_
from backend.database import SessionLocal from backend.database import SessionLocal
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule
from backend.services.device_controller import get_device_controller, DeviceControllerError from backend.services.device_controller import get_device_controller, DeviceControllerError
from backend.services.alert_service import get_alert_service from backend.services.alert_service import get_alert_service
import uuid import uuid
@@ -272,7 +272,7 @@ class SchedulerService:
) )
# Create recording session # Create recording session
session = RecordingSession( session = MonitoringSession(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=action.project_id, project_id=action.project_id,
location_id=action.location_id, location_id=action.location_id,
@@ -336,11 +336,11 @@ class SchedulerService:
) )
# Find and update the active recording session # Find and update the active recording session
active_session = db.query(RecordingSession).filter( active_session = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.location_id == action.location_id, MonitoringSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id, MonitoringSession.unit_id == unit_id,
RecordingSession.status == "recording", MonitoringSession.status == "recording",
) )
).first() ).first()
@@ -617,11 +617,11 @@ class SchedulerService:
result["steps"]["download"] = {"success": False, "error": "Project or location not found"} result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
# Close out the old recording session # Close out the old recording session
active_session = db.query(RecordingSession).filter( active_session = db.query(MonitoringSession).filter(
and_( and_(
RecordingSession.location_id == action.location_id, MonitoringSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id, MonitoringSession.unit_id == unit_id,
RecordingSession.status == "recording", MonitoringSession.status == "recording",
) )
).first() ).first()
@@ -648,7 +648,7 @@ class SchedulerService:
result["steps"]["start"] = {"success": True, "response": cycle_response} result["steps"]["start"] = {"success": True, "response": cycle_response}
# Create new recording session # Create new recording session
new_session = RecordingSession( new_session = MonitoringSession(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=action.project_id, project_id=action.project_id,
location_id=action.location_id, location_id=action.location_id,

View File

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

View File

@@ -80,7 +80,7 @@
<button onclick="switchTab('sessions')" <button onclick="switchTab('sessions')"
data-tab="sessions" data-tab="sessions"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Recording Sessions Monitoring Sessions
</button> </button>
<button onclick="switchTab('data')" <button onclick="switchTab('data')"
data-tab="data" data-tab="data"
@@ -302,11 +302,11 @@
</div> </div>
{% endif %} {% endif %}
<!-- Recording Sessions Tab --> <!-- Monitoring Sessions Tab -->
<div id="sessions-tab" class="tab-panel hidden"> <div id="sessions-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
{% if assigned_unit %} {% if assigned_unit %}
<button onclick="openScheduleModal()" <button onclick="openScheduleModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"> class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
@@ -329,8 +329,40 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
<div class="text-sm text-gray-500"> <div class="flex items-center gap-3">
<span class="font-medium">{{ file_count }}</span> files <span class="text-sm text-gray-500"><span class="font-medium">{{ file_count }}</span> files</span>
<button onclick="toggleUploadPanel()"
class="px-3 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
Upload Data
</button>
</div>
</div>
<!-- Upload Panel -->
<div id="upload-panel" class="hidden mb-6 p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload SD Card Data</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Select a ZIP file, or select all files from inside an <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">Auto_####</code> folder. File types (.rnd, .rnh) are auto-detected.
</p>
<input type="file" id="upload-input" multiple
accept=".zip,.rnd,.rnh,.RND,.RNH"
class="block w-full text-sm text-gray-500 dark:text-gray-400
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
hover:file:bg-seismo-navy file:cursor-pointer" />
<div class="flex items-center gap-3 mt-3">
<button 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> </div>
</div> </div>
@@ -559,5 +591,64 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
closeAssignModal(); closeAssignModal();
} }
}); });
// ── Upload Data ─────────────────────────────────────────────────────────────
function toggleUploadPanel() {
const panel = document.getElementById('upload-panel');
const status = document.getElementById('upload-status');
panel.classList.toggle('hidden');
// Reset 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> </script>
{% endblock %} {% 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"> <svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg> </svg>
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p> <p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500"> <p class="text-sm text-gray-400 dark:text-gray-500">
Files will appear here once they are downloaded from the sound level meter Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.
</p> </p>
</div> </div>
{% endif %} {% endif %}

View File

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

View File

@@ -1,4 +1,4 @@
<!-- Recording Sessions List --> <!-- Monitoring Sessions List -->
{% if sessions %} {% if sessions %}
<div class="space-y-4"> <div class="space-y-4">
{% for item in sessions %} {% 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"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg> </svg>
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p> <p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p> <p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
</div> </div>
{% endif %} {% endif %}
@@ -99,7 +99,7 @@ function viewSession(sessionId) {
} }
function stopRecording(sessionId) { function stopRecording(sessionId) {
if (!confirm('Stop this recording session?')) return; if (!confirm('Stop this monitoring session?')) return;
// TODO: Implement stop recording API call // TODO: Implement stop recording API call
alert('Stop recording API coming soon for session: ' + sessionId); alert('Stop recording API coming soon for session: ' + sessionId);

View File

@@ -53,7 +53,7 @@
<button id="sessions-tab-btn" onclick="switchTab('sessions')" <button id="sessions-tab-btn" onclick="switchTab('sessions')"
data-tab="sessions" data-tab="sessions"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap"> class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
Recording Sessions Monitoring Sessions
</button> </button>
<button id="data-tab-btn" onclick="switchTab('data')" <button id="data-tab-btn" onclick="switchTab('data')"
data-tab="data" data-tab="data"
@@ -185,11 +185,11 @@
</div> </div>
</div> </div>
<!-- Recording Sessions Tab --> <!-- Monitoring Sessions Tab -->
<div id="sessions-tab" class="tab-panel hidden"> <div id="sessions-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<select id="sessions-filter" onchange="filterSessions()" <select id="sessions-filter" onchange="filterSessions()"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"> class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
@@ -521,7 +521,7 @@
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span> <span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
Single recording session with a specific start and end date/time (15 min - 24 hrs). Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
</p> </p>
</div> </div>
</label> </label>
@@ -721,7 +721,7 @@ async function loadProjectDetails() {
document.getElementById('locations-header').textContent = 'Noise Recording Locations'; document.getElementById('locations-header').textContent = 'Noise Recording Locations';
document.getElementById('add-location-label').textContent = 'Add NRL'; document.getElementById('add-location-label').textContent = 'Add NRL';
} }
// Recording Sessions and Data Files tabs are SLM-only // Monitoring Sessions and Data Files tabs are SLM-only
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject); document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject); document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
@@ -808,6 +808,10 @@ function openLocationModal(defaultType) {
locationTypeSelect.value = 'sound'; locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true; locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else { } else {
locationTypeSelect.disabled = false; locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
@@ -832,6 +836,10 @@ function openEditLocationModal(button) {
locationTypeSelect.value = 'sound'; locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true; locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else { } else {
locationTypeSelect.disabled = false; locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
@@ -855,6 +863,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
let locationType = document.getElementById('location-type').value; let locationType = document.getElementById('location-type').value;
if (projectTypeId === 'sound_monitoring') { if (projectTypeId === 'sound_monitoring') {
locationType = 'sound'; locationType = 'sound';
} else if (projectTypeId === 'vibration_monitoring') {
locationType = 'vibration';
} }
try { try {

View File

@@ -78,9 +78,16 @@
<!-- Create Project Modal --> <!-- Create Project Modal -->
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center"> <div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200 dark:border-gray-700"> <div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2> <div>
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p> <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
</div>
<button onclick="hideCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 ml-4">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div> </div>
<div class="p-6" id="createProjectContent"> <div class="p-6" id="createProjectContent">
@@ -97,6 +104,12 @@
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div> <div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div> <div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
</div> </div>
<div class="mt-6 flex justify-end">
<button type="button" onclick="hideCreateProjectModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
</div>
</div> </div>
<!-- Step 2: Project Details Form (hidden initially) --> <!-- Step 2: Project Details Form (hidden initially) -->

View File

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