5 Commits

Author SHA1 Message Date
0f582a8a17 Merge pull request 'Update to 0.9.3' (#43) from dev into main
## [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
```
2026-03-28 13:49:00 -04:00
5e9cc32fdc 'merge v0.9.2.' (#40) from dev into main
Reviewed-on: #40
2026-03-27 14:58:34 -04:00
40359db066 Merge pull request 'merge 0.9.1' (#39) from dev into main
## [0.9.1] - 2026-03-23

### 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.
- **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.

### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_location_slots.py
```
2026-03-23 21:17:15 -04:00
3d5b2fddef Merge pull request 'Merge 0.9.0' (#36) from dev into main
[0.9.0] - 2026-03-19

Added

Job Planner: Full redesign of the Fleet Calendar into a two-tab Job Planner / Calendar interface
Planner tab: Create and manage job reservations with name, device type, dates, color, estimated units, and monitoring locations
Calendar tab: 12-month rolling heatmap with colored job bars per day; confirmed jobs solid, planned jobs dashed
Monitoring Locations: Each job has named location slots (filled = unit assigned, empty = needs a unit); progress shown as 2/5 with colored squares that fill as units are assigned
Estimated Units: Separate planning number independent of actual location count; shown prominently on job cards
Fleet Summary panel: Unit counts as clickable filter buttons; unit list shows reservation badges with job name, dates, and color
Available Units panel: Shows units available for the job's date range when assigning
Smart color picker: 18-swatch palette + custom color wheel; new jobs auto-pick a color maximally distant in hue from existing jobs
Job card progress: est. N · X/Y (Z more) with filled/empty squares; amber → green when fully assigned
Promote to Project: Promote a planned job to a tracked project directly from the planner form
Collapsible job details: Name, dates, device type, color, project link, and estimated units collapse into a summary header
Calendar bar tooltips: Hover any job bar to see job name and date range
Hash-based tab persistence: #cal in URL restores Calendar tab on refresh; device type toggle preserves active tab
Auto-scroll to today: Switching to Calendar tab smooth-scrolls to the current month
Upcoming project status: New upcoming status for projects promoted from reservations
Job device type: Reservations carry a device type so they only appear on the correct calendar
Project filtering by device type: Projects only appear on the calendar matching their type (vibration → seismograph, sound → SLM, combined → both)
Confirmed/Planned toggles: Independent show/hide toggles for job bar layers on the calendar
Cal expire dots toggle: Calibration expiry dots off by default, togglable
Changed

Renamed: "Fleet Calendar" / "Reservation Planner" → "Job Planner" throughout UI and sidebar
Project status dropdown: Inline <select> in project header for quick status changes
"All Projects" tab: Shows everything except deleted; default view excludes archived/completed
Toast notifications: All alert() dialogs replaced with non-blocking toasts (green = success, red = error)
Migration Notes

Run on each database before deploying:

docker compose exec terra-view python3 -c "
import sqlite3
conn = sqlite3.connect('/app/data/seismo_fleet.db')
conn.execute('ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER')
conn.commit()
conn.close()
"
2026-03-20 00:51:42 -04:00
5ea64c3561 Merge pull request 'merge watcher from dev to main (0.8.0)' (#34) from dev into main
Reviewed-on: #34
2026-03-18 16:05:46 -04:00
9 changed files with 176 additions and 494 deletions

View File

@@ -1,71 +0,0 @@
"""
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, UniqueConstraint from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
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=True) # Legacy FK to ProjectType.id; use ProjectModule for feature flags project_type_id = Column(String, nullable=False) # FK to ProjectType.id
status = Column(String, default="active") # active, on_hold, completed, archived, deleted status = Column(String, default="active") # active, on_hold, completed, archived, deleted
# Data collection mode: how field data reaches Terra-View. # Data collection mode: how field data reaches Terra-View.
@@ -197,22 +197,6 @@ 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,7 +24,6 @@ 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,
@@ -41,17 +40,12 @@ router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations
# Shared helpers # Shared helpers
# ============================================================================ # ============================================================================
def _require_module(project, module_type: str, db: Session) -> None: def _require_sound_project(project) -> None:
"""Raise 400 if the project does not have the given module enabled.""" """Raise 400 if the project is not a sound_monitoring project."""
if not project: if not project or project.project_type_id != "sound_monitoring":
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=f"This project does not have the {module_type.replace('_', ' ').title()} module enabled.", detail="This feature is only available for Sound Monitoring projects.",
) )
@@ -768,7 +762,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_module(project, "sound_monitoring", db) _require_sound_project(project)
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
@@ -961,7 +955,7 @@ async def get_nrl_live_status(
import os import os
import httpx import httpx
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db) _require_sound_project(db.query(Project).filter_by(id=project_id).first())
# 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,7 +31,6 @@ import pathlib as _pathlib
from backend.models import ( from backend.models import (
Project, Project,
ProjectType, ProjectType,
ProjectModule,
MonitoringLocation, MonitoringLocation,
UnitAssignment, UnitAssignment,
MonitoringSession, MonitoringSession,
@@ -50,40 +49,11 @@ logger = logging.getLogger(__name__)
# Shared helpers # Shared helpers
# ============================================================================ # ============================================================================
# Registry of known module types. Add new entries here to make them available def _require_sound_project(project: Project) -> None:
# in the UI for any project. """Raise 400 if the project is not a sound_monitoring project.
MODULES = { Call this at the top of any endpoint that only makes sense for sound projects
"sound_monitoring": {"name": "Sound Monitoring", "icon": "speaker", "color": "orange"}, (report generation, FTP browser, RND file viewer, etc.)."""
"vibration_monitoring": {"name": "Vibration Monitoring", "icon": "activity", "color": "blue"}, if not project or project.project_type_id != "sound_monitoring":
}
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.",
@@ -634,7 +604,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") or "", project_type_id=form_data.get("project_type_id"),
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"),
@@ -665,7 +635,6 @@ 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,
@@ -674,7 +643,6 @@ 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,
@@ -687,58 +655,6 @@ 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,
@@ -951,7 +867,6 @@ 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,
@@ -984,7 +899,6 @@ 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),
}) })
@@ -1457,7 +1371,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_module(project, "sound_monitoring", db) _require_sound_project(project)
# 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(
@@ -1505,7 +1419,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_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db) _require_sound_project(db.query(Project).filter_by(id=project_id).first())
data = await request.json() data = await request.json()
unit_id = data.get("unit_id") unit_id = data.get("unit_id")
@@ -1673,7 +1587,7 @@ async def ftp_download_folder_to_server(
import zipfile import zipfile
import io import io
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db) _require_sound_project(db.query(Project).filter_by(id=project_id).first())
from pathlib import Path from pathlib import Path
from backend.models import DataFile from backend.models import DataFile
@@ -2233,7 +2147,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_module(project, "sound_monitoring", db) _require_sound_project(project)
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:
@@ -2319,7 +2233,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) _require_sound_project(project)
# Get location info if available # Get location info if available
location = None location = None
@@ -2372,7 +2286,7 @@ async def get_rnd_data(
import csv import csv
import io import io
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db) _require_sound_project(db.query(Project).filter_by(id=project_id).first())
# 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()
@@ -2532,7 +2446,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) _require_sound_project(project)
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
@@ -2963,7 +2877,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) _require_sound_project(project)
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
@@ -3175,7 +3089,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) _require_sound_project(project)
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
@@ -3456,7 +3370,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) _require_sound_project(project)
# 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()
@@ -3802,7 +3716,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) _require_sound_project(project)
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()
@@ -4089,7 +4003,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) _require_sound_project(project)
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)
@@ -4565,7 +4479,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) _require_sound_project(project)
# 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,25 +63,16 @@ 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">
Modules Project Type <span class="text-red-500">*</span>
<span class="text-gray-400 font-normal">(optional)</span>
</label> </label>
<div class="grid grid-cols-2 gap-2"> <select name="project_type_id"
<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"> id="qcp-project-type"
<input type="checkbox" name="module_sound" value="1" class="accent-seismo-orange"> required
<div> 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">
<p class="text-sm font-medium text-gray-900 dark:text-white leading-tight">Sound</p> <option value="vibration_monitoring">Vibration Monitoring</option>
<p class="text-xs text-gray-500 dark:text-gray-400">SLMs, sessions, reports</p> <option value="sound_monitoring">Sound Monitoring</option>
</div> <option value="combined">Combined (Vibration + Sound)</option>
</label> </select>
<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>
@@ -231,20 +222,6 @@ 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');
@@ -258,7 +235,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(projectId, displayText, pickerId); selectProject(result.project_id, 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 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %} {% if project_type and project_type.id == 'sound_monitoring' %}
NRLs NRLs
{% else %} {% else %}
Locations Locations
{% endif %} {% endif %}
</h3> </h3>
<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"> <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">
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %} {% if project_type and project_type.id == 'sound_monitoring' %}
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 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}?location_type=sound{% endif %}" hx-get="/api/projects/{{ project.id }}/locations{% if project_type and project_type.id == 'sound_monitoring' %}?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,30 +23,9 @@
</svg> </svg>
</span> </span>
</div> </div>
<!-- Module badges --> {% if project_type %}
<div id="module-badges" class="flex items-center gap-1.5 flex-wrap"> <span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
{% for m in modules %} {% endif %}
<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">
@@ -66,7 +45,7 @@
</div> </div>
<!-- Project Actions --> <!-- Project Actions -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{% if 'sound_monitoring' in modules %} {% if project_type and project_type.id == 'sound_monitoring' %}
<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">
@@ -78,69 +57,3 @@
</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

@@ -770,7 +770,7 @@
<script> <script>
const projectId = "{{ project_id }}"; const projectId = "{{ project_id }}";
let editingLocationId = null; let editingLocationId = null;
let projectModules = []; // list of enabled module_type strings, e.g. ['sound_monitoring'] let projectTypeId = null;
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();
projectModules = data.modules || []; projectTypeId = data.project_type_id || null;
// 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 active modules // Update tab labels and visibility based on project type
const hasSoundModule = projectModules.includes('sound_monitoring'); const isSoundProject = projectTypeId === 'sound_monitoring';
const hasVibrationModule = projectModules.includes('vibration_monitoring'); const isVibrationProject = projectTypeId === 'vibration_monitoring';
if (hasSoundModule && !hasVibrationModule) { if (isSoundProject) {
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', !hasSoundModule); document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('data-tab-btn').classList.toggle('hidden', !hasSoundModule); document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
// Schedules and Assigned Units: hidden when no sound module; for sound, only show if remote // Schedules and Assigned Units: hidden for vibration; for sound, only show if remote
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', !hasSoundModule || !isRemote); document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
document.getElementById('units-tab-btn')?.classList.toggle('hidden', !hasSoundModule || !isRemote); document.getElementById('units-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !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,13 +996,11 @@ 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');
const hasSoundMod = projectModules.includes('sound_monitoring'); if (projectTypeId === '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 (hasVibMod && !hasSoundMod) { } else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration'; locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true; locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
@@ -1032,13 +1030,11 @@ 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');
const hasSoundModE = projectModules.includes('sound_monitoring'); if (projectTypeId === '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 (hasVibModE && !hasSoundModE) { } else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration'; locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true; locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden'); if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
@@ -1064,9 +1060,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 (projectModules.includes('sound_monitoring') && !projectModules.includes('vibration_monitoring')) { if (projectTypeId === 'sound_monitoring') {
locationType = 'sound'; locationType = 'sound';
} else if (projectModules.includes('vibration_monitoring') && !projectModules.includes('sound_monitoring')) { } else if (projectTypeId === 'vibration_monitoring') {
locationType = 'vibration'; locationType = 'vibration';
} }

View File

@@ -96,38 +96,48 @@
</div> </div>
<div class="p-6" id="createProjectContent"> <div class="p-6" id="createProjectContent">
<form id="createProjectFormElement"> <!-- Step 1: Project Type Selection (initially shown) -->
<div id="projectTypeSelection">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Choose Project Type</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"
hx-get="/api/projects/types/list"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<!-- Project type cards will be loaded here -->
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
</div>
<div class="mt-6 flex justify-end">
<button type="button" onclick="hideCreateProjectModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
</div>
</div>
<!-- Step 2: Project Details Form (hidden initially) -->
<div id="projectDetailsForm" class="hidden">
<button onclick="backToTypeSelection()"
class="mb-4 text-seismo-orange hover:text-seismo-navy">
← Back to project types
</button>
<form id="createProjectFormElement"
hx-post="/api/projects/create"
hx-swap="none">
<input type="hidden" id="project_type_id" name="project_type_id">
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Name <span class="text-red-500">*</span> Project Name *
</label> </label>
<input type="text" <input type="text"
name="name" name="name"
required required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> 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 class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Number
<span class="text-gray-400 font-normal">(xxxx-YY)</span>
</label>
<input type="text"
name="project_number"
pattern="\d{4}-\d{2}"
placeholder="2567-23"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client Name
</label>
<input type="text"
name="client_name"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
</div> </div>
<div> <div>
@@ -135,70 +145,61 @@
Description Description
</label> </label>
<textarea name="description" <textarea name="description"
rows="2" rows="3"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white 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"></textarea>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client Name
</label>
<input type="text"
name="client_name"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Address Site Address
</label> </label>
<input type="text" <input type="text"
name="site_address" name="site_address"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> 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">
Site Coordinates
<span class="text-gray-400 font-normal">(optional)</span>
</label>
<input type="text"
name="site_coordinates"
placeholder="40.7128,-74.0060"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">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 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">End Date <span class="text-gray-400 font-normal">(optional)</span></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 focus:ring-2 focus:ring-seismo-orange">
</div>
</div>
<!-- Modules -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Add Modules Start Date
<span class="text-gray-400 font-normal">(optional — can be added later)</span>
</label> </label>
<div class="grid grid-cols-2 gap-3"> <input type="date"
<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"> name="start_date"
<input type="checkbox" id="ov-module-sound" class="accent-seismo-orange"> 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>
<p class="text-sm font-medium text-gray-900 dark:text-white">Sound Monitoring</p> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<p class="text-xs text-gray-500 dark:text-gray-400">SLMs, sessions, reports</p> End Date (Optional)
</div>
</label> </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="date"
<input type="checkbox" id="ov-module-vibration" class="accent-blue-500"> name="end_date"
<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">
<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> </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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Coordinates (Optional)
</label>
<input type="text"
name="site_coordinates"
placeholder="40.7128,-74.0060"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3"> <div class="mt-6 flex justify-end space-x-3">
<button type="button" <button type="button"
@@ -206,14 +207,15 @@
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" id="ov-submit-btn" <button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors"> class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Create Project Create Project
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div>
</div> </div>
<script> <script>
@@ -239,58 +241,31 @@ 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');
} }
document.getElementById('createProjectFormElement').addEventListener('submit', async function(e) { function selectProjectType(typeId, typeName) {
e.preventDefault(); document.getElementById('project_type_id').value = typeId;
const submitBtn = document.getElementById('ov-submit-btn'); document.getElementById('projectTypeSelection').classList.add('hidden');
const errorDiv = document.getElementById('ov-create-error'); document.getElementById('projectDetailsForm').classList.remove('hidden');
errorDiv.classList.add('hidden'); }
submitBtn.disabled = true;
submitBtn.textContent = 'Creating...';
const formData = new FormData(this); function backToTypeSelection() {
// project_type_id no longer required — send empty string so backend accepts it document.getElementById('projectTypeSelection').classList.remove('hidden');
formData.set('project_type_id', ''); document.getElementById('projectDetailsForm').classList.add('hidden');
}
try { // Handle form submission success
const resp = await fetch('/api/projects/create', { method: 'POST', body: formData }); document.body.addEventListener('htmx:afterRequest', function(event) {
const result = await resp.json(); if (event.detail.elt.id === 'createProjectFormElement' && event.detail.successful) {
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'});
} catch(err) { // Show success message
errorDiv.textContent = `Error: ${err.message}`; alert('Project created successfully!');
errorDiv.classList.remove('hidden');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Create Project';
} }
}); });
</script> </script>