Compare commits
10 Commits
3c4b81cf78
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 73a6ff4d20 | |||
| 184f0ddd13 | |||
| e7bd09418b | |||
| 27eeb0fae6 | |||
| 192e15f238 | |||
| 49bc625c1a | |||
| 95fedca8c9 | |||
| e8e155556a | |||
| 33e962e73d | |||
| ac48fb2977 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -5,6 +5,33 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.9.3] - 2026-03-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
|
||||||
|
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
|
||||||
|
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
|
||||||
|
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
|
||||||
|
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
|
||||||
|
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
|
||||||
|
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
|
||||||
|
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.9.2] - 2026-03-27
|
## [0.9.2] - 2026-03-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.9.2
|
# Terra-View v0.9.3
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.9.2"
|
VERSION = "0.9.3"
|
||||||
if ENVIRONMENT == "development":
|
if ENVIRONMENT == "development":
|
||||||
_build = os.getenv("BUILD_NUMBER", "0")
|
_build = os.getenv("BUILD_NUMBER", "0")
|
||||||
if _build and _build != "0":
|
if _build and _build != "0":
|
||||||
@@ -355,8 +355,11 @@ async def nrl_detail_page(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
assigned_unit = None
|
assigned_unit = None
|
||||||
|
assigned_modem = None
|
||||||
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()
|
||||||
|
if assigned_unit and assigned_unit.deployed_with_modem_id:
|
||||||
|
assigned_modem = db.query(RosterUnit).filter_by(id=assigned_unit.deployed_with_modem_id).first()
|
||||||
|
|
||||||
# Get session count
|
# Get session count
|
||||||
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
||||||
@@ -393,6 +396,7 @@ async def nrl_detail_page(
|
|||||||
"location": location,
|
"location": location,
|
||||||
"assignment": assignment,
|
"assignment": assignment,
|
||||||
"assigned_unit": assigned_unit,
|
"assigned_unit": assigned_unit,
|
||||||
|
"assigned_modem": assigned_modem,
|
||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
"file_count": file_count,
|
"file_count": file_count,
|
||||||
"active_session": active_session,
|
"active_session": active_session,
|
||||||
|
|||||||
71
backend/migrate_add_project_modules.py
Normal file
71
backend/migrate_add_project_modules.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add project_modules table and seed from existing project_type_id values.
|
||||||
|
|
||||||
|
Safe to run multiple times — idempotent.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "seismo_fleet.db")
|
||||||
|
DB_PATH = os.path.abspath(DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 1. Create project_modules table if not exists
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS project_modules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
module_type TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(project_id, module_type)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print(" Table 'project_modules' ready.")
|
||||||
|
|
||||||
|
# 2. Seed modules from existing project_type_id values
|
||||||
|
cur.execute("SELECT id, project_type_id FROM projects WHERE project_type_id IS NOT NULL")
|
||||||
|
projects = cur.fetchall()
|
||||||
|
|
||||||
|
seeded = 0
|
||||||
|
for p in projects:
|
||||||
|
pid = p["id"]
|
||||||
|
ptype = p["project_type_id"]
|
||||||
|
|
||||||
|
modules_to_add = []
|
||||||
|
if ptype == "sound_monitoring":
|
||||||
|
modules_to_add = ["sound_monitoring"]
|
||||||
|
elif ptype == "vibration_monitoring":
|
||||||
|
modules_to_add = ["vibration_monitoring"]
|
||||||
|
elif ptype == "combined":
|
||||||
|
modules_to_add = ["sound_monitoring", "vibration_monitoring"]
|
||||||
|
|
||||||
|
for module_type in modules_to_add:
|
||||||
|
# INSERT OR IGNORE — skip if already exists
|
||||||
|
cur.execute("""
|
||||||
|
INSERT OR IGNORE INTO project_modules (id, project_id, module_type, enabled)
|
||||||
|
VALUES (?, ?, ?, 1)
|
||||||
|
""", (str(uuid.uuid4()), pid, module_type))
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
seeded += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f" Seeded {seeded} module record(s) from existing project_type_id values.")
|
||||||
|
|
||||||
|
# 3. Make project_type_id nullable (SQLite doesn't support ALTER COLUMN,
|
||||||
|
# but since we're just loosening a constraint this is a no-op in SQLite —
|
||||||
|
# the column already accepts NULL in practice. Nothing to do.)
|
||||||
|
print(" project_type_id column is now treated as nullable (legacy field).")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
42
backend/migrate_add_session_period_hours.py
Normal file
42
backend/migrate_add_session_period_hours.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Migration: add period_start_hour and period_end_hour to monitoring_sessions.
|
||||||
|
|
||||||
|
Run once:
|
||||||
|
python backend/migrate_add_session_period_hours.py
|
||||||
|
|
||||||
|
Or inside the container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_session_period_hours.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from backend.database import engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check which columns already exist
|
||||||
|
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
|
||||||
|
existing = {row[1] for row in result}
|
||||||
|
|
||||||
|
added = []
|
||||||
|
for col, definition in [
|
||||||
|
("period_start_hour", "INTEGER"),
|
||||||
|
("period_end_hour", "INTEGER"),
|
||||||
|
]:
|
||||||
|
if col not in existing:
|
||||||
|
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
|
||||||
|
added.append(col)
|
||||||
|
else:
|
||||||
|
print(f" Column '{col}' already exists — skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f" Added columns: {', '.join(added)}")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
41
backend/migrate_add_session_report_date.py
Normal file
41
backend/migrate_add_session_report_date.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Migration: add report_date to monitoring_sessions.
|
||||||
|
|
||||||
|
Run once:
|
||||||
|
python backend/migrate_add_session_report_date.py
|
||||||
|
|
||||||
|
Or inside the container:
|
||||||
|
docker exec terra-view-terra-view-1 python3 backend/migrate_add_session_report_date.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from backend.database import engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check which columns already exist
|
||||||
|
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
|
||||||
|
existing = {row[1] for row in result}
|
||||||
|
|
||||||
|
added = []
|
||||||
|
for col, definition in [
|
||||||
|
("report_date", "DATE"),
|
||||||
|
]:
|
||||||
|
if col not in existing:
|
||||||
|
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
|
||||||
|
added.append(col)
|
||||||
|
else:
|
||||||
|
print(f" Column '{col}' already exists — skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f" Added columns: {', '.join(added)}")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer, UniqueConstraint
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from backend.database import Base
|
from backend.database import Base
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ class Project(Base):
|
|||||||
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
|
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
|
||||||
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=True) # Legacy FK to ProjectType.id; use ProjectModule for feature flags
|
||||||
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
||||||
|
|
||||||
# Data collection mode: how field data reaches Terra-View.
|
# Data collection mode: how field data reaches Terra-View.
|
||||||
@@ -197,6 +197,22 @@ class Project(Base):
|
|||||||
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
|
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectModule(Base):
|
||||||
|
"""
|
||||||
|
Modules enabled on a project. Each module unlocks a set of features/tabs.
|
||||||
|
A project can have zero or more modules (sound_monitoring, vibration_monitoring, etc.).
|
||||||
|
"""
|
||||||
|
__tablename__ = "project_modules"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: __import__('uuid').uuid4().__str__())
|
||||||
|
project_id = Column(String, nullable=False, index=True) # FK to projects.id
|
||||||
|
module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ...
|
||||||
|
enabled = Column(Boolean, default=True, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
||||||
|
|
||||||
|
|
||||||
class MonitoringLocation(Base):
|
class MonitoringLocation(Base):
|
||||||
"""
|
"""
|
||||||
Monitoring locations: generic location for monitoring activities.
|
Monitoring locations: generic location for monitoring activities.
|
||||||
@@ -303,6 +319,17 @@ class MonitoringSession(Base):
|
|||||||
# weekday_day | weekday_night | weekend_day | weekend_night
|
# weekday_day | weekday_night | weekend_day | weekend_night
|
||||||
period_type = Column(String, nullable=True)
|
period_type = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Effective monitoring window (hours 0–23). Night sessions cross midnight
|
||||||
|
# (period_end_hour < period_start_hour). NULL = no filtering applied.
|
||||||
|
# e.g. Day: start=7, end=19 Night: start=19, end=7
|
||||||
|
period_start_hour = Column(Integer, nullable=True)
|
||||||
|
period_end_hour = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# For day sessions: the specific calendar date to use for report filtering.
|
||||||
|
# Overrides the automatic "last date with daytime rows" heuristic.
|
||||||
|
# Null = use heuristic.
|
||||||
|
report_date = Column(Date, nullable=True)
|
||||||
|
|
||||||
# Snapshot of device configuration at recording time
|
# Snapshot of device configuration at recording time
|
||||||
session_metadata = Column(Text, nullable=True) # JSON
|
session_metadata = Column(Text, nullable=True) # JSON
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from backend.database import get_db
|
|||||||
from backend.models import (
|
from backend.models import (
|
||||||
Project,
|
Project,
|
||||||
ProjectType,
|
ProjectType,
|
||||||
|
ProjectModule,
|
||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
RosterUnit,
|
RosterUnit,
|
||||||
@@ -31,10 +32,29 @@ from backend.models import (
|
|||||||
DataFile,
|
DataFile,
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
from backend.utils.timezone import local_to_utc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Shared helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _require_module(project, module_type: str, db: Session) -> None:
|
||||||
|
"""Raise 400 if the project does not have the given module enabled."""
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found.")
|
||||||
|
exists = db.query(ProjectModule).filter_by(
|
||||||
|
project_id=project.id, module_type=module_type, enabled=True
|
||||||
|
).first()
|
||||||
|
if not exists:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"This project does not have the {module_type.replace('_', ' ').title()} module enabled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Session period helpers
|
# Session period helpers
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -98,11 +118,11 @@ async def get_project_locations(
|
|||||||
# Enrich with assignment info
|
# Enrich with assignment info
|
||||||
locations_data = []
|
locations_data = []
|
||||||
for location in locations:
|
for location in locations:
|
||||||
# Get active assignment
|
# Get active assignment (active = assigned_until IS NULL)
|
||||||
assignment = db.query(UnitAssignment).filter(
|
assignment = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location.id,
|
UnitAssignment.location_id == location.id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -258,11 +278,11 @@ async def delete_location(
|
|||||||
if not location:
|
if not location:
|
||||||
raise HTTPException(status_code=404, detail="Location not found")
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
# Check if location has active assignments
|
# Check if location has active assignments (active = assigned_until IS NULL)
|
||||||
active_assignments = db.query(UnitAssignment).filter(
|
active_assignments = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location_id,
|
UnitAssignment.location_id == location_id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
@@ -353,18 +373,18 @@ async def assign_unit_to_location(
|
|||||||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if location already has an active assignment
|
# Check if location already has an active assignment (active = assigned_until IS NULL)
|
||||||
existing_assignment = db.query(UnitAssignment).filter(
|
existing_assignment = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location_id,
|
UnitAssignment.location_id == location_id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing_assignment:
|
if existing_assignment:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Unassign first.",
|
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create new assignment
|
# Create new assignment
|
||||||
@@ -433,10 +453,120 @@ async def unassign_unit(
|
|||||||
return {"success": True, "message": "Unit unassigned successfully"}
|
return {"success": True, "message": "Unit unassigned successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/locations/{location_id}/swap")
|
||||||
|
async def swap_unit_on_location(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Swap the unit assigned to a vibration monitoring location.
|
||||||
|
Ends the current active assignment (if any), creates a new one,
|
||||||
|
and optionally updates modem pairing on the seismograph.
|
||||||
|
Works for first-time assignments too (no current assignment = just create).
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
form_data = await request.form()
|
||||||
|
unit_id = form_data.get("unit_id")
|
||||||
|
modem_id = form_data.get("modem_id") or None
|
||||||
|
notes = form_data.get("notes") or None
|
||||||
|
|
||||||
|
if not unit_id:
|
||||||
|
raise HTTPException(status_code=400, detail="unit_id is required")
|
||||||
|
|
||||||
|
# Validate new unit
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
|
||||||
|
if unit.device_type != expected_device_type:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
# End current active assignment if one exists (active = assigned_until IS NULL)
|
||||||
|
current = db.query(UnitAssignment).filter(
|
||||||
|
and_(
|
||||||
|
UnitAssignment.location_id == location_id,
|
||||||
|
UnitAssignment.assigned_until == None,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if current:
|
||||||
|
current.assigned_until = datetime.utcnow()
|
||||||
|
current.status = "completed"
|
||||||
|
|
||||||
|
# Create new assignment
|
||||||
|
new_assignment = UnitAssignment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=unit_id,
|
||||||
|
location_id=location_id,
|
||||||
|
project_id=project_id,
|
||||||
|
device_type=unit.device_type,
|
||||||
|
assigned_until=None,
|
||||||
|
status="active",
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
db.add(new_assignment)
|
||||||
|
|
||||||
|
# Update modem pairing on the seismograph if modem provided
|
||||||
|
if modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
|
if not modem:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
||||||
|
unit.deployed_with_modem_id = modem_id
|
||||||
|
modem.deployed_with_unit_id = unit_id
|
||||||
|
else:
|
||||||
|
# Clear modem pairing if not provided
|
||||||
|
unit.deployed_with_modem_id = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"assignment_id": new_assignment.id,
|
||||||
|
"message": f"Unit '{unit_id}' assigned to '{location.name}'" + (f" with modem '{modem_id}'" if modem_id else ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Available Units for Assignment
|
# Available Units for Assignment
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/available-modems", response_class=JSONResponse)
|
||||||
|
async def get_available_modems(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all deployed, non-retired modems for the modem assignment dropdown.
|
||||||
|
"""
|
||||||
|
modems = db.query(RosterUnit).filter(
|
||||||
|
and_(
|
||||||
|
RosterUnit.device_type == "modem",
|
||||||
|
RosterUnit.deployed == True,
|
||||||
|
RosterUnit.retired == False,
|
||||||
|
)
|
||||||
|
).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"hardware_model": m.hardware_model,
|
||||||
|
"ip_address": m.ip_address,
|
||||||
|
}
|
||||||
|
for m in modems
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/available-units", response_class=JSONResponse)
|
@router.get("/available-units", response_class=JSONResponse)
|
||||||
async def get_available_units(
|
async def get_available_units(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -459,9 +589,9 @@ async def get_available_units(
|
|||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Filter out units that already have active assignments
|
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
||||||
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
||||||
UnitAssignment.status == "active"
|
UnitAssignment.assigned_until == None
|
||||||
).distinct().all()
|
).distinct().all()
|
||||||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
||||||
|
|
||||||
@@ -637,6 +767,9 @@ async def upload_nrl_data(
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Verify project and location exist
|
# Verify project and location exist
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
|
|
||||||
location = db.query(MonitoringLocation).filter_by(
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
id=location_id, project_id=project_id
|
id=location_id, project_id=project_id
|
||||||
).first()
|
).first()
|
||||||
@@ -698,8 +831,15 @@ async def upload_nrl_data(
|
|||||||
rnh_meta = _parse_rnh(fbytes)
|
rnh_meta = _parse_rnh(fbytes)
|
||||||
break
|
break
|
||||||
|
|
||||||
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
# RNH files store local time (no UTC offset). Use local values for period
|
||||||
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
# classification / label generation, then convert to UTC for DB storage so
|
||||||
|
# the local_datetime Jinja filter displays the correct time.
|
||||||
|
started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||||
|
stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||||
|
|
||||||
|
started_at = local_to_utc(started_at_local)
|
||||||
|
stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
|
||||||
|
|
||||||
duration_seconds = None
|
duration_seconds = None
|
||||||
if started_at and stopped_at:
|
if started_at and stopped_at:
|
||||||
duration_seconds = int((stopped_at - started_at).total_seconds())
|
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||||
@@ -709,8 +849,9 @@ async def upload_nrl_data(
|
|||||||
index_number = rnh_meta.get("index_number", "")
|
index_number = rnh_meta.get("index_number", "")
|
||||||
|
|
||||||
# --- Step 3: Create MonitoringSession ---
|
# --- Step 3: Create MonitoringSession ---
|
||||||
period_type = _derive_period_type(started_at) if started_at else None
|
# Use local times for period/label so classification reflects the clock at the site.
|
||||||
session_label = _build_session_label(started_at, location.name, period_type) if started_at else None
|
period_type = _derive_period_type(started_at_local) if started_at_local else None
|
||||||
|
session_label = _build_session_label(started_at_local, location.name, period_type) if started_at_local else None
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
monitoring_session = MonitoringSession(
|
monitoring_session = MonitoringSession(
|
||||||
@@ -815,15 +956,18 @@ async def get_nrl_live_status(
|
|||||||
Fetch cached status from SLMM for the unit assigned to this NRL and
|
Fetch cached status from SLMM for the unit assigned to this NRL and
|
||||||
return a compact HTML status card. Used in the NRL overview tab for
|
return a compact HTML status card. Used in the NRL overview tab for
|
||||||
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
|
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
|
||||||
|
Sound Monitoring projects only.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
# Find the assigned unit
|
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
|
||||||
|
|
||||||
|
# Find the assigned unit (active = assigned_until IS NULL)
|
||||||
assignment = db.query(UnitAssignment).filter(
|
assignment = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location_id,
|
UnitAssignment.location_id == location_id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from backend.utils.timezone import utc_to_local, format_local_datetime
|
from backend.utils.timezone import utc_to_local, format_local_datetime, local_to_utc
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from fastapi import UploadFile, File
|
from fastapi import UploadFile, File
|
||||||
@@ -31,6 +31,7 @@ import pathlib as _pathlib
|
|||||||
from backend.models import (
|
from backend.models import (
|
||||||
Project,
|
Project,
|
||||||
ProjectType,
|
ProjectType,
|
||||||
|
ProjectModule,
|
||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
MonitoringSession,
|
MonitoringSession,
|
||||||
@@ -45,6 +46,50 @@ router = APIRouter(prefix="/api/projects", tags=["projects"])
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Shared helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Registry of known module types. Add new entries here to make them available
|
||||||
|
# in the UI for any project.
|
||||||
|
MODULES = {
|
||||||
|
"sound_monitoring": {"name": "Sound Monitoring", "icon": "speaker", "color": "orange"},
|
||||||
|
"vibration_monitoring": {"name": "Vibration Monitoring", "icon": "activity", "color": "blue"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project_modules(project_id: str, db: Session) -> list[str]:
|
||||||
|
"""Return list of enabled module_type strings for a project."""
|
||||||
|
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
|
||||||
|
return [r.module_type for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_module(project: Project, module_type: str, db: Session) -> None:
|
||||||
|
"""Raise 400 if the project does not have the given module enabled."""
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found.")
|
||||||
|
exists = db.query(ProjectModule).filter_by(
|
||||||
|
project_id=project.id, module_type=module_type, enabled=True
|
||||||
|
).first()
|
||||||
|
if not exists:
|
||||||
|
module_name = MODULES.get(module_type, {}).get("name", module_type)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"This project does not have the {module_name} module enabled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Keep legacy alias so any call sites not yet migrated still work
|
||||||
|
def _require_sound_project(project: Project, db: Session = None) -> None:
|
||||||
|
if db is not None:
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
|
elif not project or project.project_type_id != "sound_monitoring":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This feature is only available for Sound Monitoring projects.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# RND file normalization — maps AU2 (older Rion) column names to the NL-43
|
# RND file normalization — maps AU2 (older Rion) column names to the NL-43
|
||||||
# equivalents so report generation and the web viewer work for both formats.
|
# equivalents so report generation and the web viewer work for both formats.
|
||||||
@@ -398,11 +443,11 @@ async def get_projects_list(
|
|||||||
project_id=project.id
|
project_id=project.id
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count assigned units
|
# Count assigned units (active = assigned_until IS NULL)
|
||||||
unit_count = db.query(func.count(UnitAssignment.id)).filter(
|
unit_count = db.query(func.count(UnitAssignment.id)).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.project_id == project.id,
|
UnitAssignment.project_id == project.id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -589,7 +634,7 @@ async def create_project(request: Request, db: Session = Depends(get_db)):
|
|||||||
project_number=form_data.get("project_number"), # TMI ID: xxxx-YY format
|
project_number=form_data.get("project_number"), # TMI ID: xxxx-YY format
|
||||||
name=form_data.get("name"),
|
name=form_data.get("name"),
|
||||||
description=form_data.get("description"),
|
description=form_data.get("description"),
|
||||||
project_type_id=form_data.get("project_type_id"),
|
project_type_id=form_data.get("project_type_id") or "",
|
||||||
status="active",
|
status="active",
|
||||||
client_name=form_data.get("client_name"),
|
client_name=form_data.get("client_name"),
|
||||||
site_address=form_data.get("site_address"),
|
site_address=form_data.get("site_address"),
|
||||||
@@ -620,6 +665,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
|
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
|
||||||
|
modules = _get_project_modules(project.id, db)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": project.id,
|
"id": project.id,
|
||||||
@@ -628,6 +674,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
|
|||||||
"description": project.description,
|
"description": project.description,
|
||||||
"project_type_id": project.project_type_id,
|
"project_type_id": project.project_type_id,
|
||||||
"project_type_name": project_type.name if project_type else None,
|
"project_type_name": project_type.name if project_type else None,
|
||||||
|
"modules": modules,
|
||||||
"status": project.status,
|
"status": project.status,
|
||||||
"client_name": project.client_name,
|
"client_name": project.client_name,
|
||||||
"site_address": project.site_address,
|
"site_address": project.site_address,
|
||||||
@@ -640,6 +687,58 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/modules")
|
||||||
|
async def list_project_modules(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Return enabled modules for a project, including metadata from MODULES registry."""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
|
||||||
|
active = {r.module_type for r in rows}
|
||||||
|
return {
|
||||||
|
"active": list(active),
|
||||||
|
"available": [
|
||||||
|
{"module_type": k, **v}
|
||||||
|
for k, v in MODULES.items()
|
||||||
|
if k not in active
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/modules")
|
||||||
|
async def add_project_module(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Add a module to a project. Body: {module_type: str}"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
data = await request.json()
|
||||||
|
module_type = data.get("module_type")
|
||||||
|
if not module_type or module_type not in MODULES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown module type: {module_type}")
|
||||||
|
existing = db.query(ProjectModule).filter_by(project_id=project_id, module_type=module_type).first()
|
||||||
|
if existing:
|
||||||
|
existing.enabled = True
|
||||||
|
else:
|
||||||
|
db.add(ProjectModule(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=project_id,
|
||||||
|
module_type=module_type,
|
||||||
|
enabled=True,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "modules": _get_project_modules(project_id, db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/modules/{module_type}")
|
||||||
|
async def remove_project_module(project_id: str, module_type: str, db: Session = Depends(get_db)):
|
||||||
|
"""Disable a module on a project. Data is not deleted."""
|
||||||
|
row = db.query(ProjectModule).filter_by(project_id=project_id, module_type=module_type).first()
|
||||||
|
if row:
|
||||||
|
row.enabled = False
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "modules": _get_project_modules(project_id, db)}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{project_id}")
|
@router.put("/{project_id}")
|
||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -806,11 +905,11 @@ async def get_project_dashboard(
|
|||||||
# Get locations
|
# Get locations
|
||||||
locations = db.query(MonitoringLocation).filter_by(project_id=project_id).all()
|
locations = db.query(MonitoringLocation).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
# Get assigned units with details
|
# Get assigned units with details (active = assigned_until IS NULL)
|
||||||
assignments = db.query(UnitAssignment).filter(
|
assignments = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.project_id == project_id,
|
UnitAssignment.project_id == project_id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@@ -852,6 +951,7 @@ async def get_project_dashboard(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"project": project,
|
"project": project,
|
||||||
"project_type": project_type,
|
"project_type": project_type,
|
||||||
|
"modules": _get_project_modules(project_id, db),
|
||||||
"locations": locations,
|
"locations": locations,
|
||||||
"assigned_units": assigned_units,
|
"assigned_units": assigned_units,
|
||||||
"active_sessions": active_sessions,
|
"active_sessions": active_sessions,
|
||||||
@@ -884,6 +984,7 @@ async def get_project_header(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"project": project,
|
"project": project,
|
||||||
"project_type": project_type,
|
"project_type": project_type,
|
||||||
|
"modules": _get_project_modules(project_id, db),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -899,11 +1000,11 @@ async def get_project_units(
|
|||||||
"""
|
"""
|
||||||
from backend.models import DataFile
|
from backend.models import DataFile
|
||||||
|
|
||||||
# Get all assignments for this project
|
# Get all assignments for this project (active = assigned_until IS NULL)
|
||||||
assignments = db.query(UnitAssignment).filter(
|
assignments = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.project_id == project_id,
|
UnitAssignment.project_id == project_id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@@ -1128,7 +1229,7 @@ async def get_project_sessions(
|
|||||||
|
|
||||||
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
|
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with unit and location details
|
# Enrich with unit, location, and effective time window details
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
unit = None
|
unit = None
|
||||||
@@ -1139,10 +1240,34 @@ async def get_project_sessions(
|
|||||||
if session.location_id:
|
if session.location_id:
|
||||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
|
||||||
|
|
||||||
|
# Compute "Effective: date time → date time" string when period hours are set
|
||||||
|
effective_range = None
|
||||||
|
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
|
||||||
|
from datetime import date as _date
|
||||||
|
local_start = utc_to_local(session.started_at)
|
||||||
|
start_day = session.report_date if session.report_date else local_start.date()
|
||||||
|
sh = session.period_start_hour
|
||||||
|
eh = session.period_end_hour
|
||||||
|
|
||||||
|
def _fmt_h(h):
|
||||||
|
ampm = "AM" if h < 12 else "PM"
|
||||||
|
h12 = h % 12 or 12
|
||||||
|
return f"{h12}:00 {ampm}"
|
||||||
|
|
||||||
|
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
|
||||||
|
if eh > sh: # same calendar day
|
||||||
|
end_day = start_day
|
||||||
|
else: # crosses midnight
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
end_day = start_day + _td(days=1)
|
||||||
|
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
|
||||||
|
effective_range = f"{start_str} → {end_str}"
|
||||||
|
|
||||||
sessions_data.append({
|
sessions_data.append({
|
||||||
"session": session,
|
"session": session,
|
||||||
"unit": unit,
|
"unit": unit,
|
||||||
"location": location,
|
"location": location,
|
||||||
|
"effective_range": effective_range,
|
||||||
})
|
})
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/projects/session_list.html", {
|
return templates.TemplateResponse("partials/projects/session_list.html", {
|
||||||
@@ -1152,6 +1277,173 @@ async def get_project_sessions(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/sessions-calendar", response_class=HTMLResponse)
|
||||||
|
async def get_sessions_calendar(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
month: Optional[int] = Query(None),
|
||||||
|
year: Optional[int] = Query(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Monthly calendar view of monitoring sessions.
|
||||||
|
Color-coded by NRL location. Returns HTML partial.
|
||||||
|
"""
|
||||||
|
from calendar import monthrange
|
||||||
|
from datetime import date as _date, timedelta as _td
|
||||||
|
|
||||||
|
# Default to current month
|
||||||
|
now_local = utc_to_local(datetime.utcnow())
|
||||||
|
if not year:
|
||||||
|
year = now_local.year
|
||||||
|
if not month:
|
||||||
|
month = now_local.month
|
||||||
|
|
||||||
|
# Clamp month to valid range
|
||||||
|
month = max(1, min(12, month))
|
||||||
|
|
||||||
|
# Load all sessions for this project
|
||||||
|
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
|
# Build location -> color map (deterministic)
|
||||||
|
PALETTE = [
|
||||||
|
"#f97316", "#3b82f6", "#10b981", "#8b5cf6",
|
||||||
|
"#ec4899", "#14b8a6", "#f59e0b", "#6366f1",
|
||||||
|
"#ef4444", "#84cc16",
|
||||||
|
]
|
||||||
|
loc_ids = sorted({s.location_id for s in sessions if s.location_id})
|
||||||
|
loc_color = {lid: PALETTE[i % len(PALETTE)] for i, lid in enumerate(loc_ids)}
|
||||||
|
|
||||||
|
# Load location names
|
||||||
|
loc_names = {}
|
||||||
|
for lid in loc_ids:
|
||||||
|
loc = db.query(MonitoringLocation).filter_by(id=lid).first()
|
||||||
|
if loc:
|
||||||
|
loc_names[lid] = loc.name
|
||||||
|
|
||||||
|
# Build calendar grid bounds first (needed for session spanning logic)
|
||||||
|
first_day = _date(year, month, 1)
|
||||||
|
last_day = _date(year, month, monthrange(year, month)[1])
|
||||||
|
days_before = (first_day.isoweekday() % 7)
|
||||||
|
grid_start = first_day - _td(days=days_before)
|
||||||
|
days_after = 6 - (last_day.isoweekday() % 7)
|
||||||
|
grid_end = last_day + _td(days=days_after)
|
||||||
|
|
||||||
|
def _period_hours(s):
|
||||||
|
"""Return (start_hour, end_hour) for a session, falling back to period_type defaults."""
|
||||||
|
psh, peh = s.period_start_hour, s.period_end_hour
|
||||||
|
if psh is None or peh is None:
|
||||||
|
if s.period_type and "night" in s.period_type:
|
||||||
|
return 19, 7
|
||||||
|
if s.period_type and "day" in s.period_type:
|
||||||
|
return 7, 19
|
||||||
|
return psh, peh
|
||||||
|
|
||||||
|
# Build day -> list of gantt segments
|
||||||
|
day_sessions: dict = {}
|
||||||
|
for s in sessions:
|
||||||
|
if not s.started_at:
|
||||||
|
continue
|
||||||
|
local_start = utc_to_local(s.started_at)
|
||||||
|
local_end = utc_to_local(s.stopped_at) if s.stopped_at else now_local
|
||||||
|
span_start = local_start.date()
|
||||||
|
span_end = local_end.date()
|
||||||
|
psh, peh = _period_hours(s)
|
||||||
|
|
||||||
|
cur_d = span_start
|
||||||
|
while cur_d <= span_end:
|
||||||
|
if grid_start <= cur_d <= grid_end:
|
||||||
|
# Device bar bounds (hours 0–24 within this day)
|
||||||
|
dev_sh = (local_start.hour + local_start.minute / 60.0) if cur_d == span_start else 0.0
|
||||||
|
dev_eh = (local_end.hour + local_end.minute / 60.0) if cur_d == span_end else 24.0
|
||||||
|
|
||||||
|
# Effective window within this day
|
||||||
|
eff_sh = eff_eh = None
|
||||||
|
if psh is not None and peh is not None:
|
||||||
|
if psh < peh:
|
||||||
|
# Day window e.g. 7→19
|
||||||
|
eff_sh, eff_eh = float(psh), float(peh)
|
||||||
|
else:
|
||||||
|
# Night window crossing midnight e.g. 19→7
|
||||||
|
if cur_d == span_start:
|
||||||
|
eff_sh, eff_eh = float(psh), 24.0
|
||||||
|
else:
|
||||||
|
eff_sh, eff_eh = 0.0, float(peh)
|
||||||
|
|
||||||
|
# Format tooltip labels
|
||||||
|
def _fmt_h(h):
|
||||||
|
hh = int(h) % 24
|
||||||
|
mm = int((h % 1) * 60)
|
||||||
|
suffix = "AM" if hh < 12 else "PM"
|
||||||
|
return f"{hh % 12 or 12}:{mm:02d} {suffix}"
|
||||||
|
|
||||||
|
if cur_d not in day_sessions:
|
||||||
|
day_sessions[cur_d] = []
|
||||||
|
day_sessions[cur_d].append({
|
||||||
|
"session_id": s.id,
|
||||||
|
"label": s.session_label or f"Session {s.id[:8]}",
|
||||||
|
"location_id": s.location_id,
|
||||||
|
"location_name": loc_names.get(s.location_id, "Unknown"),
|
||||||
|
"color": loc_color.get(s.location_id, "#9ca3af"),
|
||||||
|
"status": s.status,
|
||||||
|
"period_type": s.period_type,
|
||||||
|
# Gantt bar percentages (0–100 scale across 24 hours)
|
||||||
|
"dev_start_pct": round(dev_sh / 24 * 100, 1),
|
||||||
|
"dev_width_pct": max(1.5, round((dev_eh - dev_sh) / 24 * 100, 1)),
|
||||||
|
"eff_start_pct": round(eff_sh / 24 * 100, 1) if eff_sh is not None else None,
|
||||||
|
"eff_width_pct": max(1.0, round((eff_eh - eff_sh) / 24 * 100, 1)) if eff_sh is not None else None,
|
||||||
|
"dev_start_label": _fmt_h(dev_sh),
|
||||||
|
"dev_end_label": _fmt_h(dev_eh),
|
||||||
|
"eff_start_label": f"{int(psh):02d}:00" if eff_sh is not None else None,
|
||||||
|
"eff_end_label": f"{int(peh):02d}:00" if eff_sh is not None else None,
|
||||||
|
})
|
||||||
|
cur_d += _td(days=1)
|
||||||
|
|
||||||
|
weeks = []
|
||||||
|
cur = grid_start
|
||||||
|
while cur <= grid_end:
|
||||||
|
week = []
|
||||||
|
for _ in range(7):
|
||||||
|
week.append({
|
||||||
|
"date": cur,
|
||||||
|
"in_month": cur.month == month,
|
||||||
|
"is_today": cur == now_local.date(),
|
||||||
|
"sessions": day_sessions.get(cur, []),
|
||||||
|
})
|
||||||
|
cur += _td(days=1)
|
||||||
|
weeks.append(week)
|
||||||
|
|
||||||
|
# Prev/next month navigation
|
||||||
|
prev_month = month - 1 if month > 1 else 12
|
||||||
|
prev_year = year if month > 1 else year - 1
|
||||||
|
next_month = month + 1 if month < 12 else 1
|
||||||
|
next_year = year if month < 12 else year + 1
|
||||||
|
|
||||||
|
import calendar as _cal
|
||||||
|
month_name = _cal.month_name[month]
|
||||||
|
|
||||||
|
# Legend: only locations that have sessions this month
|
||||||
|
used_lids = {s["location_id"] for day in day_sessions.values() for s in day}
|
||||||
|
legend = [
|
||||||
|
{"location_id": lid, "name": loc_names.get(lid, lid[:8]), "color": loc_color[lid]}
|
||||||
|
for lid in loc_ids if lid in used_lids
|
||||||
|
]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/sessions_calendar.html", {
|
||||||
|
"request": request,
|
||||||
|
"project_id": project_id,
|
||||||
|
"weeks": weeks,
|
||||||
|
"month": month,
|
||||||
|
"year": year,
|
||||||
|
"month_name": month_name,
|
||||||
|
"prev_month": prev_month,
|
||||||
|
"prev_year": prev_year,
|
||||||
|
"next_month": next_month,
|
||||||
|
"next_year": next_year,
|
||||||
|
"legend": legend,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
|
@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
|
||||||
async def get_ftp_browser(
|
async def get_ftp_browser(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -1160,15 +1452,18 @@ async def get_ftp_browser(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get FTP browser interface for downloading files from assigned SLMs.
|
Get FTP browser interface for downloading files from assigned SLMs.
|
||||||
Returns HTML partial with FTP browser.
|
Returns HTML partial with FTP browser. Sound Monitoring projects only.
|
||||||
"""
|
"""
|
||||||
from backend.models import DataFile
|
from backend.models import DataFile
|
||||||
|
|
||||||
# Get all assignments for this project
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
|
|
||||||
|
# Get all assignments for this project (active = assigned_until IS NULL)
|
||||||
assignments = db.query(UnitAssignment).filter(
|
assignments = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.project_id == project_id,
|
UnitAssignment.project_id == project_id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@@ -1202,6 +1497,7 @@ async def ftp_download_to_server(
|
|||||||
"""
|
"""
|
||||||
Download a file from an SLM to the server via FTP.
|
Download a file from an SLM to the server via FTP.
|
||||||
Creates a DataFile record and stores the file in data/Projects/{project_id}/
|
Creates a DataFile record and stores the file in data/Projects/{project_id}/
|
||||||
|
Sound Monitoring projects only.
|
||||||
"""
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
@@ -1209,6 +1505,8 @@ async def ftp_download_to_server(
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from backend.models import DataFile
|
from backend.models import DataFile
|
||||||
|
|
||||||
|
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
|
||||||
|
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
unit_id = data.get("unit_id")
|
unit_id = data.get("unit_id")
|
||||||
remote_path = data.get("remote_path")
|
remote_path = data.get("remote_path")
|
||||||
@@ -1367,12 +1665,15 @@ async def ftp_download_folder_to_server(
|
|||||||
Download an entire folder from an SLM to the server via FTP.
|
Download an entire folder from an SLM to the server via FTP.
|
||||||
Extracts all files from the ZIP and preserves folder structure.
|
Extracts all files from the ZIP and preserves folder structure.
|
||||||
Creates individual DataFile records for each file.
|
Creates individual DataFile records for each file.
|
||||||
|
Sound Monitoring projects only.
|
||||||
"""
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
import zipfile
|
import zipfile
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from backend.models import DataFile
|
from backend.models import DataFile
|
||||||
|
|
||||||
@@ -1800,6 +2101,23 @@ async def delete_session(
|
|||||||
|
|
||||||
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"}
|
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_period_type(dt: datetime) -> str:
|
||||||
|
is_weekend = dt.weekday() >= 5
|
||||||
|
is_night = dt.hour >= 22 or dt.hour < 7
|
||||||
|
if is_weekend:
|
||||||
|
return "weekend_night" if is_night else "weekend_day"
|
||||||
|
return "weekday_night" if is_night else "weekday_day"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
|
||||||
|
day_abbr = dt.strftime("%a")
|
||||||
|
date_str = f"{dt.month}/{dt.day}"
|
||||||
|
period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "")
|
||||||
|
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
|
||||||
|
return " — ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{project_id}/sessions/{session_id}")
|
@router.patch("/{project_id}/sessions/{session_id}")
|
||||||
async def patch_session(
|
async def patch_session(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -1807,13 +2125,53 @@ async def patch_session(
|
|||||||
data: dict,
|
data: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update session_label and/or period_type on a monitoring session."""
|
"""Update session fields: started_at, stopped_at, session_label, period_type."""
|
||||||
session = db.query(MonitoringSession).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:
|
||||||
raise HTTPException(status_code=403, detail="Session does not belong to this project")
|
raise HTTPException(status_code=403, detail="Session does not belong to this project")
|
||||||
|
|
||||||
|
times_changed = False
|
||||||
|
|
||||||
|
if "started_at" in data and data["started_at"]:
|
||||||
|
try:
|
||||||
|
local_dt = datetime.fromisoformat(data["started_at"])
|
||||||
|
session.started_at = local_to_utc(local_dt)
|
||||||
|
times_changed = True
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid started_at datetime format")
|
||||||
|
|
||||||
|
if "stopped_at" in data:
|
||||||
|
if data["stopped_at"]:
|
||||||
|
try:
|
||||||
|
local_dt = datetime.fromisoformat(data["stopped_at"])
|
||||||
|
session.stopped_at = local_to_utc(local_dt)
|
||||||
|
times_changed = True
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid stopped_at datetime format")
|
||||||
|
else:
|
||||||
|
session.stopped_at = None
|
||||||
|
times_changed = True
|
||||||
|
|
||||||
|
if times_changed and session.started_at and session.stopped_at:
|
||||||
|
delta = session.stopped_at - session.started_at
|
||||||
|
session.duration_seconds = max(0, int(delta.total_seconds()))
|
||||||
|
elif times_changed and not session.stopped_at:
|
||||||
|
session.duration_seconds = None
|
||||||
|
|
||||||
|
# Re-derive period_type and session_label from new started_at unless explicitly provided
|
||||||
|
if times_changed and session.started_at and "period_type" not in data:
|
||||||
|
local_start = utc_to_local(session.started_at)
|
||||||
|
session.period_type = _derive_period_type(local_start)
|
||||||
|
|
||||||
|
if times_changed and session.started_at and "session_label" not in data:
|
||||||
|
from backend.models import MonitoringLocation
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
|
||||||
|
location_name = location.name if location else ""
|
||||||
|
local_start = utc_to_local(session.started_at)
|
||||||
|
session.session_label = _build_session_label(local_start, location_name, session.period_type or "")
|
||||||
|
|
||||||
if "session_label" in data:
|
if "session_label" in data:
|
||||||
session.session_label = str(data["session_label"]).strip() or None
|
session.session_label = str(data["session_label"]).strip() or None
|
||||||
if "period_type" in data:
|
if "period_type" in data:
|
||||||
@@ -1822,8 +2180,111 @@ async def patch_session(
|
|||||||
raise HTTPException(status_code=400, detail=f"Invalid period_type. Must be one of: {', '.join(sorted(VALID_PERIOD_TYPES))}")
|
raise HTTPException(status_code=400, detail=f"Invalid period_type. Must be one of: {', '.join(sorted(VALID_PERIOD_TYPES))}")
|
||||||
session.period_type = pt or None
|
session.period_type = pt or None
|
||||||
|
|
||||||
|
# Configurable period window (0–23 integers; null = no filter)
|
||||||
|
for field in ("period_start_hour", "period_end_hour"):
|
||||||
|
if field in data:
|
||||||
|
val = data[field]
|
||||||
|
if val is None or val == "":
|
||||||
|
setattr(session, field, None)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
h = int(val)
|
||||||
|
if not (0 <= h <= 23):
|
||||||
|
raise ValueError
|
||||||
|
setattr(session, field, h)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail=f"{field} must be an integer 0–23 or null")
|
||||||
|
|
||||||
|
if "report_date" in data:
|
||||||
|
val = data["report_date"]
|
||||||
|
if val is None or val == "":
|
||||||
|
session.report_date = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from datetime import date as _date
|
||||||
|
session.report_date = _date.fromisoformat(str(val))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid report_date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return JSONResponse({"status": "success", "session_label": session.session_label, "period_type": session.period_type})
|
return JSONResponse({
|
||||||
|
"status": "success",
|
||||||
|
"session_label": session.session_label,
|
||||||
|
"period_type": session.period_type,
|
||||||
|
"period_start_hour": session.period_start_hour,
|
||||||
|
"period_end_hour": session.period_end_hour,
|
||||||
|
"report_date": session.report_date.isoformat() if session.report_date else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/sessions/{session_id}/detail", response_class=HTMLResponse)
|
||||||
|
async def view_session_detail(
|
||||||
|
request: Request,
|
||||||
|
project_id: str,
|
||||||
|
session_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Session detail page: shows files, editable session info, data preview, and report actions.
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
|
|
||||||
|
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.unit_id else None
|
||||||
|
|
||||||
|
# Load all data files for this session
|
||||||
|
files = db.query(DataFile).filter_by(session_id=session_id).order_by(DataFile.created_at).all()
|
||||||
|
|
||||||
|
# Compute effective time range string for display
|
||||||
|
effective_range = None
|
||||||
|
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
|
||||||
|
local_start = utc_to_local(session.started_at)
|
||||||
|
start_day = session.report_date if session.report_date else local_start.date()
|
||||||
|
sh = session.period_start_hour
|
||||||
|
eh = session.period_end_hour
|
||||||
|
def _fmt_h(h):
|
||||||
|
ampm = "AM" if h < 12 else "PM"
|
||||||
|
h12 = h % 12 or 12
|
||||||
|
return f"{h12}:00 {ampm}"
|
||||||
|
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
|
||||||
|
if eh > sh:
|
||||||
|
end_day = start_day
|
||||||
|
else:
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
end_day = start_day + _td(days=1)
|
||||||
|
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
|
||||||
|
effective_range = f"{start_str} → {end_str}"
|
||||||
|
|
||||||
|
# Parse session_metadata if present
|
||||||
|
session_meta = {}
|
||||||
|
if session.session_metadata:
|
||||||
|
try:
|
||||||
|
session_meta = json.loads(session.session_metadata)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return templates.TemplateResponse("session_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"project": project,
|
||||||
|
"project_id": project_id,
|
||||||
|
"session": session,
|
||||||
|
"location": location,
|
||||||
|
"unit": unit,
|
||||||
|
"files": files,
|
||||||
|
"effective_range": effective_range,
|
||||||
|
"session_meta": session_meta,
|
||||||
|
"report_date": session.report_date.isoformat() if session.report_date else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
|
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
|
||||||
@@ -1858,6 +2319,7 @@ async def view_rnd_file(
|
|||||||
|
|
||||||
# Get project info
|
# Get project info
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
|
|
||||||
# Get location info if available
|
# Get location info if available
|
||||||
location = None
|
location = None
|
||||||
@@ -1889,6 +2351,8 @@ async def view_rnd_file(
|
|||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"filename": file_path.name,
|
"filename": file_path.name,
|
||||||
"is_leq": _is_leq_file(str(file_record.file_path), _peek_rnd_headers(file_path)),
|
"is_leq": _is_leq_file(str(file_record.file_path), _peek_rnd_headers(file_path)),
|
||||||
|
"period_start_hour": session.period_start_hour,
|
||||||
|
"period_end_hour": session.period_end_hour,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1901,12 +2365,15 @@ async def get_rnd_data(
|
|||||||
"""
|
"""
|
||||||
Get parsed RND file data as JSON.
|
Get parsed RND file data as JSON.
|
||||||
Returns the measurement data for charts and tables.
|
Returns the measurement data for charts and tables.
|
||||||
|
Sound Monitoring projects only.
|
||||||
"""
|
"""
|
||||||
from backend.models import DataFile
|
from backend.models import DataFile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
|
||||||
|
|
||||||
# Get the file record
|
# Get the file record
|
||||||
file_record = db.query(DataFile).filter_by(id=file_id).first()
|
file_record = db.query(DataFile).filter_by(id=file_id).first()
|
||||||
if not file_record:
|
if not file_record:
|
||||||
@@ -1995,6 +2462,8 @@ async def get_rnd_data(
|
|||||||
"summary": summary,
|
"summary": summary,
|
||||||
"headers": summary["headers"],
|
"headers": summary["headers"],
|
||||||
"data": rows,
|
"data": rows,
|
||||||
|
"period_start_hour": session.period_start_hour,
|
||||||
|
"period_end_hour": session.period_end_hour,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2063,6 +2532,7 @@ async def generate_excel_report(
|
|||||||
|
|
||||||
# Get related data for report context
|
# Get related data for report context
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||||
|
|
||||||
# Build full file path
|
# Build full file path
|
||||||
@@ -2349,7 +2819,7 @@ async def generate_excel_report(
|
|||||||
_plot_border.ln.solidFill = "000000"
|
_plot_border.ln.solidFill = "000000"
|
||||||
_plot_border.ln.w = 12700
|
_plot_border.ln.w = 12700
|
||||||
chart.plot_area.spPr = _plot_border
|
chart.plot_area.spPr = _plot_border
|
||||||
ws.add_chart(chart, "H4")
|
ws.add_chart(chart, "I4")
|
||||||
|
|
||||||
# --- Stats table: note at I28-I29, headers at I31, data rows 32-34 ---
|
# --- Stats table: note at I28-I29, headers at I31, data rows 32-34 ---
|
||||||
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
|
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
|
||||||
@@ -2493,6 +2963,7 @@ async def preview_report_data(
|
|||||||
|
|
||||||
# Get related data for report context
|
# Get related data for report context
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||||
|
|
||||||
# Build full file path
|
# Build full file path
|
||||||
@@ -2704,6 +3175,7 @@ async def generate_report_from_preview(
|
|||||||
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")
|
||||||
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||||
|
|
||||||
# Extract data from request
|
# Extract data from request
|
||||||
@@ -2835,7 +3307,7 @@ async def generate_report_from_preview(
|
|||||||
_plot_border.ln.solidFill = "000000"
|
_plot_border.ln.solidFill = "000000"
|
||||||
_plot_border.ln.w = 12700
|
_plot_border.ln.w = 12700
|
||||||
chart.plot_area.spPr = _plot_border
|
chart.plot_area.spPr = _plot_border
|
||||||
ws.add_chart(chart, "H4")
|
ws.add_chart(chart, "I4")
|
||||||
|
|
||||||
# --- Stats block starting at I28 ---
|
# --- Stats block starting at I28 ---
|
||||||
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
|
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
|
||||||
@@ -2984,6 +3456,7 @@ async def generate_combined_excel_report(
|
|||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
|
|
||||||
# Get all sessions with measurement files
|
# Get all sessions with measurement files
|
||||||
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
||||||
@@ -3182,7 +3655,7 @@ async def generate_combined_excel_report(
|
|||||||
_plot_border.ln.solidFill = "000000"
|
_plot_border.ln.solidFill = "000000"
|
||||||
_plot_border.ln.w = 12700
|
_plot_border.ln.w = 12700
|
||||||
chart.plot_area.spPr = _plot_border
|
chart.plot_area.spPr = _plot_border
|
||||||
ws.add_chart(chart, "H4")
|
ws.add_chart(chart, "I4")
|
||||||
|
|
||||||
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
|
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
|
||||||
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
|
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
|
||||||
@@ -3329,6 +3802,7 @@ async def combined_report_wizard(
|
|||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
|
|
||||||
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()
|
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()
|
||||||
|
|
||||||
@@ -3421,7 +3895,10 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
|
|||||||
"loc_name": loc_name,
|
"loc_name": loc_name,
|
||||||
"session_label": session.session_label or "",
|
"session_label": session.session_label or "",
|
||||||
"period_type": session.period_type or "",
|
"period_type": session.period_type or "",
|
||||||
|
"period_start_hour": session.period_start_hour,
|
||||||
|
"period_end_hour": session.period_end_hour,
|
||||||
"started_at": session.started_at,
|
"started_at": session.started_at,
|
||||||
|
"report_date": session.report_date,
|
||||||
"rows": [],
|
"rows": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3462,25 +3939,39 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
|
|||||||
pass
|
pass
|
||||||
parsed.append((dt, row))
|
parsed.append((dt, row))
|
||||||
|
|
||||||
# Determine which rows to keep based on period_type
|
# Determine effective hour window.
|
||||||
|
# Prefer per-session period_start/end_hour; fall back to hardcoded defaults.
|
||||||
|
sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night
|
||||||
|
eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night
|
||||||
|
if sh is None or eh is None:
|
||||||
|
# Legacy defaults based on period_type
|
||||||
is_day_session = period_type in ('weekday_day', 'weekend_day')
|
is_day_session = period_type in ('weekday_day', 'weekend_day')
|
||||||
|
sh = 7 if is_day_session else 19
|
||||||
|
eh = 19 if is_day_session else 7
|
||||||
|
else:
|
||||||
|
is_day_session = eh > sh # crosses midnight when end < start
|
||||||
|
|
||||||
target_date = None
|
target_date = None
|
||||||
if is_day_session:
|
if is_day_session:
|
||||||
# Day: 07:00–18:59 only, restricted to the LAST calendar date that has daytime rows
|
# Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
|
||||||
|
in_window = lambda h: sh <= h < eh
|
||||||
|
if entry.get("report_date"):
|
||||||
|
target_date = entry["report_date"]
|
||||||
|
else:
|
||||||
daytime_dates = sorted({
|
daytime_dates = sorted({
|
||||||
dt.date() for dt, row in parsed
|
dt.date() for dt, row in parsed if dt and in_window(dt.hour)
|
||||||
if dt and 7 <= dt.hour < 19
|
|
||||||
})
|
})
|
||||||
target_date = daytime_dates[-1] if daytime_dates else None
|
target_date = daytime_dates[-1] if daytime_dates else None
|
||||||
filtered = [
|
filtered = [
|
||||||
(dt, row) for dt, row in parsed
|
(dt, row) for dt, row in parsed
|
||||||
if dt and dt.date() == target_date and 7 <= dt.hour < 19
|
if dt and dt.date() == target_date and in_window(dt.hour)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# Night: 19:00–06:59, spanning both calendar days — no date restriction
|
# Night-style: hour >= start_h OR hour < end_h (crosses midnight)
|
||||||
|
in_window = lambda h: h >= sh or h < eh
|
||||||
filtered = [
|
filtered = [
|
||||||
(dt, row) for dt, row in parsed
|
(dt, row) for dt, row in parsed
|
||||||
if dt and (dt.hour >= 19 or dt.hour < 7)
|
if dt and in_window(dt.hour)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Fall back to all rows if filtering removed everything
|
# Fall back to all rows if filtering removed everything
|
||||||
@@ -3598,6 +4089,7 @@ async def generate_combined_from_preview(
|
|||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
|
|
||||||
report_title = data.get("report_title", "Background Noise Study")
|
report_title = data.get("report_title", "Background Noise Study")
|
||||||
project_name = data.get("project_name", project.name)
|
project_name = data.get("project_name", project.name)
|
||||||
@@ -3760,7 +4252,7 @@ async def generate_combined_from_preview(
|
|||||||
_plot_border.ln.solidFill = "000000"
|
_plot_border.ln.solidFill = "000000"
|
||||||
_plot_border.ln.w = 12700
|
_plot_border.ln.w = 12700
|
||||||
chart.plot_area.spPr = _plot_border
|
chart.plot_area.spPr = _plot_border
|
||||||
ws.add_chart(chart, "H4")
|
ws.add_chart(chart, "I4")
|
||||||
|
|
||||||
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||||
|
|
||||||
@@ -4073,6 +4565,7 @@ async def upload_all_project_data(
|
|||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
_require_module(project, "sound_monitoring", db)
|
||||||
|
|
||||||
# Load all sound monitoring locations for this project
|
# Load all sound monitoring locations for this project
|
||||||
locations = db.query(MonitoringLocation).filter_by(
|
locations = db.query(MonitoringLocation).filter_by(
|
||||||
|
|||||||
@@ -73,10 +73,16 @@ def jinja_log_tail_display(s):
|
|||||||
return str(s)
|
return str(s)
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_local_datetime_input(dt):
|
||||||
|
"""Jinja filter: format UTC datetime as local YYYY-MM-DDTHH:MM for <input type='datetime-local'>."""
|
||||||
|
return format_local_datetime(dt, "%Y-%m-%dT%H:%M")
|
||||||
|
|
||||||
|
|
||||||
# Register Jinja filters and globals
|
# Register Jinja filters and globals
|
||||||
templates.env.filters["local_datetime"] = jinja_local_datetime
|
templates.env.filters["local_datetime"] = jinja_local_datetime
|
||||||
templates.env.filters["local_time"] = jinja_local_time
|
templates.env.filters["local_time"] = jinja_local_time
|
||||||
templates.env.filters["local_date"] = jinja_local_date
|
templates.env.filters["local_date"] = jinja_local_date
|
||||||
|
templates.env.filters["local_datetime_input"] = jinja_local_datetime_input
|
||||||
templates.env.filters["fromjson"] = jinja_fromjson
|
templates.env.filters["fromjson"] = jinja_fromjson
|
||||||
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
||||||
templates.env.globals["get_user_timezone"] = get_user_timezone
|
templates.env.globals["get_user_timezone"] = get_user_timezone
|
||||||
|
|||||||
@@ -63,16 +63,25 @@ Include this modal in pages that use the project picker.
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Project Type <span class="text-red-500">*</span>
|
Modules
|
||||||
|
<span class="text-gray-400 font-normal">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="project_type_id"
|
<div class="grid grid-cols-2 gap-2">
|
||||||
id="qcp-project-type"
|
<label class="flex items-center gap-2 p-2.5 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-orange-400 has-[:checked]:border-orange-400 has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-orange-900/20 transition-colors">
|
||||||
required
|
<input type="checkbox" name="module_sound" value="1" class="accent-seismo-orange">
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
<div>
|
||||||
<option value="vibration_monitoring">Vibration Monitoring</option>
|
<p class="text-sm font-medium text-gray-900 dark:text-white leading-tight">Sound</p>
|
||||||
<option value="sound_monitoring">Sound Monitoring</option>
|
<p class="text-xs text-gray-500 dark:text-gray-400">SLMs, sessions, reports</p>
|
||||||
<option value="combined">Combined (Vibration + Sound)</option>
|
</div>
|
||||||
</select>
|
</label>
|
||||||
|
<label class="flex items-center gap-2 p-2.5 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-blue-400 has-[:checked]:border-blue-400 has-[:checked]:bg-blue-50 dark:has-[:checked]:bg-blue-900/20 transition-colors">
|
||||||
|
<input type="checkbox" name="module_vibration" value="1" class="accent-blue-500">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white leading-tight">Vibration</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Seismographs, modems</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -222,6 +231,20 @@ if (typeof openCreateProjectModal === 'undefined') {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
|
const projectId = result.project_id;
|
||||||
|
|
||||||
|
// Add selected modules
|
||||||
|
const moduleMap = { module_sound: 'sound_monitoring', module_vibration: 'vibration_monitoring' };
|
||||||
|
for (const [field, moduleType] of Object.entries(moduleMap)) {
|
||||||
|
if (formData.get(field)) {
|
||||||
|
await fetch(`/api/projects/${projectId}/modules`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ module_type: moduleType }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build display text from form values
|
// Build display text from form values
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const projectNumber = formData.get('project_number');
|
const projectNumber = formData.get('project_number');
|
||||||
@@ -235,7 +258,7 @@ if (typeof openCreateProjectModal === 'undefined') {
|
|||||||
const displayText = parts.join(' - ');
|
const displayText = parts.join(' - ');
|
||||||
|
|
||||||
// Select the newly created project in the picker
|
// Select the newly created project in the picker
|
||||||
selectProject(result.project_id, displayText, pickerId);
|
selectProject(projectId, displayText, pickerId);
|
||||||
|
|
||||||
// Close modal
|
// Close modal
|
||||||
closeCreateProjectModal();
|
closeCreateProjectModal();
|
||||||
|
|||||||
@@ -52,14 +52,14 @@
|
|||||||
<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-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||||||
NRLs
|
NRLs
|
||||||
{% else %}
|
{% else %}
|
||||||
Locations
|
Locations
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<button onclick="openLocationModal('{% if project_type and project_type.id == 'sound_monitoring' %}sound{% elif project_type and project_type.id == 'vibration_monitoring' %}vibration{% else %}{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
|
<button onclick="openLocationModal('{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}sound{% elif 'vibration_monitoring' in modules and 'sound_monitoring' not in modules %}vibration{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
|
||||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||||||
Add NRL
|
Add NRL
|
||||||
{% else %}
|
{% else %}
|
||||||
Add Location
|
Add Location
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="project-locations"
|
<div id="project-locations"
|
||||||
hx-get="/api/projects/{{ project.id }}/locations{% if project_type and project_type.id == 'sound_monitoring' %}?location_type=sound{% endif %}"
|
hx-get="/api/projects/{{ project.id }}/locations{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}?location_type=sound{% endif %}"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="animate-pulse space-y-3">
|
<div class="animate-pulse space-y-3">
|
||||||
|
|||||||
@@ -23,9 +23,30 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% if project_type %}
|
<!-- Module badges -->
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
<div id="module-badges" class="flex items-center gap-1.5 flex-wrap">
|
||||||
{% endif %}
|
{% for m in modules %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium
|
||||||
|
{% if m == 'sound_monitoring' %}bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300
|
||||||
|
{% elif m == 'vibration_monitoring' %}bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300
|
||||||
|
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||||
|
{% if m == 'sound_monitoring' %}
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
|
||||||
|
Sound Monitoring
|
||||||
|
{% elif m == 'vibration_monitoring' %}
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
Vibration Monitoring
|
||||||
|
{% else %}{{ m }}{% endif %}
|
||||||
|
<button onclick="removeModule('{{ m }}')" class="ml-0.5 hover:text-red-500 transition-colors" title="Remove module">
|
||||||
|
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
<button onclick="openAddModuleModal()" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border border-dashed border-gray-400 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-orange-400 hover:text-orange-500 transition-colors">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||||
|
Add Module
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% if project.data_collection_mode == 'remote' %}
|
{% if project.data_collection_mode == 'remote' %}
|
||||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -45,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Project Actions -->
|
<!-- Project Actions -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
{% if 'sound_monitoring' in modules %}
|
||||||
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
|
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
|
||||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -57,3 +78,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Module Modal -->
|
||||||
|
<div id="add-module-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add Module</h3>
|
||||||
|
<button onclick="closeAddModuleModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="add-module-options" class="space-y-2">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</div>
|
||||||
|
<p id="add-module-none" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-4">All available modules are already enabled.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const _MODULE_META = {
|
||||||
|
sound_monitoring: { name: "Sound Monitoring", color: "orange", icon: "M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072" },
|
||||||
|
vibration_monitoring: { name: "Vibration Monitoring", color: "blue", icon: "M22 12h-4l-3 9L9 3l-3 9H2" },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function openAddModuleModal() {
|
||||||
|
const resp = await fetch(`/api/projects/${projectId}/modules`);
|
||||||
|
const data = await resp.json();
|
||||||
|
const container = document.getElementById('add-module-options');
|
||||||
|
const none = document.getElementById('add-module-none');
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!data.available || data.available.length === 0) {
|
||||||
|
none.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
none.classList.add('hidden');
|
||||||
|
data.available.forEach(m => {
|
||||||
|
const meta = _MODULE_META[m.module_type] || { name: m.module_type, color: 'gray' };
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = `w-full text-left px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-${meta.color}-400 hover:bg-${meta.color}-50 dark:hover:bg-${meta.color}-900/20 transition-colors flex items-center gap-3`;
|
||||||
|
btn.innerHTML = `<span class="flex-1 font-medium text-gray-900 dark:text-white">${meta.name}</span>`;
|
||||||
|
btn.onclick = () => addModule(m.module_type);
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('add-module-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddModuleModal() {
|
||||||
|
document.getElementById('add-module-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addModule(moduleType) {
|
||||||
|
await fetch(`/api/projects/${projectId}/modules`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ module_type: moduleType }),
|
||||||
|
});
|
||||||
|
closeAddModuleModal();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeModule(moduleType) {
|
||||||
|
const meta = _MODULE_META[moduleType] || { name: moduleType };
|
||||||
|
if (!confirm(`Remove the ${meta.name} module? The data will not be deleted, but the related tabs will be hidden.`)) return;
|
||||||
|
await fetch(`/api/projects/${projectId}/modules/${moduleType}`, { method: 'DELETE' });
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% set s = item.session %}
|
{% set s = item.session %}
|
||||||
{% set loc = item.location %}
|
{% set loc = item.location %}
|
||||||
{% set unit = item.unit %}
|
{% set unit = item.unit %}
|
||||||
|
{% set effective_range = item.effective_range %}
|
||||||
|
|
||||||
{# Period display maps #}
|
{# Period display maps #}
|
||||||
{% set period_labels = {
|
{% set period_labels = {
|
||||||
@@ -49,27 +50,76 @@
|
|||||||
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Period type badge (click to change) -->
|
<!-- Period type badge (click to open hour editor) -->
|
||||||
<div class="relative" id="period-wrap-{{ s.id }}">
|
<div class="relative" id="period-wrap-{{ s.id }}">
|
||||||
<button onclick="togglePeriodMenu('{{ s.id }}')"
|
<button onclick="openPeriodEditor('{{ s.id }}')"
|
||||||
id="period-badge-{{ s.id }}"
|
id="period-badge-{{ s.id }}"
|
||||||
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||||
title="Click to change period type">
|
title="Click to edit period type and hours">
|
||||||
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
||||||
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="period-menu-{{ s.id }}"
|
|
||||||
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
|
<!-- Period editor panel -->
|
||||||
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
|
<div id="period-editor-{{ s.id }}"
|
||||||
<button onclick="setPeriodType('{{ s.id }}', '{{ pt }}')"
|
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg w-64 p-3 space-y-3">
|
||||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
|
|
||||||
|
<!-- Period type selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Period Type</label>
|
||||||
|
<div class="grid grid-cols-2 gap-1">
|
||||||
|
{% for pt, pt_label in [('weekday_day','WD Day'),('weekday_night','WD Night'),('weekend_day','WE Day'),('weekend_night','WE Night')] %}
|
||||||
|
<button onclick="selectPeriodType('{{ s.id }}', '{{ pt }}')"
|
||||||
|
id="pt-btn-{{ s.id }}-{{ pt }}"
|
||||||
|
class="period-type-btn text-xs py-1 px-2 rounded border transition-colors
|
||||||
|
{% if s.period_type == pt %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
|
||||||
{{ pt_label }}
|
{{ pt_label }}
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hour inputs -->
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Hour (0–23)</label>
|
||||||
|
<input type="number" min="0" max="23"
|
||||||
|
id="period-start-hr-{{ s.id }}"
|
||||||
|
value="{{ s.period_start_hour if s.period_start_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 19"
|
||||||
|
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Hour (0–23)</label>
|
||||||
|
<input type="number" min="0" max="23"
|
||||||
|
id="period-end-hr-{{ s.id }}"
|
||||||
|
value="{{ s.period_end_hour if s.period_end_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 7"
|
||||||
|
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">Day: 7→19 · Night: 19→7 · Customize as needed</p>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button onclick="savePeriodEditor('{{ s.id }}')"
|
||||||
|
class="flex-1 text-xs py-1 bg-seismo-orange text-white rounded hover:bg-orange-600 transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onclick="closePeriodEditor('{{ s.id }}')"
|
||||||
|
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="clearPeriodEditor('{{ s.id }}')"
|
||||||
|
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-500 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors"
|
||||||
|
title="Clear period type and hours">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info grid -->
|
<!-- Info grid -->
|
||||||
@@ -131,8 +181,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if s.notes %}
|
<!-- Effective window (when period hours are set) -->
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
|
{% if effective_range %}
|
||||||
|
<div class="flex items-center gap-1 mt-1.5 text-xs text-indigo-600 dark:text-indigo-400">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="effective-range-{{ s.id }}">Effective: {{ effective_range }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="hidden text-xs text-indigo-600 dark:text-indigo-400 mt-1.5"
|
||||||
|
id="effective-range-{{ s.id }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,6 +222,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const PROJECT_ID = '{{ project_id }}';
|
||||||
|
|
||||||
const PERIOD_COLORS = {
|
const PERIOD_COLORS = {
|
||||||
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
@@ -175,47 +236,146 @@ const PERIOD_LABELS = {
|
|||||||
weekend_day: 'Weekend Day',
|
weekend_day: 'Weekend Day',
|
||||||
weekend_night: 'Weekend Night',
|
weekend_night: 'Weekend Night',
|
||||||
};
|
};
|
||||||
|
// Default hours for each period type
|
||||||
|
const PERIOD_DEFAULT_HOURS = {
|
||||||
|
weekday_day: {start: 7, end: 19},
|
||||||
|
weekday_night: {start: 19, end: 7},
|
||||||
|
weekend_day: {start: 7, end: 19},
|
||||||
|
weekend_night: {start: 19, end: 7},
|
||||||
|
};
|
||||||
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
|
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
|
||||||
const ALL_BADGE_COLORS = [...new Set([
|
const ALL_BADGE_COLORS = [...new Set([
|
||||||
...FALLBACK_COLORS,
|
...FALLBACK_COLORS,
|
||||||
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
|
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
|
||||||
])];
|
])];
|
||||||
|
|
||||||
function togglePeriodMenu(sessionId) {
|
// Track which period type is selected in the editor before saving
|
||||||
const menu = document.getElementById('period-menu-' + sessionId);
|
const _editorState = {};
|
||||||
document.querySelectorAll('[id^="period-menu-"]').forEach(m => {
|
|
||||||
if (m.id !== 'period-menu-' + sessionId) m.classList.add('hidden');
|
// ---- Period editor ----
|
||||||
|
|
||||||
|
function openPeriodEditor(sessionId) {
|
||||||
|
// Close all other editors first
|
||||||
|
document.querySelectorAll('[id^="period-editor-"]').forEach(el => {
|
||||||
|
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
|
||||||
});
|
});
|
||||||
menu.classList.toggle('hidden');
|
document.getElementById('period-editor-' + sessionId).classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
function closePeriodEditor(sessionId) {
|
||||||
if (!e.target.closest('[id^="period-wrap-"]')) {
|
document.getElementById('period-editor-' + sessionId).classList.add('hidden');
|
||||||
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden'));
|
delete _editorState[sessionId];
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
async function setPeriodType(sessionId, periodType) {
|
function selectPeriodType(sessionId, pt) {
|
||||||
document.getElementById('period-menu-' + sessionId).classList.add('hidden');
|
_editorState[sessionId] = pt;
|
||||||
const badge = document.getElementById('period-badge-' + sessionId);
|
// Highlight selected button
|
||||||
badge.disabled = true;
|
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
|
||||||
|
const isSelected = btn.id === `pt-btn-${sessionId}-${pt}`;
|
||||||
|
btn.classList.toggle('border-seismo-orange', isSelected);
|
||||||
|
btn.classList.toggle('bg-orange-50', isSelected);
|
||||||
|
btn.classList.toggle('text-seismo-orange', isSelected);
|
||||||
|
btn.classList.toggle('dark:bg-orange-900/20', isSelected);
|
||||||
|
btn.classList.toggle('border-gray-200', !isSelected);
|
||||||
|
btn.classList.toggle('dark:border-gray-600', !isSelected);
|
||||||
|
btn.classList.toggle('text-gray-600', !isSelected);
|
||||||
|
btn.classList.toggle('dark:text-gray-400', !isSelected);
|
||||||
|
});
|
||||||
|
// Fill default hours
|
||||||
|
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||||
|
if (defaults) {
|
||||||
|
const sh = document.getElementById('period-start-hr-' + sessionId);
|
||||||
|
const eh = document.getElementById('period-end-hr-' + sessionId);
|
||||||
|
if (sh && !sh.value) sh.value = defaults.start;
|
||||||
|
if (eh && !eh.value) eh.value = defaults.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePeriodEditor(sessionId) {
|
||||||
|
const pt = _editorState[sessionId] || document.getElementById('period-badge-' + sessionId)
|
||||||
|
?.dataset?.currentPeriod || null;
|
||||||
|
const shInput = document.getElementById('period-start-hr-' + sessionId);
|
||||||
|
const ehInput = document.getElementById('period-end-hr-' + sessionId);
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
if (pt !== undefined) payload.period_type = pt || null;
|
||||||
|
payload.period_start_hour = shInput?.value !== '' ? parseInt(shInput.value, 10) : null;
|
||||||
|
payload.period_end_hour = ehInput?.value !== '' ? parseInt(ehInput.value, 10) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({period_type: periodType}),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await resp.text());
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
// Update badge
|
||||||
|
const badge = document.getElementById('period-badge-' + sessionId);
|
||||||
|
const label = document.getElementById('period-label-' + sessionId);
|
||||||
|
const newPt = result.period_type;
|
||||||
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
||||||
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean));
|
if (newPt && PERIOD_COLORS[newPt]) {
|
||||||
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
|
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
|
||||||
|
if (label) label.textContent = PERIOD_LABELS[newPt];
|
||||||
|
} else {
|
||||||
|
badge.classList.add(...FALLBACK_COLORS);
|
||||||
|
if (label) label.textContent = 'Set period';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update effective range display
|
||||||
|
_updateEffectiveRange(sessionId, result.period_start_hour, result.period_end_hour);
|
||||||
|
|
||||||
|
closePeriodEditor(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to update period type: ' + err.message);
|
alert('Failed to save period: ' + err.message);
|
||||||
} finally {
|
|
||||||
badge.disabled = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearPeriodEditor(sessionId) {
|
||||||
|
const shInput = document.getElementById('period-start-hr-' + sessionId);
|
||||||
|
const ehInput = document.getElementById('period-end-hr-' + sessionId);
|
||||||
|
if (shInput) shInput.value = '';
|
||||||
|
if (ehInput) ehInput.value = '';
|
||||||
|
_editorState[sessionId] = null;
|
||||||
|
|
||||||
|
// Reset period type button highlights
|
||||||
|
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
|
||||||
|
btn.classList.remove('border-seismo-orange','bg-orange-50','text-seismo-orange','dark:bg-orange-900/20');
|
||||||
|
btn.classList.add('border-gray-200','dark:border-gray-600','text-gray-600','dark:text-gray-400');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Effective range helper ----
|
||||||
|
|
||||||
|
function _updateEffectiveRange(sessionId, startHour, endHour) {
|
||||||
|
const el = document.getElementById('effective-range-' + sessionId);
|
||||||
|
if (!el) return;
|
||||||
|
if (startHour == null || endHour == null) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
function _fmt(h) {
|
||||||
|
const ampm = h < 12 ? 'AM' : 'PM';
|
||||||
|
const h12 = h % 12 || 12;
|
||||||
|
return `${h12}:00 ${ampm}`;
|
||||||
|
}
|
||||||
|
// We don't have the session start date in JS so just show the hours pattern
|
||||||
|
el.textContent = `Effective window: ${_fmt(startHour)} → ${_fmt(endHour)}`;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Close editors on outside click ----
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('[id^="period-wrap-"]')) {
|
||||||
|
document.querySelectorAll('[id^="period-editor-"]').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Label editing ----
|
||||||
|
|
||||||
function startEditLabel(sessionId) {
|
function startEditLabel(sessionId) {
|
||||||
document.getElementById('label-display-' + sessionId).classList.add('hidden');
|
document.getElementById('label-display-' + sessionId).classList.add('hidden');
|
||||||
const input = document.getElementById('label-input-' + sessionId);
|
const input = document.getElementById('label-input-' + sessionId);
|
||||||
@@ -234,7 +394,7 @@ async function saveLabel(sessionId) {
|
|||||||
const input = document.getElementById('label-input-' + sessionId);
|
const input = document.getElementById('label-input-' + sessionId);
|
||||||
const newLabel = input.value.trim();
|
const newLabel = input.value.trim();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({session_label: newLabel}),
|
body: JSON.stringify({session_label: newLabel}),
|
||||||
@@ -249,8 +409,10 @@ async function saveLabel(sessionId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Session details ----
|
||||||
|
|
||||||
function viewSession(sessionId) {
|
function viewSession(sessionId) {
|
||||||
alert('Session details coming soon: ' + sessionId);
|
window.location.href = `/api/projects/${PROJECT_ID}/sessions/${sessionId}/detail`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording(sessionId) {
|
function stopRecording(sessionId) {
|
||||||
|
|||||||
124
templates/partials/projects/sessions_calendar.html
Normal file
124
templates/partials/projects/sessions_calendar.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!-- Monthly Sessions Calendar — Gantt Style -->
|
||||||
|
<div class="sessions-cal-wrap bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Month navigation -->
|
||||||
|
<div class="px-5 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ prev_month }}&year={{ prev_year }}"
|
||||||
|
hx-target="#sessions-calendar"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
|
||||||
|
title="Previous month">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ month_name }} {{ year }}</h3>
|
||||||
|
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ next_month }}&year={{ next_year }}"
|
||||||
|
hx-target="#sessions-calendar"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
|
||||||
|
title="Next month">
|
||||||
|
<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="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend + key -->
|
||||||
|
<div class="px-5 py-2 border-b border-gray-100 dark:border-gray-700 flex flex-wrap items-center gap-x-5 gap-y-1.5">
|
||||||
|
{% if legend %}
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{% for loc in legend %}
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background-color: {{ loc.color }}"></span>
|
||||||
|
<span>{{ loc.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Bar key -->
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500 ml-auto shrink-0">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-8 h-2 rounded-sm" style="background:rgba(100,100,100,0.25)"></div>
|
||||||
|
<span>Device on</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-8 h-2 rounded-sm bg-blue-500"></div>
|
||||||
|
<span>Effective window</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day-of-week headers -->
|
||||||
|
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
{% for day_name in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %}
|
||||||
|
<div class="py-2 text-center text-xs font-medium uppercase tracking-wide
|
||||||
|
{% if loop.index == 1 or loop.index == 7 %}text-amber-500 dark:text-amber-400{% else %}text-gray-400 dark:text-gray-500{% endif %}">
|
||||||
|
{{ day_name }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar grid -->
|
||||||
|
{% for week in weeks %}
|
||||||
|
<div class="grid grid-cols-7 {% if not loop.last %}border-b border-gray-100 dark:border-gray-700{% endif %}">
|
||||||
|
{% for day in week %}
|
||||||
|
<div class="min-h-[80px] p-1.5
|
||||||
|
{% if not loop.last %}border-r border-gray-100 dark:border-gray-700{% endif %}
|
||||||
|
{% if not day.in_month %}bg-gray-50 dark:bg-slate-800/50{% else %}bg-white dark:bg-slate-800{% endif %}
|
||||||
|
{% if day.is_today %}ring-1 ring-inset ring-seismo-orange{% endif %}">
|
||||||
|
|
||||||
|
<!-- Date number -->
|
||||||
|
<div class="text-right mb-1.5">
|
||||||
|
<span class="text-xs {% if day.is_today %}font-bold text-seismo-orange{% elif day.in_month %}text-gray-700 dark:text-gray-300{% else %}text-gray-300 dark:text-gray-600{% endif %}">
|
||||||
|
{{ day.date.day }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gantt bars -->
|
||||||
|
{% if day.sessions %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for s in day.sessions %}
|
||||||
|
|
||||||
|
<a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail" class="block">
|
||||||
|
|
||||||
|
<!-- 24-hour timeline bar -->
|
||||||
|
<div class="relative overflow-hidden -mx-1.5" style="height:11px; background:rgba(128,128,128,0.08);">
|
||||||
|
<div class="absolute top-0 bottom-0 w-px" style="left:25%; background:rgba(128,128,128,0.18)"></div>
|
||||||
|
<div class="absolute top-0 bottom-0 w-px" style="left:50%; background:rgba(128,128,128,0.28)"></div>
|
||||||
|
<div class="absolute top-0 bottom-0 w-px" style="left:75%; background:rgba(128,128,128,0.18)"></div>
|
||||||
|
<div class="absolute top-0 bottom-0"
|
||||||
|
style="left:{{ s.dev_start_pct }}%; width:{{ s.dev_width_pct }}%; background-color:{{ s.color }}; opacity:0.28;"></div>
|
||||||
|
{% if s.eff_start_pct is not none %}
|
||||||
|
<div class="absolute"
|
||||||
|
style="left:{{ s.eff_start_pct }}%; width:{{ s.eff_width_pct }}%; top:1.5px; bottom:1.5px; background-color:{{ s.color }};"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<div class="truncate mt-0.5" style="color:{{ s.color }}; font-size:0.58rem; line-height:1.3;">
|
||||||
|
{{ s.location_name }} · {{ day.date.strftime('%-m/%-d') }} · {% if s.period_type %}{{ 'Night' if 'night' in s.period_type else 'Day' }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Time scale footer hint -->
|
||||||
|
<div class="px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 flex justify-between text-gray-300 dark:text-gray-600" style="font-size:0.6rem;">
|
||||||
|
<span>12 AM</span>
|
||||||
|
<span>6 AM</span>
|
||||||
|
<span>12 PM</span>
|
||||||
|
<span>6 PM</span>
|
||||||
|
<span>12 AM</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -56,6 +56,15 @@
|
|||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||||
{{ session.status or 'unknown' }}
|
{{ session.status or 'unknown' }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Edit Session Times -->
|
||||||
|
<button onclick="event.stopPropagation(); openEditSessionModal('{{ session.id }}', '{{ session.started_at|local_datetime if session.started_at else '' }}', '{{ session.stopped_at|local_datetime if session.stopped_at else '' }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
|
||||||
|
title="Edit session times">
|
||||||
|
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<!-- Download All Files in Session -->
|
<!-- Download All Files in Session -->
|
||||||
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
|
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
|
||||||
@@ -326,4 +335,74 @@ async function deleteSession(sessionId) {
|
|||||||
alert(`Error deleting session: ${error.message}`);
|
alert(`Error deleting session: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEditSessionModal(sessionId, startedAt, stoppedAt) {
|
||||||
|
document.getElementById('editSessionId').value = sessionId;
|
||||||
|
// local_datetime filter returns "YYYY-MM-DD HH:MM" — convert to "YYYY-MM-DDTHH:MM" for datetime-local input
|
||||||
|
document.getElementById('editStartedAt').value = startedAt ? startedAt.replace(' ', 'T') : '';
|
||||||
|
document.getElementById('editStoppedAt').value = stoppedAt ? stoppedAt.replace(' ', 'T') : '';
|
||||||
|
document.getElementById('editSessionModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditSessionModal() {
|
||||||
|
document.getElementById('editSessionModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSessionTimes() {
|
||||||
|
const sessionId = document.getElementById('editSessionId').value;
|
||||||
|
const startedAt = document.getElementById('editStartedAt').value;
|
||||||
|
const stoppedAt = document.getElementById('editStoppedAt').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
started_at: startedAt || null,
|
||||||
|
stopped_at: stoppedAt || null,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
closeEditSessionModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Failed to update session: ${data.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error updating session: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Edit Session Times Modal -->
|
||||||
|
<div id="editSessionModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Session Times</h3>
|
||||||
|
<input type="hidden" id="editSessionId">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
|
||||||
|
<input type="datetime-local" id="editStartedAt"
|
||||||
|
class="w-full 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 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stop Time</label>
|
||||||
|
<input type="datetime-local" id="editStoppedAt"
|
||||||
|
class="w-full 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 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">Times are in your local timezone. The session label and period type will be updated automatically.</p>
|
||||||
|
<div class="flex justify-end gap-3 mt-5">
|
||||||
|
<button onclick="closeEditSessionModal()"
|
||||||
|
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="saveSessionTimes()"
|
||||||
|
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -208,6 +208,19 @@
|
|||||||
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly Calendar -->
|
||||||
|
<div class="mt-6 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Calendar View</h3>
|
||||||
|
</div>
|
||||||
|
<div id="sessions-calendar"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/sessions-calendar"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-6 text-gray-400 text-sm">Loading calendar…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Data Files Tab -->
|
<!-- Data Files Tab -->
|
||||||
@@ -757,7 +770,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const projectId = "{{ project_id }}";
|
const projectId = "{{ project_id }}";
|
||||||
let editingLocationId = null;
|
let editingLocationId = null;
|
||||||
let projectTypeId = null;
|
let projectModules = []; // list of enabled module_type strings, e.g. ['sound_monitoring']
|
||||||
|
|
||||||
async function quickUpdateStatus(newStatus) {
|
async function quickUpdateStatus(newStatus) {
|
||||||
try {
|
try {
|
||||||
@@ -815,7 +828,7 @@ async function loadProjectDetails() {
|
|||||||
throw new Error('Failed to load project details');
|
throw new Error('Failed to load project details');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
projectTypeId = data.project_type_id || null;
|
projectModules = data.modules || [];
|
||||||
|
|
||||||
// Update breadcrumb
|
// Update breadcrumb
|
||||||
document.getElementById('project-name-breadcrumb').textContent = data.name || 'Project';
|
document.getElementById('project-name-breadcrumb').textContent = data.name || 'Project';
|
||||||
@@ -836,9 +849,10 @@ async function loadProjectDetails() {
|
|||||||
if (modeRadio) modeRadio.checked = true;
|
if (modeRadio) modeRadio.checked = true;
|
||||||
settingsUpdateModeStyles();
|
settingsUpdateModeStyles();
|
||||||
|
|
||||||
// Update tab labels and visibility based on project type
|
// Update tab labels and visibility based on active modules
|
||||||
const isSoundProject = projectTypeId === 'sound_monitoring';
|
const hasSoundModule = projectModules.includes('sound_monitoring');
|
||||||
if (isSoundProject) {
|
const hasVibrationModule = projectModules.includes('vibration_monitoring');
|
||||||
|
if (hasSoundModule && !hasVibrationModule) {
|
||||||
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
||||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||||
@@ -846,11 +860,11 @@ async function loadProjectDetails() {
|
|||||||
// Monitoring Sessions and Data Files tabs are sound-only
|
// Monitoring Sessions and Data Files tabs are sound-only
|
||||||
// Data Files also hides the FTP browser section for manual projects
|
// Data Files also hides the FTP browser section for manual projects
|
||||||
const isRemote = mode === 'remote';
|
const isRemote = mode === 'remote';
|
||||||
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !hasSoundModule);
|
||||||
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('data-tab-btn').classList.toggle('hidden', !hasSoundModule);
|
||||||
// Schedules and Assigned Units are remote-only (manual projects collect data by hand)
|
// Schedules and Assigned Units: hidden when no sound module; for sound, only show if remote
|
||||||
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', !hasSoundModule || !isRemote);
|
||||||
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
document.getElementById('units-tab-btn')?.classList.toggle('hidden', !hasSoundModule || !isRemote);
|
||||||
// FTP browser within Data Files tab
|
// FTP browser within Data Files tab
|
||||||
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
|
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
|
||||||
|
|
||||||
@@ -982,11 +996,13 @@ function openLocationModal(defaultType) {
|
|||||||
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
|
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
|
||||||
const locationTypeSelect = document.getElementById('location-type');
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
const hasSoundMod = projectModules.includes('sound_monitoring');
|
||||||
|
const hasVibMod = projectModules.includes('vibration_monitoring');
|
||||||
|
if (hasSoundMod && !hasVibMod) {
|
||||||
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') {
|
} else if (hasVibMod && !hasSoundMod) {
|
||||||
locationTypeSelect.value = 'vibration';
|
locationTypeSelect.value = 'vibration';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
@@ -1016,11 +1032,13 @@ function openEditLocationModal(button) {
|
|||||||
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
|
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
|
||||||
const locationTypeSelect = document.getElementById('location-type');
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
const hasSoundModE = projectModules.includes('sound_monitoring');
|
||||||
|
const hasVibModE = projectModules.includes('vibration_monitoring');
|
||||||
|
if (hasSoundModE && !hasVibModE) {
|
||||||
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') {
|
} else if (hasVibModE && !hasSoundModE) {
|
||||||
locationTypeSelect.value = 'vibration';
|
locationTypeSelect.value = 'vibration';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
@@ -1046,9 +1064,9 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
const address = document.getElementById('location-address').value.trim();
|
const address = document.getElementById('location-address').value.trim();
|
||||||
const coordinates = document.getElementById('location-coordinates').value.trim();
|
const coordinates = document.getElementById('location-coordinates').value.trim();
|
||||||
let locationType = document.getElementById('location-type').value;
|
let locationType = document.getElementById('location-type').value;
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectModules.includes('sound_monitoring') && !projectModules.includes('vibration_monitoring')) {
|
||||||
locationType = 'sound';
|
locationType = 'sound';
|
||||||
} else if (projectTypeId === 'vibration_monitoring') {
|
} else if (projectModules.includes('vibration_monitoring') && !projectModules.includes('sound_monitoring')) {
|
||||||
locationType = 'vibration';
|
locationType = 'vibration';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1828,5 +1846,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
switchTab(hash);
|
switchTab(hash);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -96,48 +96,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6" id="createProjectContent">
|
<div class="p-6" id="createProjectContent">
|
||||||
<!-- Step 1: Project Type Selection (initially shown) -->
|
<form id="createProjectFormElement">
|
||||||
<div id="projectTypeSelection">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Choose Project Type</h3>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
|
||||||
hx-get="/api/projects/types/list"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-target="this"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- Project type cards will be loaded here -->
|
|
||||||
<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 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) -->
|
|
||||||
<div id="projectDetailsForm" class="hidden">
|
|
||||||
<button onclick="backToTypeSelection()"
|
|
||||||
class="mb-4 text-seismo-orange hover:text-seismo-navy">
|
|
||||||
← Back to project types
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<form id="createProjectFormElement"
|
|
||||||
hx-post="/api/projects/create"
|
|
||||||
hx-swap="none">
|
|
||||||
<input type="hidden" id="project_type_id" name="project_type_id">
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Project Name *
|
Project Name <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Project Number
|
||||||
|
<span class="text-gray-400 font-normal">(xxxx-YY)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="project_number"
|
||||||
|
pattern="\d{4}-\d{2}"
|
||||||
|
placeholder="2567-23"
|
||||||
|
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 focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Client Name
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="client_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 focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -145,61 +135,70 @@
|
|||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea name="description"
|
<textarea name="description"
|
||||||
rows="3"
|
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>
|
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 focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Client Name
|
|
||||||
</label>
|
|
||||||
<input type="text"
|
|
||||||
name="client_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">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Site Address
|
Site Address
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="site_address"
|
name="site_address"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Site Coordinates
|
||||||
|
<span class="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="site_coordinates"
|
||||||
|
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 focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Start Date</label>
|
||||||
Start Date
|
<input type="date" name="start_date"
|
||||||
</label>
|
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 focus:ring-2 focus:ring-seismo-orange">
|
||||||
<input type="date"
|
|
||||||
name="start_date"
|
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">End Date <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||||
End Date (Optional)
|
<input type="date" name="end_date"
|
||||||
</label>
|
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 focus:ring-2 focus:ring-seismo-orange">
|
||||||
<input type="date"
|
|
||||||
name="end_date"
|
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Modules -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Site Coordinates (Optional)
|
Add Modules
|
||||||
|
<span class="text-gray-400 font-normal">(optional — can be added later)</span>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex items-center gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-orange-400 has-[:checked]:border-orange-400 has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-orange-900/20 transition-colors">
|
||||||
|
<input type="checkbox" id="ov-module-sound" class="accent-seismo-orange">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Sound Monitoring</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">SLMs, sessions, reports</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-blue-400 has-[:checked]:border-blue-400 has-[:checked]:bg-blue-50 dark:has-[:checked]:bg-blue-900/20 transition-colors">
|
||||||
|
<input type="checkbox" id="ov-module-vibration" class="accent-blue-500">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Vibration Monitoring</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Seismographs, modems</p>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
|
||||||
name="site_coordinates"
|
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ov-create-error" class="hidden mt-3 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm"></div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end space-x-3">
|
<div class="mt-6 flex justify-end space-x-3">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -207,8 +206,8 @@
|
|||||||
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">
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit"
|
<button type="submit" id="ov-submit-btn"
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
|
||||||
Create Project
|
Create Project
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +215,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Tab switching
|
// Tab switching
|
||||||
@@ -241,31 +239,58 @@ function showCreateProjectModal() {
|
|||||||
document.getElementById('createProjectModal').classList.remove('hidden');
|
document.getElementById('createProjectModal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showCreateProjectModal() {
|
||||||
|
document.getElementById('createProjectModal').classList.remove('hidden');
|
||||||
|
document.getElementById('createProjectFormElement').reset();
|
||||||
|
document.getElementById('ov-create-error').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
function hideCreateProjectModal() {
|
function hideCreateProjectModal() {
|
||||||
document.getElementById('createProjectModal').classList.add('hidden');
|
document.getElementById('createProjectModal').classList.add('hidden');
|
||||||
document.getElementById('projectTypeSelection').classList.remove('hidden');
|
|
||||||
document.getElementById('projectDetailsForm').classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectProjectType(typeId, typeName) {
|
document.getElementById('createProjectFormElement').addEventListener('submit', async function(e) {
|
||||||
document.getElementById('project_type_id').value = typeId;
|
e.preventDefault();
|
||||||
document.getElementById('projectTypeSelection').classList.add('hidden');
|
const submitBtn = document.getElementById('ov-submit-btn');
|
||||||
document.getElementById('projectDetailsForm').classList.remove('hidden');
|
const errorDiv = document.getElementById('ov-create-error');
|
||||||
}
|
errorDiv.classList.add('hidden');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Creating...';
|
||||||
|
|
||||||
function backToTypeSelection() {
|
const formData = new FormData(this);
|
||||||
document.getElementById('projectTypeSelection').classList.remove('hidden');
|
// project_type_id no longer required — send empty string so backend accepts it
|
||||||
document.getElementById('projectDetailsForm').classList.add('hidden');
|
formData.set('project_type_id', '');
|
||||||
}
|
|
||||||
|
|
||||||
// Handle form submission success
|
try {
|
||||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
const resp = await fetch('/api/projects/create', { method: 'POST', body: formData });
|
||||||
if (event.detail.elt.id === 'createProjectFormElement' && event.detail.successful) {
|
const result = await resp.json();
|
||||||
|
if (!resp.ok || !result.success) {
|
||||||
|
errorDiv.textContent = result.detail || result.message || 'Failed to create project';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const projectId = result.project_id;
|
||||||
|
// Add selected modules
|
||||||
|
if (document.getElementById('ov-module-sound').checked) {
|
||||||
|
await fetch(`/api/projects/${projectId}/modules`, {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ module_type: 'sound_monitoring' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.getElementById('ov-module-vibration').checked) {
|
||||||
|
await fetch(`/api/projects/${projectId}/modules`, {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ module_type: 'vibration_monitoring' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
hideCreateProjectModal();
|
hideCreateProjectModal();
|
||||||
// Refresh project list
|
|
||||||
htmx.ajax('GET', '/api/projects/list', {target: '#projects-list'});
|
htmx.ajax('GET', '/api/projects/list', {target: '#projects-list'});
|
||||||
// Show success message
|
} catch(err) {
|
||||||
alert('Project created successfully!');
|
errorDiv.textContent = `Error: ${err.message}`;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Create Project';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -181,6 +181,27 @@ let chartInstance = null;
|
|||||||
let allData = [];
|
let allData = [];
|
||||||
let allHeaders = [];
|
let allHeaders = [];
|
||||||
|
|
||||||
|
// Session period window (null = no filtering)
|
||||||
|
const SESSION_PERIOD_START_HOUR = {{ period_start_hour if period_start_hour is not none else 'null' }};
|
||||||
|
const SESSION_PERIOD_END_HOUR = {{ period_end_hour if period_end_hour is not none else 'null' }};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given hour integer is within the session's period window.
|
||||||
|
* Always returns true when no period window is configured.
|
||||||
|
*/
|
||||||
|
function _isInPeriodWindow(hour) {
|
||||||
|
if (SESSION_PERIOD_START_HOUR === null || SESSION_PERIOD_END_HOUR === null) return true;
|
||||||
|
const sh = SESSION_PERIOD_START_HOUR;
|
||||||
|
const eh = SESSION_PERIOD_END_HOUR;
|
||||||
|
if (eh > sh) {
|
||||||
|
// Same-day window, e.g. 7–19
|
||||||
|
return hour >= sh && hour < eh;
|
||||||
|
} else {
|
||||||
|
// Crosses midnight, e.g. 19–7
|
||||||
|
return hour >= sh || hour < eh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load data on page load
|
// Load data on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadRndData();
|
loadRndData();
|
||||||
@@ -387,19 +408,21 @@ function renderChart(data, fileType) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(headers, data) {
|
function _rowHour(row) {
|
||||||
const headerRow = document.getElementById('table-header');
|
// Parse hour from "Start Time" field (format: "YYYY/MM/DD HH:MM:SS")
|
||||||
const tbody = document.getElementById('table-body');
|
const t = row['Start Time'];
|
||||||
|
if (!t) return null;
|
||||||
|
const parts = t.split(' ');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
return parseInt(parts[1].split(':')[0], 10);
|
||||||
|
}
|
||||||
|
|
||||||
// Render headers
|
function _buildRow(headers, row) {
|
||||||
headerRow.innerHTML = '<tr>' + headers.map(h =>
|
const hour = _rowHour(row);
|
||||||
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
|
const inWindow = hour === null || _isInPeriodWindow(hour);
|
||||||
).join('') + '</tr>';
|
const dimClass = inWindow ? '' : 'opacity-40';
|
||||||
|
const titleAttr = (!inWindow) ? ' title="Outside period window"' : '';
|
||||||
// Render rows (limit to first 500 for performance)
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 ${dimClass}"${titleAttr}>` +
|
||||||
const displayData = data.slice(0, 500);
|
|
||||||
tbody.innerHTML = displayData.map(row =>
|
|
||||||
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
|
||||||
headers.map(h => {
|
headers.map(h => {
|
||||||
const val = row[h];
|
const val = row[h];
|
||||||
let displayVal = val;
|
let displayVal = val;
|
||||||
@@ -410,12 +433,34 @@ function renderTable(headers, data) {
|
|||||||
}
|
}
|
||||||
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</tr>'
|
'</tr>';
|
||||||
).join('');
|
}
|
||||||
|
|
||||||
|
function renderTable(headers, data) {
|
||||||
|
const headerRow = document.getElementById('table-header');
|
||||||
|
const tbody = document.getElementById('table-body');
|
||||||
|
|
||||||
|
// Render headers — add period window indicator if configured
|
||||||
|
let periodNote = '';
|
||||||
|
if (SESSION_PERIOD_START_HOUR !== null && SESSION_PERIOD_END_HOUR !== null) {
|
||||||
|
function _fmtH(h) { const ampm = h < 12 ? 'AM' : 'PM'; return `${h%12||12}:00 ${ampm}`; }
|
||||||
|
periodNote = ` <span class="ml-2 text-indigo-500 dark:text-indigo-400 font-normal normal-case text-xs" title="Dimmed rows are outside this window">Period: ${_fmtH(SESSION_PERIOD_START_HOUR)} → ${_fmtH(SESSION_PERIOD_END_HOUR)}</span>`;
|
||||||
|
}
|
||||||
|
headerRow.innerHTML = '<tr>' + headers.map((h, i) =>
|
||||||
|
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}${i === 0 ? periodNote : ''}</th>`
|
||||||
|
).join('') + '</tr>';
|
||||||
|
|
||||||
|
// Render rows (limit to first 500 for performance)
|
||||||
|
const displayData = data.slice(0, 500);
|
||||||
|
tbody.innerHTML = displayData.map(row => _buildRow(headers, row)).join('');
|
||||||
|
|
||||||
// Update row count
|
// Update row count
|
||||||
|
const inWindowCount = data.filter(r => { const h = _rowHour(r); return h === null || _isInPeriodWindow(h); }).length;
|
||||||
|
const windowNote = (SESSION_PERIOD_START_HOUR !== null && inWindowCount < data.length)
|
||||||
|
? ` (${inWindowCount} in period window)`
|
||||||
|
: '';
|
||||||
document.getElementById('row-count').textContent =
|
document.getElementById('row-count').textContent =
|
||||||
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows` : `${data.length.toLocaleString()} rows`;
|
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows${windowNote}` : `${data.length.toLocaleString()} rows${windowNote}`;
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
document.getElementById('table-search').addEventListener('input', function(e) {
|
document.getElementById('table-search').addEventListener('input', function(e) {
|
||||||
@@ -427,20 +472,7 @@ function renderTable(headers, data) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const displayFiltered = filtered.slice(0, 500);
|
const displayFiltered = filtered.slice(0, 500);
|
||||||
tbody.innerHTML = displayFiltered.map(row =>
|
tbody.innerHTML = displayFiltered.map(row => _buildRow(headers, row)).join('');
|
||||||
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
|
||||||
headers.map(h => {
|
|
||||||
const val = row[h];
|
|
||||||
let displayVal = val;
|
|
||||||
if (val === null || val === undefined) {
|
|
||||||
displayVal = '-';
|
|
||||||
} else if (typeof val === 'number') {
|
|
||||||
displayVal = val.toFixed(1);
|
|
||||||
}
|
|
||||||
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
|
||||||
}).join('') +
|
|
||||||
'</tr>'
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
document.getElementById('row-count').textContent =
|
document.getElementById('row-count').textContent =
|
||||||
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
||||||
|
|||||||
437
templates/session_detail.html
Normal file
437
templates/session_detail.html
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ session.session_label or 'Session' }} — {{ project.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
|
||||||
|
<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="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name }}</a>
|
||||||
|
<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="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white truncate max-w-xs">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
<svg class="w-7 h-7 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="header-label">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
|
||||||
|
</h1>
|
||||||
|
{% if location %}
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ location.name }}{% if unit %} · {{ unit.id }}{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
{% if session.status == 'completed' %}
|
||||||
|
<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">Completed</span>
|
||||||
|
{% elif session.status == 'recording' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span> Recording
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- LEFT COLUMN: Info + Edit -->
|
||||||
|
<div class="lg:col-span-1 space-y-4">
|
||||||
|
|
||||||
|
<!-- Session Info Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Session Info</h2>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Label</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white text-right max-w-[180px] truncate"
|
||||||
|
id="info-label">{{ session.session_label or '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ location.name if location else '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Period</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white" id="info-period">
|
||||||
|
{% set PLABELS = {'weekday_day':'Weekday Day','weekday_night':'Weekday Night','weekend_day':'Weekend Day','weekend_night':'Weekend Night'} %}
|
||||||
|
{{ PLABELS.get(session.period_type, '—') }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if effective_range %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
|
||||||
|
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective">{{ effective_range }}</dd>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex justify-between hidden" id="info-effective-row">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
|
||||||
|
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective"></dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Report Date</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white" id="info-report-date">
|
||||||
|
{{ report_date or '— (auto)' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if session.started_at %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.started_at|local_datetime }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session.stopped_at %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Ended</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.stopped_at|local_datetime }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session.duration_seconds %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Duration</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session.duration_seconds // 3600 }}h {{ (session.duration_seconds % 3600) // 60 }}m</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session.device_model %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Device Model</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session.device_model }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session_meta.get('store_name') %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Store Name</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.store_name }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session_meta.get('serial_number') %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Serial #</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.serial_number }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Edit Session</h2>
|
||||||
|
<form id="edit-form" onsubmit="saveSession(event)">
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Label</label>
|
||||||
|
<input type="text" id="edit-label" name="session_label"
|
||||||
|
value="{{ session.session_label or '' }}"
|
||||||
|
placeholder="e.g. NRL-1 — Mon 3/24 — Night"
|
||||||
|
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Required Recording Window -->
|
||||||
|
<div class="mb-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
|
||||||
|
<p class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 mb-0.5">Required Recording Window</p>
|
||||||
|
<p class="text-xs text-indigo-500 dark:text-indigo-400 mb-3">The hours that count for reports. Only data within this window is included.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Period Type</label>
|
||||||
|
<select id="edit-period-type" name="period_type"
|
||||||
|
onchange="fillPeriodDefaults()"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
<option value="">— Not Set —</option>
|
||||||
|
<option value="weekday_day" {% if session.period_type == 'weekday_day' %}selected{% endif %}>Weekday Day</option>
|
||||||
|
<option value="weekday_night" {% if session.period_type == 'weekday_night' %}selected{% endif %}>Weekday Night</option>
|
||||||
|
<option value="weekend_day" {% if session.period_type == 'weekend_day' %}selected{% endif %}>Weekend Day</option>
|
||||||
|
<option value="weekend_night" {% if session.period_type == 'weekend_night' %}selected{% endif %}>Weekend Night</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From (hour)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="number" min="0" max="23" id="edit-start-hour" name="period_start_hour"
|
||||||
|
value="{{ session.period_start_hour if session.period_start_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 19"
|
||||||
|
oninput="updateWindowPreview()"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To (hour)</label>
|
||||||
|
<input type="number" min="0" max="23" id="edit-end-hour" name="period_end_hour"
|
||||||
|
value="{{ session.period_end_hour if session.period_end_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 7"
|
||||||
|
oninput="updateWindowPreview()"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live preview -->
|
||||||
|
<div id="window-preview" class="text-xs font-medium text-indigo-600 dark:text-indigo-300 min-h-[1rem]">
|
||||||
|
{% if session.period_start_hour is not none and session.period_end_hour is not none %}
|
||||||
|
{% set sh = session.period_start_hour %}
|
||||||
|
{% set eh = session.period_end_hour %}
|
||||||
|
Window: {{ (sh % 12) or 12 }}:00 {{ 'AM' if sh < 12 else 'PM' }} → {{ (eh % 12) or 12 }}:00 {{ 'AM' if eh < 12 else 'PM' }}{% if eh <= sh %} (crosses midnight){% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Target Date <span class="text-gray-400">(optional — day sessions only)</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" id="edit-report-date" name="report_date"
|
||||||
|
value="{{ report_date or '' }}"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Leave blank to auto-select the last day with data in the window.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Device On/Off Times -->
|
||||||
|
<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-0.5">Device On/Off Times</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">When the meter was actually running. Usually set automatically from the data file.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered on</label>
|
||||||
|
<input type="datetime-local" id="edit-started-at" name="started_at"
|
||||||
|
value="{{ session.started_at|local_datetime_input if session.started_at else '' }}"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered off</label>
|
||||||
|
<input type="datetime-local" id="edit-stopped-at" name="stopped_at"
|
||||||
|
value="{{ session.stopped_at|local_datetime_input if session.stopped_at else '' }}"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 text-sm py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="save-status" class="hidden text-xs text-center pt-2"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: Files + Report Actions -->
|
||||||
|
<div class="lg:col-span-2 space-y-5">
|
||||||
|
|
||||||
|
<!-- Files List -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||||
|
Data Files
|
||||||
|
<span class="ml-2 text-xs font-normal text-gray-400">({{ files|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{% if files %}
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for f in files %}
|
||||||
|
{% set fname = f.file_path.split('/')[-1] %}
|
||||||
|
{% set is_rnd = fname.lower().endswith('.rnd') %}
|
||||||
|
{% set is_leq = '_leq_' in fname.lower() or fname.lower().startswith('au2_') %}
|
||||||
|
<div class="flex items-center gap-3 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center
|
||||||
|
{% if is_rnd %}bg-green-100 dark:bg-green-900/30{% else %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
{% if is_rnd %}
|
||||||
|
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<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="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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name + meta -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ fname }}</div>
|
||||||
|
<div class="text-xs text-gray-400 flex items-center gap-2 mt-0.5">
|
||||||
|
<span>{{ f.file_type | upper }}</span>
|
||||||
|
{% if f.file_size_bytes %}
|
||||||
|
<span>{{ (f.file_size_bytes / 1024) | round(1) }} KB</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_leq %}<span class="text-green-600 dark:text-green-400 font-medium">Leq</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
{% if is_rnd %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/view-rnd"
|
||||||
|
class="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{% if is_leq %}
|
||||||
|
<button onclick="openSingleFileReport('{{ f.id }}', '{{ fname }}')"
|
||||||
|
class="px-2 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 transition-colors">
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/download"
|
||||||
|
class="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-5 py-10 text-center text-gray-400">
|
||||||
|
<p>No files found for this session.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Report Actions -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Report Actions</h2>
|
||||||
|
|
||||||
|
{% if session.status == 'completed' %}
|
||||||
|
{% set has_rnd = files | selectattr('file_type', 'equalto', 'rnd') | list | length > 0 %}
|
||||||
|
{% if has_rnd %}
|
||||||
|
<div class="p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg space-y-2">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Use the <strong>Combined Report Wizard</strong> to generate an Excel report for this session, or click <strong>View</strong> on a Leq file above to access per-file reporting.
|
||||||
|
{% if session.period_start_hour is not none %}
|
||||||
|
<br><span class="text-indigo-600 dark:text-indigo-400">Period window {{ session.period_start_hour }}:00–{{ session.period_end_hour }}:00 will be applied.</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<a href="/projects/{{ project_id }}?tab=data"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||||
|
<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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
Go to Combined Report Wizard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">No .rnd files found — upload data to generate a report.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">Reports are available after the session is completed.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PROJECT_ID = '{{ project_id }}';
|
||||||
|
const SESSION_ID = '{{ session.id }}';
|
||||||
|
|
||||||
|
const PERIOD_DEFAULT_HOURS = {
|
||||||
|
weekday_day: {start: 7, end: 19},
|
||||||
|
weekday_night: {start: 19, end: 7},
|
||||||
|
weekend_day: {start: 7, end: 19},
|
||||||
|
weekend_night: {start: 19, end: 7},
|
||||||
|
};
|
||||||
|
|
||||||
|
function fillPeriodDefaults() {
|
||||||
|
const pt = document.getElementById('edit-period-type').value;
|
||||||
|
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||||
|
if (defaults) {
|
||||||
|
document.getElementById('edit-start-hour').value = defaults.start;
|
||||||
|
document.getElementById('edit-end-hour').value = defaults.end;
|
||||||
|
}
|
||||||
|
updateWindowPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWindowPreview() {
|
||||||
|
const sh = parseInt(document.getElementById('edit-start-hour').value, 10);
|
||||||
|
const eh = parseInt(document.getElementById('edit-end-hour').value, 10);
|
||||||
|
const el = document.getElementById('window-preview');
|
||||||
|
if (!el) return;
|
||||||
|
if (isNaN(sh) || isNaN(eh)) { el.textContent = ''; return; }
|
||||||
|
function fmt(h) { return `${h % 12 || 12}:00 ${h < 12 ? 'AM' : 'PM'}`; }
|
||||||
|
const crosses = eh <= sh;
|
||||||
|
el.textContent = `Window: ${fmt(sh)} → ${fmt(eh)}${crosses ? ' (crosses midnight)' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run once on load to populate preview if values already set
|
||||||
|
document.addEventListener('DOMContentLoaded', updateWindowPreview);
|
||||||
|
|
||||||
|
async function saveSession(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const status = document.getElementById('save-status');
|
||||||
|
status.className = 'text-xs text-center pt-1 text-gray-400';
|
||||||
|
status.textContent = 'Saving…';
|
||||||
|
status.classList.remove('hidden');
|
||||||
|
|
||||||
|
const form = document.getElementById('edit-form');
|
||||||
|
const payload = {};
|
||||||
|
|
||||||
|
const label = form.session_label.value.trim();
|
||||||
|
payload.session_label = label || null;
|
||||||
|
|
||||||
|
const pt = form.period_type.value;
|
||||||
|
payload.period_type = pt || null;
|
||||||
|
|
||||||
|
const sh = form.period_start_hour.value;
|
||||||
|
const eh = form.period_end_hour.value;
|
||||||
|
payload.period_start_hour = sh !== '' ? parseInt(sh, 10) : null;
|
||||||
|
payload.period_end_hour = eh !== '' ? parseInt(eh, 10) : null;
|
||||||
|
|
||||||
|
const rd = form.report_date.value;
|
||||||
|
payload.report_date = rd || null;
|
||||||
|
|
||||||
|
const sa = form.started_at.value;
|
||||||
|
if (sa) payload.started_at = sa;
|
||||||
|
|
||||||
|
const st = form.stopped_at.value;
|
||||||
|
if (st) payload.stopped_at = st;
|
||||||
|
else if (form.stopped_at.value === '') payload.stopped_at = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
// Update displayed label
|
||||||
|
const newLabel = result.session_label || ('Session ' + SESSION_ID.slice(0, 8) + '…');
|
||||||
|
document.getElementById('header-label').textContent = newLabel;
|
||||||
|
document.getElementById('info-label').textContent = result.session_label || '—';
|
||||||
|
document.getElementById('info-period').textContent = {
|
||||||
|
weekday_day: 'Weekday Day', weekday_night: 'Weekday Night',
|
||||||
|
weekend_day: 'Weekend Day', weekend_night: 'Weekend Night'
|
||||||
|
}[result.period_type] || '—';
|
||||||
|
document.getElementById('info-report-date').textContent = result.report_date || '— (auto)';
|
||||||
|
|
||||||
|
status.className = 'text-xs text-center pt-1 text-green-600 dark:text-green-400';
|
||||||
|
status.textContent = 'Saved!';
|
||||||
|
setTimeout(() => status.classList.add('hidden'), 2500);
|
||||||
|
} catch(err) {
|
||||||
|
status.className = 'text-xs text-center pt-1 text-red-500';
|
||||||
|
status.textContent = 'Error: ' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSingleFileReport(fileId, filename) {
|
||||||
|
window.location.href = `/api/projects/${PROJECT_ID}/files/${fileId}/view-rnd`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
{{ location.name }}
|
{{ location.name }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
Monitoring Location • {{ project.name }}
|
Monitoring Location • {{ project.name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -116,20 +116,36 @@
|
|||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<!-- Seismograph row -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Seismograph</div>
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
<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">
|
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
{{ assigned_unit.id }}
|
{{ assigned_unit.id }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% if assigned_unit.unit_type %}
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ assigned_unit.unit_type }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if assigned_unit.device_type %}
|
<!-- Modem row -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Modem</div>
|
||||||
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
|
{% if assigned_modem %}
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
<a href="/unit/{{ assigned_modem.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ assigned_modem.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% if assigned_modem.hardware_model or assigned_modem.ip_address %}
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{{ assigned_modem.hardware_model or '' }}{% if assigned_modem.hardware_model and assigned_modem.ip_address %} • {% endif %}{{ assigned_modem.ip_address or '' }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No modem paired</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% if assignment %}
|
{% if assignment %}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
||||||
@@ -142,10 +158,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="pt-2">
|
<div class="pt-2 flex gap-2 flex-wrap">
|
||||||
|
<button onclick="openSwapModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors text-sm">
|
||||||
|
Swap Unit / Modem
|
||||||
|
</button>
|
||||||
<button onclick="unassignUnit('{{ assignment.id }}')"
|
<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">
|
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 text-sm">
|
||||||
Unassign Unit
|
Unassign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +175,7 @@
|
|||||||
<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>
|
<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>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
||||||
<button onclick="openAssignModal()"
|
<button onclick="openSwapModal()"
|
||||||
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">
|
||||||
Assign a Unit
|
Assign a Unit
|
||||||
</button>
|
</button>
|
||||||
@@ -214,47 +234,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assign Unit Modal -->
|
<!-- Assign / Swap Modal -->
|
||||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div id="swap-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="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 class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
<h2 id="swap-modal-title" 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>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a seismograph and optionally a modem for this location</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
<button onclick="closeSwapModal()" 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">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="assign-form" class="p-6 space-y-4">
|
<form id="swap-form" class="p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Seismograph <span class="text-red-500">*</span></label>
|
||||||
<select id="assign-unit-id" name="unit_id"
|
<select id="swap-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>
|
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>
|
<option value="">Loading units...</option>
|
||||||
</select>
|
</select>
|
||||||
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
|
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Modem <span class="text-xs text-gray-400">(optional)</span></label>
|
||||||
|
<select id="swap-modem-id" name="modem_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">
|
||||||
|
<option value="">No modem</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
<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"
|
<textarea id="swap-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>
|
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>
|
||||||
|
|
||||||
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
<div id="swap-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button type="button" onclick="closeAssignModal()"
|
<button type="button" onclick="closeSwapModal()"
|
||||||
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">
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit"
|
<button type="submit" id="swap-submit-btn"
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
Assign Unit
|
Assign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -264,6 +292,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const projectId = "{{ project_id }}";
|
const projectId = "{{ project_id }}";
|
||||||
const locationId = "{{ location_id }}";
|
const locationId = "{{ location_id }}";
|
||||||
|
const hasAssignment = {{ 'true' if assigned_unit else 'false' }};
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName) {
|
||||||
@@ -314,60 +343,89 @@ document.getElementById('location-settings-form').addEventListener('submit', asy
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign modal
|
// Swap / Assign modal
|
||||||
function openAssignModal() {
|
async function openSwapModal() {
|
||||||
document.getElementById('assign-modal').classList.remove('hidden');
|
document.getElementById('swap-modal').classList.remove('hidden');
|
||||||
loadAvailableUnits();
|
document.getElementById('swap-modal-title').textContent = hasAssignment ? 'Swap Unit / Modem' : 'Assign Unit';
|
||||||
|
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
|
||||||
|
document.getElementById('swap-error').classList.add('hidden');
|
||||||
|
document.getElementById('swap-notes').value = '';
|
||||||
|
await Promise.all([loadSwapUnits(), loadSwapModems()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAssignModal() {
|
function closeSwapModal() {
|
||||||
document.getElementById('assign-modal').classList.add('hidden');
|
document.getElementById('swap-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAvailableUnits() {
|
async function loadSwapUnits() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||||
if (!response.ok) throw new Error('Failed to load available units');
|
if (!response.ok) throw new Error('Failed to load units');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const select = document.getElementById('assign-unit-id');
|
const select = document.getElementById('swap-unit-id');
|
||||||
select.innerHTML = '<option value="">Select a unit</option>';
|
select.innerHTML = '<option value="">Select a seismograph</option>';
|
||||||
|
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
document.getElementById('assign-empty').classList.remove('hidden');
|
document.getElementById('swap-units-empty').classList.remove('hidden');
|
||||||
return;
|
} else {
|
||||||
|
document.getElementById('swap-units-empty').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
data.forEach(unit => {
|
data.forEach(unit => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = unit.id;
|
option.value = unit.id;
|
||||||
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
option.textContent = unit.id + (unit.model ? ` \u2022 ${unit.model}` : '') + (unit.location ? ` \u2014 ${unit.location}` : '');
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorEl = document.getElementById('assign-error');
|
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
|
||||||
errorEl.textContent = err.message || 'Failed to load units.';
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
async function loadSwapModems() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/available-modems`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load modems');
|
||||||
|
const data = await response.json();
|
||||||
|
const select = document.getElementById('swap-modem-id');
|
||||||
|
select.innerHTML = '<option value="">No modem</option>';
|
||||||
|
|
||||||
|
data.forEach(modem => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = modem.id;
|
||||||
|
let label = modem.id;
|
||||||
|
if (modem.hardware_model) label += ` \u2022 ${modem.hardware_model}`;
|
||||||
|
if (modem.ip_address) label += ` \u2014 ${modem.ip_address}`;
|
||||||
|
option.textContent = label;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Modem list failure is non-fatal — just leave blank
|
||||||
|
console.warn('Failed to load modems:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('swap-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const unitId = document.getElementById('assign-unit-id').value;
|
const unitId = document.getElementById('swap-unit-id').value;
|
||||||
const notes = document.getElementById('assign-notes').value.trim();
|
const modemId = document.getElementById('swap-modem-id').value;
|
||||||
|
const notes = document.getElementById('swap-notes').value.trim();
|
||||||
|
|
||||||
if (!unitId) {
|
if (!unitId) {
|
||||||
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
document.getElementById('swap-error').textContent = 'Please select a seismograph.';
|
||||||
document.getElementById('assign-error').classList.remove('hidden');
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('unit_id', unitId);
|
formData.append('unit_id', unitId);
|
||||||
formData.append('notes', notes);
|
if (modemId) formData.append('modem_id', modemId);
|
||||||
|
if (notes) formData.append('notes', notes);
|
||||||
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/swap`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
@@ -379,9 +437,8 @@ document.getElementById('assign-form').addEventListener('submit', async function
|
|||||||
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorEl = document.getElementById('assign-error');
|
document.getElementById('swap-error').textContent = err.message || 'Failed to assign unit.';
|
||||||
errorEl.textContent = err.message || 'Failed to assign unit.';
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -405,11 +462,11 @@ async function unassignUnit(assignmentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') closeAssignModal();
|
if (e.key === 'Escape') closeSwapModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
||||||
if (e.target === this) closeAssignModal();
|
if (e.target === this) closeSwapModal();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user