13 Commits

Author SHA1 Message Date
20e180644e feat: enhance swap modal with search functionality for seismographs and modems 2026-03-31 20:16:47 +00:00
73a6ff4d20 feat: Refactor project creation and management to support modular project types
- Updated project creation modal to allow selection of optional modules (Sound and Vibration Monitoring).
- Modified project dashboard and header to display active modules and provide options to add/remove them.
- Enhanced project detail view to dynamically adjust UI based on enabled modules.
- Implemented a new migration script to create a `project_modules` table and seed it based on existing project types.
- Adjusted form submissions to handle module selections and ensure proper API interactions for module management.
2026-03-30 21:44:15 +00:00
184f0ddd13 doc: update to 0.9.3 2026-03-28 01:53:13 +00:00
e7bd09418b fix: update session calendar layout and improve session labels for clarity 2026-03-28 01:44:59 +00:00
27eeb0fae6 fix: adds timeline bars to SLM calendar view, more conscise and legible. 2026-03-27 22:44:53 +00:00
192e15f238 Merge pull request 'cleanup/project-locations-active-assignment' (#42) from cleanup/project-locations-active-assignment into dev
Reviewed-on: #42
2026-03-27 18:20:07 -04:00
49bc625c1a feat: add report_date to monitoring sessions and update related functionality
fix: chart properly renders centered
2026-03-27 22:18:50 +00:00
95fedca8c9 feat: monitoring session improvements — UTC fix, period hours, calendar, session detail
- Fix UTC display bug: upload_nrl_data now wraps RNH datetimes with
  local_to_utc() before storing, matching patch_session behavior.
  Period type and label are derived from local time before conversion.

- Add period_start_hour / period_end_hour to MonitoringSession model
  (nullable integers 0–23). Migration: migrate_add_session_period_hours.py

- Update patch_session to accept and store period_start_hour / period_end_hour.
  Response now includes both fields.

- Update get_project_sessions to compute "Effective: M/D H:MM AM → M/D H:MM AM"
  string from period hours and pass it to session_list.html.

- Rework period edit UI in session_list.html: clicking the period badge now
  opens an inline editor with period type selector + start/end hour inputs.
  Selecting a period type pre-fills default hours (Day: 7–19, Night: 19–7).

- Wire period hours into _build_location_data_from_sessions: uses
  period_start/end_hour when set, falls back to hardcoded defaults.

- RND viewer: inject SESSION_PERIOD_START/END_HOUR from template context.
  renderTable() dims rows outside the period window (opacity-40) with a
  tooltip; shows "(N in period window)" in the row count.

- New session detail page at /api/projects/{id}/sessions/{id}/detail:
  shows breadcrumb, files list with View/Download/Report actions,
  editable session info form (label, period type, hours, times).

- Add local_datetime_input Jinja filter for datetime-local input values.

- Monthly calendar view: new get_sessions_calendar endpoint returns
  sessions_calendar.html partial; added below sessions list in detail.html.
  Color-coded per NRL with legend, HTMX prev/next navigation, session dots
  link to detail page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:52:52 +00:00
e8e155556a refactor: unify active assignment checks and add project-type guards
- Replace all UnitAssignment "active" checks from `status == "active"` to
  `assigned_until == None` in both project_locations.py and projects.py.
  This aligns with the canonical definition: active = no end date set.
  (status field is still set in sync, but is no longer the query criterion)

- Add `_require_sound_project()` helper to both routers and call it at the
  top of every sound-monitoring-specific endpoint (FTP browser, FTP downloads,
  RND file viewer, all Excel report endpoints, combined report wizard,
  upload-all, NRL live status, NRL data upload). Vibration projects hitting
  these endpoints now receive a clear 400 instead of silently failing or
  returning empty results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:12:38 +00:00
33e962e73d feat: add edit session times functionality with modal for monitoring sessions 2026-03-27 20:54:04 +00:00
ac48fb2977 feat: add swap functionality for unit and modem assignments in vibration monitoring locations 2026-03-27 20:33:13 +00:00
3c4b81cf78 docs: updates to 0.9.2 2026-03-27 17:01:43 +00:00
d135727ebd feat: add in-line quick editing for seismograph details (cal date, notes, deployment status) 2026-03-26 06:10:03 +00:00
25 changed files with 2600 additions and 418 deletions

View File

@@ -5,11 +5,58 @@ 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.1] - 2026-03-23 ## [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
### Added
- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations.
- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators.
- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster.
- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page.
- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing.
- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_allocated.py
docker compose exec terra-view python3 backend/migrate_add_deployment_records.py
```
---
## [0.9.1] - 2026-03-20
### Fixed ### Fixed
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots. - **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance. - **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility.
### Migration Notes ### Migration Notes
Run on each database before deploying: Run on each database before deploying:

View File

@@ -1,4 +1,4 @@
# Terra-View v0.9.1 # 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

View File

@@ -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.1" 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,

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

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

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

View File

@@ -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 023). 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

View File

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

View File

@@ -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 024 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 (0100 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 (023 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 023 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.
is_day_session = period_type in ('weekday_day', 'weekend_day') # 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')
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:0018: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
daytime_dates = sorted({ in_window = lambda h: sh <= h < eh
dt.date() for dt, row in parsed if entry.get("report_date"):
if dt and 7 <= dt.hour < 19 target_date = entry["report_date"]
}) else:
target_date = daytime_dates[-1] if daytime_dates else None daytime_dates = sorted({
dt.date() for dt, row in parsed if dt and in_window(dt.hour)
})
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:0006: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(

View File

@@ -3,13 +3,13 @@ Seismograph Dashboard API Router
Provides endpoints for the seismograph-specific dashboard Provides endpoints for the seismograph-specific dashboard
""" """
from datetime import date from datetime import date, datetime, timedelta
from fastapi import APIRouter, Request, Depends, Query from fastapi import APIRouter, Request, Depends, Query, Form, HTTPException
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit, UnitHistory, UserPreferences
from backend.templates_config import templates from backend.templates_config import templates
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"]) router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
@@ -120,3 +120,109 @@ async def get_seismo_units(
"today": date.today() "today": date.today()
} }
) )
def _get_calibration_interval(db: Session) -> int:
prefs = db.query(UserPreferences).first()
if prefs and prefs.calibration_interval_days:
return prefs.calibration_interval_days
return 365
def _row_context(request: Request, unit: RosterUnit) -> dict:
return {"request": request, "unit": unit, "today": date.today()}
@router.get("/unit/{unit_id}/view-row", response_class=HTMLResponse)
async def get_seismo_view_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
@router.get("/unit/{unit_id}/edit-row", response_class=HTMLResponse)
async def get_seismo_edit_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
return templates.TemplateResponse("partials/seismo_row_edit.html", _row_context(request, unit))
@router.post("/unit/{unit_id}/quick-update", response_class=HTMLResponse)
async def quick_update_seismo_unit(
unit_id: str,
request: Request,
db: Session = Depends(get_db),
status: str = Form(...),
last_calibrated: str = Form(""),
note: str = Form(""),
):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
# --- Status ---
old_deployed = unit.deployed
old_out_for_cal = unit.out_for_calibration
if status == "deployed":
unit.deployed = True
unit.out_for_calibration = False
elif status == "out_for_calibration":
unit.deployed = False
unit.out_for_calibration = True
else:
unit.deployed = False
unit.out_for_calibration = False
if unit.deployed != old_deployed or unit.out_for_calibration != old_out_for_cal:
old_status = "deployed" if old_deployed else ("out_for_calibration" if old_out_for_cal else "benched")
db.add(UnitHistory(
unit_id=unit_id,
change_type="deployed_change",
field_name="status",
old_value=old_status,
new_value=status,
source="manual",
))
# --- Last calibrated ---
old_cal = unit.last_calibrated
if last_calibrated:
try:
new_cal = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
unit.last_calibrated = new_cal
unit.next_calibration_due = new_cal + timedelta(days=_get_calibration_interval(db))
else:
unit.last_calibrated = None
unit.next_calibration_due = None
if unit.last_calibrated != old_cal:
db.add(UnitHistory(
unit_id=unit_id,
change_type="calibration_status_change",
field_name="last_calibrated",
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
new_value=last_calibrated or None,
source="manual",
))
# --- Note ---
old_note = unit.note
unit.note = note or None
if unit.note != old_note:
db.add(UnitHistory(
unit_id=unit_id,
change_type="note_change",
field_name="note",
old_value=old_note,
new_value=unit.note,
source="manual",
))
db.commit()
db.refresh(unit)
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))

View File

@@ -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

View File

@@ -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();

View File

@@ -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">

View File

@@ -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>

View File

@@ -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,25 +50,74 @@
<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 %}">
{{ pt_label }} <!-- Period type selector -->
</button> <div>
{% endfor %} <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 }}
</button>
{% endfor %}
</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 (023)</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 (023)</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>
</div> </div>
@@ -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,46 +236,145 @@ 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');
} }
function closePeriodEditor(sessionId) {
document.getElementById('period-editor-' + sessionId).classList.add('hidden');
delete _editorState[sessionId];
}
function selectPeriodType(sessionId, pt) {
_editorState[sessionId] = pt;
// Highlight selected button
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 {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
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 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));
if (newPt && PERIOD_COLORS[newPt]) {
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) {
alert('Failed to save period: ' + err.message);
}
}
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) { document.addEventListener('click', function(e) {
if (!e.target.closest('[id^="period-wrap-"]')) { if (!e.target.closest('[id^="period-wrap-"]')) {
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden')); document.querySelectorAll('[id^="period-editor-"]').forEach(m => m.classList.add('hidden'));
} }
}); });
async function setPeriodType(sessionId, periodType) { // ---- Label editing ----
document.getElementById('period-menu-' + sessionId).classList.add('hidden');
const badge = document.getElementById('period-badge-' + sessionId);
badge.disabled = true;
try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({period_type: periodType}),
});
if (!resp.ok) throw new Error(await resp.text());
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean));
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
} catch(err) {
alert('Failed to update period type: ' + err.message);
} finally {
badge.disabled = false;
}
}
function startEditLabel(sessionId) { function startEditLabel(sessionId) {
document.getElementById('label-display-' + sessionId).classList.add('hidden'); document.getElementById('label-display-' + sessionId).classList.add('hidden');
@@ -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) {

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

View File

@@ -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>

View File

@@ -0,0 +1,63 @@
<tr id="seismo-row-{{ unit.id }}" class="bg-blue-50 dark:bg-slate-600 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.id }}
</a>
</td>
<td class="px-4 py-2 whitespace-nowrap">
<select name="status"
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="deployed" {% if unit.deployed %}selected{% endif %}>Deployed</option>
<option value="out_for_calibration" {% if unit.out_for_calibration %}selected{% endif %}>Out for Cal</option>
<option value="benched" {% if not unit.deployed and not unit.out_for_calibration %}selected{% endif %}>Benched</option>
</select>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{% if unit.deployed_with_modem_id %}
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.deployed_with_modem_id }}
</a>
{% else %}
<span class="text-gray-400 dark:text-gray-600">None</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{% if unit.address %}
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
{% elif unit.coordinates %}
<span>{{ unit.coordinates }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-2 whitespace-nowrap">
<input type="date"
name="last_calibrated"
value="{{ unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '' }}"
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
</td>
<td class="px-4 py-2">
<input type="text"
name="note"
value="{{ unit.note or '' }}"
placeholder="Add a note..."
class="w-full text-sm rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
</td>
<td class="px-4 py-2 whitespace-nowrap text-right text-sm">
<div class="flex items-center justify-end gap-2">
<button hx-post="/api/seismo-dashboard/unit/{{ unit.id }}/quick-update"
hx-include="closest tr"
hx-target="#seismo-row-{{ unit.id }}"
hx-swap="outerHTML"
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors">
Save
</button>
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/view-row"
hx-target="#seismo-row-{{ unit.id }}"
hx-swap="outerHTML"
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Cancel
</button>
</div>
</td>
</tr>

View File

@@ -0,0 +1,93 @@
<tr id="seismo-row-{{ unit.id }}" class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.id }}
</a>
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if unit.deployed %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Deployed
</span>
{% elif unit.out_for_calibration %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
</svg>
Out for Cal
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
</svg>
Benched
</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.deployed_with_modem_id %}
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.deployed_with_modem_id }}
</a>
{% else %}
<span class="text-gray-400 dark:text-gray-600">None</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
{% if unit.address %}
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
{% elif unit.coordinates %}
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.last_calibrated %}
<span class="inline-flex items-center gap-1.5">
{% if unit.next_calibration_due and today %}
{% set days_until = (unit.next_calibration_due - today).days %}
{% if days_until < 0 %}
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
{% elif days_until <= 14 %}
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
{% endif %}
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
{% endif %}
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
{% if unit.note %}
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<div class="flex items-center justify-end gap-3">
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/edit-row"
hx-target="#seismo-row-{{ unit.id }}"
hx-swap="outerHTML"
class="text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
title="Edit row">
<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.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
</button>
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
View Details →
</a>
</div>
</td>
</tr>

View File

@@ -92,88 +92,7 @@
</thead> </thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in units %} {% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"> {% include "partials/seismo_row_view.html" %}
<td class="px-4 py-3 whitespace-nowrap">
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.id }}
</a>
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if unit.deployed %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Deployed
</span>
{% elif unit.out_for_calibration %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
</svg>
Out for Cal
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
</svg>
Benched
</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.deployed_with_modem_id %}
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.deployed_with_modem_id }}
</a>
{% else %}
<span class="text-gray-400 dark:text-gray-600">None</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
{% if unit.address %}
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
{% elif unit.coordinates %}
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.last_calibrated %}
<span class="inline-flex items-center gap-1.5">
{% if unit.next_calibration_due and today %}
{% set days_until = (unit.next_calibration_due - today).days %}
{% if days_until < 0 %}
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
{% elif days_until <= 14 %}
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
{% endif %}
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
{% endif %}
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
{% if unit.note %}
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
View Details →
</a>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -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 %}

View File

@@ -96,124 +96,122 @@
</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"> <div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Choose Project Type</h3> <div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4" <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
hx-get="/api/projects/types/list" Project Name <span class="text-red-500">*</span>
hx-trigger="load" </label>
hx-target="this" <input type="text"
hx-swap="innerHTML"> name="name"
<!-- Project type cards will be loaded here --> required
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div> 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 class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div> </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 class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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> <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 Number
<span class="text-gray-400 font-normal">(xxxx-YY)</span>
</label> </label>
<input type="text" <input type="text"
name="name" name="project_number"
required pattern="\d{4}-\d{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"> 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>
<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">
Description Client Name
</label> </label>
<textarea name="description" <input type="text"
rows="3" 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"></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">
</div> </div>
</div>
<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"> Description
Client Name </label>
</label> <textarea name="description"
<input type="text" rows="2"
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"></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"> </div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Address
</label>
<input type="text"
name="site_address"
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 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">
Start Date
</label>
<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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
End Date (Optional)
</label>
<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 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">
Site Coordinates (Optional) Site Address
</label>
<input type="text"
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 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> </label>
<input type="text" <input type="text"
name="site_coordinates" name="site_coordinates"
placeholder="40.7128,-74.0060" 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"> 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">
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end space-x-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<button type="button" <div>
onclick="hideCreateProjectModal()" <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Start Date</label>
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"> <input type="date" name="start_date"
Cancel 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">
</button> </div>
<button type="submit" <div>
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium"> <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>
Create Project <input type="date" name="end_date"
</button> 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>
</form>
</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">
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>
</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">
<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>
<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 transition-colors">
Create Project
</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
@@ -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>

View File

@@ -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. 719
return hour >= sh && hour < eh;
} else {
// Crosses midnight, e.g. 197
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`;

