3 Commits

Author SHA1 Message Date
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
13 changed files with 536 additions and 200 deletions

View File

@@ -5,6 +5,33 @@ All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.3] - 2026-03-28
### Added
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.
### Fixed
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.
### Changed
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
```
---
## [0.9.2] - 2026-03-27 ## [0.9.2] - 2026-03-27
### Added ### Added

View File

@@ -1,4 +1,4 @@
# Terra-View v0.9.2 # Terra-View v0.9.3
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features ## Features

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.2" VERSION = "0.9.3"
if ENVIRONMENT == "development": if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0") _build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0": if _build and _build != "0":

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

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

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,
@@ -40,12 +41,17 @@ router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations
# Shared helpers # Shared helpers
# ============================================================================ # ============================================================================
def _require_sound_project(project) -> None: def _require_module(project, module_type: str, db: Session) -> None:
"""Raise 400 if the project is not a sound_monitoring project.""" """Raise 400 if the project does not have the given module enabled."""
if not project or project.project_type_id != "sound_monitoring": 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( raise HTTPException(
status_code=400, status_code=400,
detail="This feature is only available for Sound Monitoring projects.", detail=f"This project does not have the {module_type.replace('_', ' ').title()} module enabled.",
) )
@@ -762,7 +768,7 @@ async def upload_nrl_data(
# Verify project and location exist # Verify project and location exist
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project) _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
@@ -955,7 +961,7 @@ async def get_nrl_live_status(
import os import os
import httpx import httpx
_require_sound_project(db.query(Project).filter_by(id=project_id).first()) _require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
# Find the assigned unit (active = assigned_until IS NULL) # Find the assigned unit (active = assigned_until IS NULL)
assignment = db.query(UnitAssignment).filter( assignment = db.query(UnitAssignment).filter(

View 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,
@@ -49,11 +50,40 @@ logger = logging.getLogger(__name__)
# Shared helpers # Shared helpers
# ============================================================================ # ============================================================================
def _require_sound_project(project: Project) -> None: # Registry of known module types. Add new entries here to make them available
"""Raise 400 if the project is not a sound_monitoring project. # in the UI for any project.
Call this at the top of any endpoint that only makes sense for sound projects MODULES = {
(report generation, FTP browser, RND file viewer, etc.).""" "sound_monitoring": {"name": "Sound Monitoring", "icon": "speaker", "color": "orange"},
if not project or project.project_type_id != "sound_monitoring": "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( raise HTTPException(
status_code=400, status_code=400,
detail="This feature is only available for Sound Monitoring projects.", detail="This feature is only available for Sound Monitoring projects.",
@@ -604,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"),
@@ -635,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,
@@ -643,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,
@@ -655,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,
@@ -867,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,
@@ -899,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),
}) })
@@ -1371,7 +1457,7 @@ async def get_ftp_browser(
from backend.models import DataFile from backend.models import DataFile
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project) _require_module(project, "sound_monitoring", db)
# Get all assignments for this project (active = assigned_until IS NULL) # Get all assignments for this project (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter( assignments = db.query(UnitAssignment).filter(
@@ -1419,7 +1505,7 @@ 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_sound_project(db.query(Project).filter_by(id=project_id).first()) _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")
@@ -1587,7 +1673,7 @@ async def ftp_download_folder_to_server(
import zipfile import zipfile
import io import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first()) _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
@@ -2147,7 +2233,7 @@ async def view_session_detail(
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_sound_project(project) _require_module(project, "sound_monitoring", db)
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first() session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
if not session: if not session:
@@ -2233,7 +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_sound_project(project) _require_module(project, "sound_monitoring", db)
# Get location info if available # Get location info if available
location = None location = None
@@ -2286,7 +2372,7 @@ async def get_rnd_data(
import csv import csv
import io import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first()) _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()
@@ -2446,7 +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_sound_project(project) _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
@@ -2877,7 +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_sound_project(project) _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
@@ -3089,7 +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_sound_project(project) _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
@@ -3370,7 +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_sound_project(project) _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()
@@ -3716,7 +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_sound_project(project) _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()
@@ -4003,7 +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_sound_project(project) _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)
@@ -4479,7 +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_sound_project(project) _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

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

@@ -1,5 +1,5 @@
<!-- Monthly Sessions Calendar — Gantt Style --> <!-- Monthly Sessions Calendar — Gantt Style -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"> <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 --> <!-- Month navigation -->
<div class="px-5 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between"> <div class="px-5 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
@@ -79,40 +79,29 @@
{% if day.sessions %} {% if day.sessions %}
<div class="space-y-2"> <div class="space-y-2">
{% for s in day.sessions %} {% for s in day.sessions %}
{% set tooltip %}{{ s.label }} · Device {{ s.dev_start_label }}{{ s.dev_end_label }}{% if s.eff_start_label %} · Window {{ s.eff_start_label }}{{ s.eff_end_label }}{% endif %}{% endset %}
<a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail" <a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail" class="block">
class="block group"
title="{{ tooltip }}">
<!-- 24-hour timeline bar --> <!-- 24-hour timeline bar -->
<div class="relative w-full rounded-sm overflow-hidden" style="height:11px; background:rgba(128,128,128,0.08);"> <div class="relative overflow-hidden -mx-1.5" style="height:11px; background:rgba(128,128,128,0.08);">
<!-- Hour guide ticks at 6h, 12h, 18h -->
<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: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: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 w-px" style="left:75%; background:rgba(128,128,128,0.18)"></div>
<div class="absolute top-0 bottom-0"
<!-- Device on/off bar (dim) --> style="left:{{ s.dev_start_pct }}%; width:{{ s.dev_width_pct }}%; background-color:{{ s.color }}; opacity:0.28;"></div>
<div class="absolute top-0 bottom-0 rounded-sm"
style="left:{{ s.dev_start_pct }}%; width:{{ s.dev_width_pct }}%; background-color:{{ s.color }}; opacity:0.28;">
</div>
<!-- Effective window (solid, slightly inset) -->
{% if s.eff_start_pct is not none %} {% if s.eff_start_pct is not none %}
<div class="absolute rounded-sm group-hover:brightness-110 transition-all" <div class="absolute"
style="left:{{ s.eff_start_pct }}%; width:{{ s.eff_width_pct }}%; top:1.5px; bottom:1.5px; background-color:{{ s.color }};"> style="left:{{ s.eff_start_pct }}%; width:{{ s.eff_width_pct }}%; top:1.5px; bottom:1.5px; background-color:{{ s.color }};"></div>
</div>
{% endif %} {% endif %}
</div> </div>
<!-- Location micro-label --> <!-- Label -->
<div class="truncate mt-0.5 group-hover:opacity-70 transition-opacity" <div class="truncate mt-0.5" style="color:{{ s.color }}; font-size:0.58rem; line-height:1.3;">
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 %}
{{ s.location_name }}{% if s.period_type %} <span class="opacity-60">{{ '☀' if 'day' in s.period_type else '☾' }}</span>{% endif %}
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -132,3 +121,4 @@
</div> </div>
</div> </div>

View File

@@ -770,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 {
@@ -828,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';
@@ -849,10 +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');
const isVibrationProject = projectTypeId === 'vibration_monitoring'; const hasVibrationModule = projectModules.includes('vibration_monitoring');
if (isSoundProject) { 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';
@@ -860,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: hidden for vibration; for sound, only show if remote // Schedules and Assigned Units: hidden when no sound module; for sound, only show if remote
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote)); document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', !hasSoundModule || !isRemote);
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isVibrationProject || (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);
@@ -996,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');
@@ -1030,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');
@@ -1060,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';
} }
@@ -1842,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>