View 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 %}

View File

@@ -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 &bull; {{ 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 %} &bull; {% endif %}{{ assigned_modem.ip_address or '' }}
</div>
{% endif %}
{% else %}
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No modem paired</div>
{% endif %}
</div> </div>
{% endif %}
{% 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,69 @@
</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-5">
<!-- Seismograph picker -->
<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">
<select id="assign-unit-id" name="unit_id" Seismograph <span class="text-red-500">*</span>
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> </label>
<option value="">Loading units...</option> <input id="swap-unit-search" type="text" placeholder="Search by ID or model..."
</select> oninput="filterSwapList('unit')"
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p> 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 mb-2 focus:ring-2 focus:ring-seismo-orange">
<div id="swap-unit-list"
class="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
</div>
<input type="hidden" id="swap-unit-id" name="unit_id" required>
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
</div>
<!-- Modem picker -->
<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>
<input id="swap-modem-search" type="text" placeholder="Search by ID, model, or IP..."
oninput="filterSwapList('modem')"
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 mb-2 focus:ring-2 focus:ring-seismo-orange">
<div id="swap-modem-list"
class="max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
</div>
<input type="hidden" id="swap-modem-id" name="modem_id">
</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 +306,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 +357,156 @@ 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 = '';
document.getElementById('swap-unit-search').value = '';
document.getElementById('swap-modem-search').value = '';
document.getElementById('swap-unit-id').value = '';
document.getElementById('swap-modem-id').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() { let _swapUnits = [];
try { let _swapModems = [];
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
if (!response.ok) throw new Error('Failed to load available units');
const data = await response.json();
const select = document.getElementById('assign-unit-id');
select.innerHTML = '<option value="">Select a unit</option>';
if (!data.length) { function _fuzzyMatch(query, text) {
document.getElementById('assign-empty').classList.remove('hidden'); if (!query) return true;
return; const q = query.toLowerCase();
} const t = text.toLowerCase();
// Substring match first (fast), then character-sequence fuzzy
if (t.includes(q)) return true;
let qi = 0;
for (let i = 0; i < t.length && qi < q.length; i++) {
if (t[i] === q[qi]) qi++;
}
return qi === q.length;
}
data.forEach(unit => { function _renderSwapList(type, items, selectedId, noSelectionLabel) {
const option = document.createElement('option'); const listEl = document.getElementById(`swap-${type}-list`);
option.value = unit.id; if (!items.length) {
option.textContent = `${unit.id}${unit.model || unit.device_type}`; listEl.innerHTML = `<div class="px-3 py-4 text-center text-sm text-gray-400">No results</div>`;
select.appendChild(option); return;
}); }
} catch (err) { listEl.innerHTML = items.map(item => {
const errorEl = document.getElementById('assign-error'); const isSelected = item.value === selectedId;
errorEl.textContent = err.message || 'Failed to load units.'; return `<button type="button"
errorEl.classList.remove('hidden'); onclick="selectSwapItem('${type}', '${item.value}', this)"
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${isSelected ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
<div>
<span class="font-medium text-gray-900 dark:text-white text-sm">${item.primary}</span>
${item.secondary ? `<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">${item.secondary}</span>` : ''}
</div>
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${isSelected ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
</button>`;
}).join('');
}
function selectSwapItem(type, value, btn) {
document.getElementById(`swap-${type}-id`).value = value;
// Update visual state
const list = document.getElementById(`swap-${type}-list`);
list.querySelectorAll('button').forEach(b => {
b.classList.remove('bg-orange-50', 'dark:bg-orange-900/20');
b.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-gray-400 dark:border-gray-500';
});
btn.classList.add('bg-orange-50', 'dark:bg-orange-900/20');
btn.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-seismo-orange bg-seismo-orange';
}
function filterSwapList(type) {
const query = document.getElementById(`swap-${type}-search`).value;
const items = type === 'unit' ? _swapUnits : _swapModems;
const selectedId = document.getElementById(`swap-${type}-id`).value;
const filtered = items.filter(item =>
_fuzzyMatch(query, item.primary + ' ' + (item.secondary || '') + ' ' + (item.searchText || ''))
);
_renderSwapList(type, filtered, selectedId, type === 'modem' ? 'No modem' : null);
// Re-add "No modem" option for modems
if (type === 'modem') {
const listEl = document.getElementById('swap-modem-list');
const noModemBtn = `<button type="button"
onclick="selectSwapItem('modem', '', this)"
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${!selectedId ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
<span class="text-sm text-gray-500 dark:text-gray-400 italic">No modem</span>
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${!selectedId ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
</button>`;
listEl.insertAdjacentHTML('afterbegin', noModemBtn);
} }
} }
document.getElementById('assign-form').addEventListener('submit', async function(e) { async function loadSwapUnits() {
try {
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
if (!response.ok) throw new Error('Failed to load units');
const data = await response.json();
if (!data.length) {
document.getElementById('swap-units-empty').classList.remove('hidden');
document.getElementById('swap-unit-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">No available seismographs.</div>';
return;
}
document.getElementById('swap-units-empty').classList.add('hidden');
_swapUnits = data.map(u => ({
value: u.id,
primary: u.id,
secondary: [u.model, u.location].filter(Boolean).join(' — '),
searchText: u.model + ' ' + u.location,
}));
_renderSwapList('unit', _swapUnits, document.getElementById('swap-unit-id').value);
} catch (err) {
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
document.getElementById('swap-error').classList.remove('hidden');
}
}
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();
_swapModems = data.map(m => ({
value: m.id,
primary: m.id,
secondary: [m.hardware_model, m.ip_address].filter(Boolean).join(' — '),
searchText: (m.hardware_model || '') + ' ' + (m.ip_address || ''),
}));
filterSwapList('modem'); // renders with "No modem" prepended
} catch (err) {
console.warn('Failed to load modems:', err);
document.getElementById('swap-modem-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">Could not load modems.</div>';
}
}
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 +518,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 +543,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 %}