Compare commits
3 Commits
8a5fadb5df
...
d93785c230
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d93785c230 | ||
|
|
98ee9d7cea | ||
|
|
04c66bdf9c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -211,3 +211,4 @@ __marimo__/
|
|||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
data/
|
data/
|
||||||
|
.aider*
|
||||||
|
|||||||
@@ -157,6 +157,15 @@ async def sound_level_meters_page(request: Request):
|
|||||||
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/slm/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def slm_legacy_dashboard(request: Request, unit_id: str):
|
||||||
|
"""Legacy SLM control center dashboard for a specific unit"""
|
||||||
|
return templates.TemplateResponse("slm_legacy_dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit_id": unit_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/seismographs", response_class=HTMLResponse)
|
@app.get("/seismographs", response_class=HTMLResponse)
|
||||||
async def seismographs_page(request: Request):
|
async def seismographs_page(request: Request):
|
||||||
"""Seismographs management dashboard"""
|
"""Seismographs management dashboard"""
|
||||||
@@ -178,6 +187,80 @@ async def project_detail_page(request: Request, project_id: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
|
||||||
|
async def nrl_detail_page(
|
||||||
|
request: Request,
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""NRL (Noise Recording Location) detail page with tabs"""
|
||||||
|
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
|
||||||
|
from sqlalchemy import and_
|
||||||
|
|
||||||
|
# Get project
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
return templates.TemplateResponse("404.html", {
|
||||||
|
"request": request,
|
||||||
|
"message": "Project not found"
|
||||||
|
}, status_code=404)
|
||||||
|
|
||||||
|
# Get location
|
||||||
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
|
id=location_id,
|
||||||
|
project_id=project_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not location:
|
||||||
|
return templates.TemplateResponse("404.html", {
|
||||||
|
"request": request,
|
||||||
|
"message": "Location not found"
|
||||||
|
}, status_code=404)
|
||||||
|
|
||||||
|
# Get active assignment
|
||||||
|
assignment = db.query(UnitAssignment).filter(
|
||||||
|
and_(
|
||||||
|
UnitAssignment.location_id == location_id,
|
||||||
|
UnitAssignment.status == "active"
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
assigned_unit = None
|
||||||
|
if assignment:
|
||||||
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
|
||||||
|
# Get session count
|
||||||
|
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
|
||||||
|
|
||||||
|
# Get file count (DataFile links to session, not directly to location)
|
||||||
|
file_count = db.query(DataFile).join(
|
||||||
|
RecordingSession,
|
||||||
|
DataFile.session_id == RecordingSession.id
|
||||||
|
).filter(RecordingSession.location_id == location_id).count()
|
||||||
|
|
||||||
|
# Check for active session
|
||||||
|
active_session = db.query(RecordingSession).filter(
|
||||||
|
and_(
|
||||||
|
RecordingSession.location_id == location_id,
|
||||||
|
RecordingSession.status == "recording"
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("nrl_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"project_id": project_id,
|
||||||
|
"location_id": location_id,
|
||||||
|
"project": project,
|
||||||
|
"location": location,
|
||||||
|
"assignment": assignment,
|
||||||
|
"assigned_unit": assigned_unit,
|
||||||
|
"session_count": session_count,
|
||||||
|
"file_count": file_count,
|
||||||
|
"active_session": active_session,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ===== PWA ROUTES =====
|
# ===== PWA ROUTES =====
|
||||||
|
|
||||||
@app.get("/sw.js")
|
@app.get("/sw.js")
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ async def update_location(
|
|||||||
location.name = data["name"]
|
location.name = data["name"]
|
||||||
if "description" in data:
|
if "description" in data:
|
||||||
location.description = data["description"]
|
location.description = data["description"]
|
||||||
|
if "location_type" in data:
|
||||||
|
location.location_type = data["location_type"]
|
||||||
if "coordinates" in data:
|
if "coordinates" in data:
|
||||||
location.coordinates = data["coordinates"]
|
location.coordinates = data["coordinates"]
|
||||||
if "address" in data:
|
if "address" in data:
|
||||||
@@ -402,3 +404,85 @@ async def get_available_units(
|
|||||||
]
|
]
|
||||||
|
|
||||||
return available_units
|
return available_units
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# NRL-specific endpoints for detail page
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/nrl/{location_id}/sessions", response_class=HTMLResponse)
|
||||||
|
async def get_nrl_sessions(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get recording sessions for a specific NRL.
|
||||||
|
Returns HTML partial with session list.
|
||||||
|
"""
|
||||||
|
from backend.models import RecordingSession, RosterUnit
|
||||||
|
|
||||||
|
sessions = db.query(RecordingSession).filter_by(
|
||||||
|
location_id=location_id
|
||||||
|
).order_by(RecordingSession.started_at.desc()).all()
|
||||||
|
|
||||||
|
# Enrich with unit details
|
||||||
|
sessions_data = []
|
||||||
|
for session in sessions:
|
||||||
|
unit = None
|
||||||
|
if session.unit_id:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()
|
||||||
|
|
||||||
|
sessions_data.append({
|
||||||
|
"session": session,
|
||||||
|
"unit": unit,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/session_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"project_id": project_id,
|
||||||
|
"location_id": location_id,
|
||||||
|
"sessions": sessions_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/nrl/{location_id}/files", response_class=HTMLResponse)
|
||||||
|
async def get_nrl_files(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get data files for a specific NRL.
|
||||||
|
Returns HTML partial with file list.
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile, RecordingSession
|
||||||
|
|
||||||
|
# Join DataFile with RecordingSession to filter by location_id
|
||||||
|
files = db.query(DataFile).join(
|
||||||
|
RecordingSession,
|
||||||
|
DataFile.session_id == RecordingSession.id
|
||||||
|
).filter(
|
||||||
|
RecordingSession.location_id == location_id
|
||||||
|
).order_by(DataFile.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Enrich with session details
|
||||||
|
files_data = []
|
||||||
|
for file in files:
|
||||||
|
session = None
|
||||||
|
if file.session_id:
|
||||||
|
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
|
||||||
|
|
||||||
|
files_data.append({
|
||||||
|
"file": file,
|
||||||
|
"session": session,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/file_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"project_id": project_id,
|
||||||
|
"location_id": location_id,
|
||||||
|
"files": files_data,
|
||||||
|
})
|
||||||
|
|||||||
@@ -341,6 +341,230 @@ async def get_project_dashboard(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Project Types
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/{project_id}/header", response_class=JSONResponse)
|
||||||
|
async def get_project_header(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get project header information for dynamic display.
|
||||||
|
Returns JSON with project name, status, and type.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"id": project.id,
|
||||||
|
"name": project.name,
|
||||||
|
"status": project.status,
|
||||||
|
"project_type_id": project.project_type_id,
|
||||||
|
"project_type_name": project_type.name if project_type else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/units", response_class=HTMLResponse)
|
||||||
|
async def get_project_units(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all units assigned to this project's locations.
|
||||||
|
Returns HTML partial with unit list.
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
|
||||||
|
# Get all assignments for this project
|
||||||
|
assignments = db.query(UnitAssignment).filter(
|
||||||
|
and_(
|
||||||
|
UnitAssignment.project_id == project_id,
|
||||||
|
UnitAssignment.status == "active",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Enrich with unit and location details
|
||||||
|
units_data = []
|
||||||
|
for assignment in assignments:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||||
|
|
||||||
|
# Count sessions for this assignment
|
||||||
|
session_count = db.query(func.count(RecordingSession.id)).filter_by(
|
||||||
|
location_id=assignment.location_id,
|
||||||
|
unit_id=assignment.unit_id,
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
# Count files from sessions
|
||||||
|
file_count = db.query(func.count(DataFile.id)).join(
|
||||||
|
RecordingSession,
|
||||||
|
DataFile.session_id == RecordingSession.id
|
||||||
|
).filter(
|
||||||
|
RecordingSession.location_id == assignment.location_id,
|
||||||
|
RecordingSession.unit_id == assignment.unit_id,
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
# Check if currently recording
|
||||||
|
active_session = db.query(RecordingSession).filter(
|
||||||
|
and_(
|
||||||
|
RecordingSession.location_id == assignment.location_id,
|
||||||
|
RecordingSession.unit_id == assignment.unit_id,
|
||||||
|
RecordingSession.status == "recording",
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
units_data.append({
|
||||||
|
"assignment": assignment,
|
||||||
|
"unit": unit,
|
||||||
|
"location": location,
|
||||||
|
"session_count": session_count,
|
||||||
|
"file_count": file_count,
|
||||||
|
"active_session": active_session,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get project type for label context
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() if project else None
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/unit_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"project_id": project_id,
|
||||||
|
"units": units_data,
|
||||||
|
"project_type": project_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/schedules", response_class=HTMLResponse)
|
||||||
|
async def get_project_schedules(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get scheduled actions for this project.
|
||||||
|
Returns HTML partial with schedule list.
|
||||||
|
Optional status filter: pending, completed, failed, cancelled
|
||||||
|
"""
|
||||||
|
query = db.query(ScheduledAction).filter_by(project_id=project_id)
|
||||||
|
|
||||||
|
# Filter by status if provided
|
||||||
|
if status:
|
||||||
|
query = query.filter(ScheduledAction.execution_status == status)
|
||||||
|
|
||||||
|
schedules = query.order_by(ScheduledAction.scheduled_time.desc()).all()
|
||||||
|
|
||||||
|
# Enrich with location details
|
||||||
|
schedules_data = []
|
||||||
|
for schedule in schedules:
|
||||||
|
location = None
|
||||||
|
if schedule.location_id:
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first()
|
||||||
|
|
||||||
|
schedules_data.append({
|
||||||
|
"schedule": schedule,
|
||||||
|
"location": location,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/schedule_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"project_id": project_id,
|
||||||
|
"schedules": schedules_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/sessions", response_class=HTMLResponse)
|
||||||
|
async def get_project_sessions(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all recording sessions for this project.
|
||||||
|
Returns HTML partial with session list.
|
||||||
|
Optional status filter: recording, completed, paused, failed
|
||||||
|
"""
|
||||||
|
query = db.query(RecordingSession).filter_by(project_id=project_id)
|
||||||
|
|
||||||
|
# Filter by status if provided
|
||||||
|
if status:
|
||||||
|
query = query.filter(RecordingSession.status == status)
|
||||||
|
|
||||||
|
sessions = query.order_by(RecordingSession.started_at.desc()).all()
|
||||||
|
|
||||||
|
# Enrich with unit and location details
|
||||||
|
sessions_data = []
|
||||||
|
for session in sessions:
|
||||||
|
unit = None
|
||||||
|
location = None
|
||||||
|
|
||||||
|
if session.unit_id:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()
|
||||||
|
if session.location_id:
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
|
||||||
|
|
||||||
|
sessions_data.append({
|
||||||
|
"session": session,
|
||||||
|
"unit": unit,
|
||||||
|
"location": location,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/session_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"project_id": project_id,
|
||||||
|
"sessions": sessions_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/files", response_class=HTMLResponse)
|
||||||
|
async def get_project_files(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
file_type: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all data files from all sessions in this project.
|
||||||
|
Returns HTML partial with file list.
|
||||||
|
Optional file_type filter: audio, data, log, etc.
|
||||||
|
"""
|
||||||
|
from backend.models import DataFile
|
||||||
|
|
||||||
|
# Join through RecordingSession to get project files
|
||||||
|
query = db.query(DataFile).join(
|
||||||
|
RecordingSession,
|
||||||
|
DataFile.session_id == RecordingSession.id
|
||||||
|
).filter(RecordingSession.project_id == project_id)
|
||||||
|
|
||||||
|
# Filter by file type if provided
|
||||||
|
if file_type:
|
||||||
|
query = query.filter(DataFile.file_type == file_type)
|
||||||
|
|
||||||
|
files = query.order_by(DataFile.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Enrich with session details
|
||||||
|
files_data = []
|
||||||
|
for file in files:
|
||||||
|
session = None
|
||||||
|
if file.session_id:
|
||||||
|
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
|
||||||
|
|
||||||
|
files_data.append({
|
||||||
|
"file": file,
|
||||||
|
"session": session,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/file_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"project_id": project_id,
|
||||||
|
"files": files_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Project Types
|
# Project Types
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -61,13 +61,9 @@ async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
async def get_slm_units(
|
async def get_slm_units(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
<<<<<<< Updated upstream
|
|
||||||
search: str = Query(None)
|
|
||||||
=======
|
|
||||||
search: str = Query(None),
|
search: str = Query(None),
|
||||||
project: str = Query(None),
|
project: str = Query(None),
|
||||||
include_measurement: bool = Query(False),
|
include_measurement: bool = Query(False),
|
||||||
>>>>>>> Stashed changes
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get list of SLM units for the sidebar.
|
Get list of SLM units for the sidebar.
|
||||||
@@ -75,6 +71,10 @@ async def get_slm_units(
|
|||||||
"""
|
"""
|
||||||
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter")
|
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter")
|
||||||
|
|
||||||
|
# Filter by project if provided
|
||||||
|
if project:
|
||||||
|
query = query.filter(RosterUnit.project_id == project)
|
||||||
|
|
||||||
# Filter by search term if provided
|
# Filter by search term if provided
|
||||||
if search:
|
if search:
|
||||||
search_term = f"%{search}%"
|
search_term = f"%{search}%"
|
||||||
|
|||||||
@@ -23,26 +23,26 @@ services:
|
|||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# --- TERRA-VIEW DEVELOPMENT ---
|
# --- TERRA-VIEW DEVELOPMENT ---
|
||||||
terra-view-dev:
|
# terra-view-dev:
|
||||||
build: .
|
# build: .
|
||||||
container_name: terra-view-dev
|
# container_name: terra-view-dev
|
||||||
ports:
|
# ports:
|
||||||
- "1001:8001"
|
# - "1001:8001"
|
||||||
volumes:
|
# volumes:
|
||||||
- ./data-dev:/app/data
|
# - ./data-dev:/app/data
|
||||||
environment:
|
# environment:
|
||||||
- PYTHONUNBUFFERED=1
|
# - PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=development
|
# - ENVIRONMENT=development
|
||||||
- SLMM_BASE_URL=http://slmm:8100
|
# - SLMM_BASE_URL=http://slmm:8100
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
depends_on:
|
# depends_on:
|
||||||
- slmm
|
# - slmm
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
# test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
interval: 30s
|
# interval: 30s
|
||||||
timeout: 10s
|
# timeout: 10s
|
||||||
retries: 3
|
# retries: 3
|
||||||
start_period: 40s
|
# start_period: 40s
|
||||||
|
|
||||||
# --- SLMM (Sound Level Meter Manager) ---
|
# --- SLMM (Sound Level Meter Manager) ---
|
||||||
slmm:
|
slmm:
|
||||||
|
|||||||
563
templates/nrl_detail.html
Normal file
563
templates/nrl_detail.html
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ location.name }} - NRL Detail{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb Navigation -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
|
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
Projects
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/projects/{{ project_id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ project.name }}
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">{{ location.name }}</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ location.name }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Noise Recording Location • {{ project.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if assigned_unit %}
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
Unit Assigned
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
No Unit Assigned
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex space-x-6">
|
||||||
|
<button onclick="switchTab('overview')"
|
||||||
|
data-tab="overview"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('settings')"
|
||||||
|
data-tab="settings"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
{% if assigned_unit %}
|
||||||
|
<button onclick="switchTab('command')"
|
||||||
|
data-tab="command"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
|
Command Center
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="switchTab('sessions')"
|
||||||
|
data-tab="sessions"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
|
Recording Sessions
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('data')"
|
||||||
|
data-tab="data"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
|
Data Files
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div id="tab-content">
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div id="overview-tab" class="tab-panel">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Location Details Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location Details</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Name</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ location.name }}</div>
|
||||||
|
</div>
|
||||||
|
{% if location.description %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Description</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ location.description }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if location.address %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Address</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ location.address }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if location.coordinates %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Coordinates</div>
|
||||||
|
<div class="text-gray-900 dark:text-white font-mono text-sm">{{ location.coordinates }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ location.created_at.strftime('%Y-%m-%d %H:%M') if location.created_at else 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
||||||
|
{% if assigned_unit %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
<a href="/slm/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ assigned_unit.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if assigned_unit.slm_model %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ assigned_unit.slm_model }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if assignment %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at.strftime('%Y-%m-%d %H:%M') if assignment.assigned_at else 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
{% if assignment.notes %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||||
|
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="pt-2">
|
||||||
|
<button onclick="unassignUnit('{{ assignment.id }}')"
|
||||||
|
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
||||||
|
Unassign Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
||||||
|
<button onclick="openAssignModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
Assign a Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Total Sessions</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ session_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Data Files</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ file_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
|
{% if active_session %}
|
||||||
|
<span class="text-green-600 dark:text-green-400">Recording</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500">Idle</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div id="settings-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Location Settings</h2>
|
||||||
|
|
||||||
|
<form id="location-settings-form" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
|
||||||
|
<input type="text" id="settings-name" value="{{ location.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" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||||
|
<textarea id="settings-description" 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">{{ location.description or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
|
<input type="text" id="settings-address" value="{{ location.address or '' }}"
|
||||||
|
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">Coordinates</label>
|
||||||
|
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
|
||||||
|
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 id="settings-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
|
||||||
|
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"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Command Center Tab -->
|
||||||
|
{% if assigned_unit %}
|
||||||
|
<div id="command-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
|
SLM Command Center - {{ assigned_unit.id }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div id="slm-command-center"
|
||||||
|
hx-get="/api/slm-dashboard/live-view/{{ assigned_unit.id if assigned_unit else '' }}"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
||||||
|
<p>Loading command center...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Recording Sessions Tab -->
|
||||||
|
<div id="sessions-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
||||||
|
{% if assigned_unit %}
|
||||||
|
<button onclick="openScheduleModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
Schedule Session
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sessions-list"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/sessions"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Files Tab -->
|
||||||
|
<div id="data-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<span class="font-medium">{{ file_count }}</span> files
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="data-files-list"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/files"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading data files...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assign Unit Modal -->
|
||||||
|
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a sound level meter to this location</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="assign-form" class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
||||||
|
<select id="assign-unit-id" name="unit_id"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||||
|
<option value="">Loading units...</option>
|
||||||
|
</select>
|
||||||
|
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available units for this location type.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
|
<textarea id="assign-notes" name="notes" rows="2"
|
||||||
|
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 id="assign-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="closeAssignModal()"
|
||||||
|
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"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Assign Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const projectId = "{{ project_id }}";
|
||||||
|
const locationId = "{{ location_id }}";
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
function switchTab(tabName) {
|
||||||
|
// Hide all tab panels
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset all tab buttons
|
||||||
|
document.querySelectorAll('.tab-button').forEach(button => {
|
||||||
|
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||||
|
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show selected tab panel
|
||||||
|
const panel = document.getElementById(`${tabName}-tab`);
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight selected tab button
|
||||||
|
const button = document.querySelector(`[data-tab="${tabName}"]`);
|
||||||
|
if (button) {
|
||||||
|
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location settings form submission
|
||||||
|
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('settings-name').value.trim(),
|
||||||
|
description: document.getElementById('settings-description').value.trim() || null,
|
||||||
|
address: document.getElementById('settings-address').value.trim() || null,
|
||||||
|
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to update location');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('settings-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to update location.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign modal functions
|
||||||
|
function openAssignModal() {
|
||||||
|
const modal = document.getElementById('assign-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
loadAvailableUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssignModal() {
|
||||||
|
document.getElementById('assign-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableUnits() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=sound`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load available units');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const select = document.getElementById('assign-unit-id');
|
||||||
|
select.innerHTML = '<option value="">Select a unit</option>';
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
document.getElementById('assign-empty').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(unit => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = unit.id;
|
||||||
|
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('assign-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to load units.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const unitId = document.getElementById('assign-unit-id').value;
|
||||||
|
const notes = document.getElementById('assign-notes').value.trim();
|
||||||
|
|
||||||
|
if (!unitId) {
|
||||||
|
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
||||||
|
document.getElementById('assign-error').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('unit_id', unitId);
|
||||||
|
formData.append('notes', notes);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to assign unit');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('assign-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to assign unit.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function unassignUnit(assignmentId) {
|
||||||
|
if (!confirm('Unassign this unit from the location?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to unassign unit');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to unassign unit.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeAssignModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside to close modal
|
||||||
|
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeAssignModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
30
templates/partials/projects/assignment_list.html
Normal file
30
templates/partials/projects/assignment_list.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<!-- Project Assignments List -->
|
||||||
|
{% if assignments %}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for item in assignments %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-white">{{ item.unit.id if item.unit else item.assignment.unit_id }}</p>
|
||||||
|
{% if item.location %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Location: {{ item.location.name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Assigned: {% if item.assignment.assigned_at %}{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}{% else %}Unknown{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
Unassign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No active assignments</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
126
templates/partials/projects/file_list.html
Normal file
126
templates/partials/projects/file_list.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<!-- Data Files List -->
|
||||||
|
{% if files %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
File Name
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Size
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Session
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for item in files %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ item.file.file_path.split('/')[-1] if item.file.file_path else 'Unknown' }}
|
||||||
|
</div>
|
||||||
|
{% if item.file.file_path %}
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||||
|
{{ item.file.file_path }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded-full
|
||||||
|
{% if item.file.file_type == 'audio' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
|
||||||
|
{% elif item.file.file_type == 'data' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||||
|
{% elif item.file.file_type == 'log' %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300
|
||||||
|
{% else %}bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300{% endif %}">
|
||||||
|
{{ item.file.file_type or 'unknown' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{% if item.file.file_size_bytes %}
|
||||||
|
{% if item.file.file_size_bytes < 1024 %}
|
||||||
|
{{ item.file.file_size_bytes }} B
|
||||||
|
{% elif item.file.file_size_bytes < 1048576 %}
|
||||||
|
{{ "%.1f"|format(item.file.file_size_bytes / 1024) }} KB
|
||||||
|
{% elif item.file.file_size_bytes < 1073741824 %}
|
||||||
|
{{ "%.1f"|format(item.file.file_size_bytes / 1048576) }} MB
|
||||||
|
{% else %}
|
||||||
|
{{ "%.2f"|format(item.file.file_size_bytes / 1073741824) }} GB
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{{ item.file.created_at.strftime('%Y-%m-%d %H:%M') if item.file.created_at else 'N/A' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
|
{% if item.session %}
|
||||||
|
<span class="text-gray-900 dark:text-white font-mono text-xs">
|
||||||
|
{{ item.session.id[:8] }}...
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button onclick="downloadFile('{{ item.file.id }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"
|
||||||
|
title="Download file">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="viewFileDetails('{{ item.file.id }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="View details">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">Files will appear here after recording sessions</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function downloadFile(fileId) {
|
||||||
|
// TODO: Implement file download
|
||||||
|
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewFileDetails(fileId) {
|
||||||
|
// TODO: Implement file details modal
|
||||||
|
alert('File details coming soon: ' + fileId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
69
templates/partials/projects/location_list.html
Normal file
69
templates/partials/projects/location_list.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<!-- Project Locations List -->
|
||||||
|
{% if locations %}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for item in locations %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
{% if item.location.location_type %}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
{{ item.location.location_type|capitalize }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if item.location.description %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.address %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.coordinates %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if item.assignment %}
|
||||||
|
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
Unassign
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')" class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
||||||
|
onclick="openEditLocationModal(this)"
|
||||||
|
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteLocation('{{ item.location.id }}')" class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
|
<span>Sessions: {{ item.session_count }}</span>
|
||||||
|
{% if item.assignment and item.assigned_unit %}
|
||||||
|
<span>Assigned: {{ item.assigned_unit.id }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>No active assignment</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No locations added yet</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -44,47 +44,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<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">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Locations</h3>
|
<div class="flex items-center justify-between mb-4">
|
||||||
{% if locations %}
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
<div class="space-y-3">
|
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||||
{% for location in locations %}
|
NRLs
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
{% else %}
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ location.name }}</p>
|
Locations
|
||||||
{% if location.address %}
|
{% endif %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ location.address }}</p>
|
</h3>
|
||||||
{% endif %}
|
<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 location.coordinates %}
|
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ location.coordinates }}</p>
|
Add NRL
|
||||||
{% endif %}
|
{% else %}
|
||||||
</div>
|
Add Location
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="animate-pulse space-y-3">
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No locations added yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Assigned Units</h3>
|
|
||||||
{% if assigned_units %}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{% for item in assigned_units %}
|
|
||||||
<a href="/slm/{{ item.unit.id }}" class="block border border-gray-200 dark:border-gray-700 rounded-lg p-3 hover:border-seismo-orange transition-colors">
|
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ item.unit.id }}</p>
|
|
||||||
{% if item.unit.slm_model %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.unit.slm_model }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.unit.address %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.unit.address }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No units assigned yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
|
|||||||
149
templates/partials/projects/schedule_list.html
Normal file
149
templates/partials/projects/schedule_list.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<!-- Scheduled Actions List -->
|
||||||
|
{% if schedules %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for item in schedules %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ item.schedule.action_type }}
|
||||||
|
</h4>
|
||||||
|
{% if item.schedule.execution_status == 'pending' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
{% elif item.schedule.execution_status == 'completed' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
{% elif item.schedule.execution_status == 'failed' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full">
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
{% elif item.schedule.execution_status == 'cancelled' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
||||||
|
Cancelled
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{% if item.location %}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Location:</span>
|
||||||
|
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Scheduled:</span>
|
||||||
|
<span class="ml-1">{{ item.schedule.scheduled_time.strftime('%Y-%m-%d %H:%M') if item.schedule.scheduled_time else 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.schedule.executed_at %}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Executed:</span>
|
||||||
|
<span class="ml-1">{{ item.schedule.executed_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.schedule.created_at %}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Created:</span>
|
||||||
|
<span class="ml-1">{{ item.schedule.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.schedule.description %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{{ item.schedule.description }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.schedule.result_message %}
|
||||||
|
<div class="mt-2 text-xs">
|
||||||
|
<span class="text-gray-500">Result:</span>
|
||||||
|
<span class="ml-1 text-gray-700 dark:text-gray-300">{{ item.schedule.result_message }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if item.schedule.execution_status == 'pending' %}
|
||||||
|
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
Execute Now
|
||||||
|
</button>
|
||||||
|
<button onclick="cancelSchedule('{{ item.schedule.id }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 rounded-lg hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="viewScheduleDetails('{{ item.schedule.id }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No scheduled actions yet</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">Create schedules to automate tasks</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function executeSchedule(scheduleId) {
|
||||||
|
if (!confirm('Execute this scheduled action now?')) return;
|
||||||
|
|
||||||
|
fetch(`/api/projects/{{ project_id }}/schedules/${scheduleId}/execute`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
htmx.trigger('#project-schedules', 'refresh');
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error executing schedule: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSchedule(scheduleId) {
|
||||||
|
if (!confirm('Cancel this scheduled action?')) return;
|
||||||
|
|
||||||
|
fetch(`/api/projects/{{ project_id }}/schedules/${scheduleId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
htmx.trigger('#project-schedules', 'refresh');
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error cancelling schedule: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewScheduleDetails(scheduleId) {
|
||||||
|
// TODO: Implement schedule details modal
|
||||||
|
alert('Schedule details coming soon: ' + scheduleId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
107
templates/partials/projects/session_list.html
Normal file
107
templates/partials/projects/session_list.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<!-- Recording Sessions List -->
|
||||||
|
{% if sessions %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for item in sessions %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||||
|
Session {{ item.session.id[:8] }}...
|
||||||
|
</h4>
|
||||||
|
{% if item.session.status == 'recording' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center">
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span>
|
||||||
|
Recording
|
||||||
|
</span>
|
||||||
|
{% elif item.session.status == 'completed' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
{% elif item.session.status == 'paused' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
{% elif item.session.status == 'failed' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{% if item.unit %}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500">Unit:</span>
|
||||||
|
<a href="/slm/{{ item.unit.id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
||||||
|
{{ item.unit.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Started:</span>
|
||||||
|
<span class="ml-1">{{ item.session.started_at.strftime('%Y-%m-%d %H:%M') if item.session.started_at else 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.session.stopped_at %}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Ended:</span>
|
||||||
|
<span class="ml-1">{{ item.session.stopped_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.session.duration_seconds %}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Duration:</span>
|
||||||
|
<span class="ml-1">{{ (item.session.duration_seconds // 3600) }}h {{ ((item.session.duration_seconds % 3600) // 60) }}m</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.session.notes %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{{ item.session.notes }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if item.session.status == 'recording' %}
|
||||||
|
<button onclick="stopRecording('{{ item.session.id }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="viewSession('{{ item.session.id }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function viewSession(sessionId) {
|
||||||
|
// TODO: Implement session detail modal or page
|
||||||
|
alert('Session details coming soon: ' + sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording(sessionId) {
|
||||||
|
if (!confirm('Stop this recording session?')) return;
|
||||||
|
|
||||||
|
// TODO: Implement stop recording API call
|
||||||
|
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
99
templates/partials/projects/unit_list.html
Normal file
99
templates/partials/projects/unit_list.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<!-- Assigned Units List -->
|
||||||
|
{% if units %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for item in units %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||||
|
<a href="/slm/{{ item.unit.id }}" class="hover:text-seismo-orange">
|
||||||
|
{{ item.unit.id }}
|
||||||
|
</a>
|
||||||
|
</h4>
|
||||||
|
{% if item.active_session %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center">
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span>
|
||||||
|
Recording
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
||||||
|
Available
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{% if item.location %}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||||
|
NRL:
|
||||||
|
{% else %}
|
||||||
|
Location:
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.unit.slm_model %}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Model:</span>
|
||||||
|
<span class="ml-1">{{ item.unit.slm_model }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Sessions:</span>
|
||||||
|
<span class="ml-1">{{ item.session_count }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Files:</span>
|
||||||
|
<span class="ml-1">{{ item.file_count }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.assignment.assigned_at %}
|
||||||
|
<div class="col-span-2">
|
||||||
|
<span class="text-xs text-gray-500">Assigned:</span>
|
||||||
|
<span class="ml-1">{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.unit.note %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{{ item.unit.note }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/slm/{{ item.unit.id }}"
|
||||||
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
View Unit
|
||||||
|
</a>
|
||||||
|
{% if item.location %}
|
||||||
|
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
View NRL
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No units assigned yet</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">Assign units to locations to get started</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
<!-- SLM Device List -->
|
<!-- SLM Device List -->
|
||||||
{% if units %}
|
{% if units %}
|
||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<a href="/slm/{{ unit.id }}" class="block bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors">
|
<a href="/slm/{{ unit.id }}" class="block bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
||||||
|
<button onclick="event.preventDefault(); event.stopPropagation(); openDeviceConfigModal('{{ unit.id }}');"
|
||||||
|
class="absolute top-3 right-3 text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||||
|
title="Configure {{ unit.id }}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -3,25 +3,844 @@
|
|||||||
{% block title %}Project Dashboard - Terra-View{% endblock %}
|
{% block title %}Project Dashboard - Terra-View{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<!-- Breadcrumb Navigation -->
|
||||||
<div>
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Project Dashboard</h1>
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Sound monitoring project overview and assignments</p>
|
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||||
</div>
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<a href="/projects" class="text-sm text-seismo-orange hover:text-seismo-navy">Back to projects</a>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
Projects
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="project-dashboard"
|
<!-- Header (loads dynamically) -->
|
||||||
hx-get="/api/projects/{{ project_id }}/dashboard"
|
<div id="project-header" hx-get="/api/projects/{{ project_id }}/header" hx-trigger="load" hx-swap="innerHTML">
|
||||||
hx-trigger="load, every 30s"
|
<div class="mb-8 animate-pulse">
|
||||||
hx-swap="innerHTML">
|
<div class="h-10 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2"></div>
|
||||||
<div class="animate-pulse space-y-4">
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4"></div>
|
||||||
<div class="h-24 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
</div>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
</div>
|
||||||
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
|
||||||
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
<!-- Tab Navigation -->
|
||||||
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex space-x-6 overflow-x-auto">
|
||||||
|
<button onclick="switchTab('overview')"
|
||||||
|
data-tab="overview"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange whitespace-nowrap">
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('locations')"
|
||||||
|
data-tab="locations"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
|
<span id="locations-tab-label">Locations</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('units')"
|
||||||
|
data-tab="units"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
|
Assigned Units
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('schedules')"
|
||||||
|
data-tab="schedules"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
|
Schedules
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('sessions')"
|
||||||
|
data-tab="sessions"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
|
Recording Sessions
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('data')"
|
||||||
|
data-tab="data"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
|
Data Files
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('settings')"
|
||||||
|
data-tab="settings"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div id="tab-content">
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div id="overview-tab" class="tab-panel">
|
||||||
|
<div id="project-dashboard"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/dashboard"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="animate-pulse space-y-4">
|
||||||
|
<div class="h-24 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
||||||
|
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Locations Tab -->
|
||||||
|
<div id="locations-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
<span id="locations-header">Locations</span>
|
||||||
|
</h2>
|
||||||
|
<button onclick="openLocationModal()" id="add-location-btn"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="add-location-label">Add Location</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-locations"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/locations"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Units Tab -->
|
||||||
|
<div id="units-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Assigned Units</h2>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Units currently assigned to this project's locations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-units"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/units"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading units...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedules Tab -->
|
||||||
|
<div id="schedules-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Scheduled Actions</h2>
|
||||||
|
<button onclick="openScheduleModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
Schedule Action
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-schedules"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/schedules"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading schedules...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording Sessions Tab -->
|
||||||
|
<div id="sessions-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<select id="sessions-filter" onchange="filterSessions()"
|
||||||
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<option value="all">All Sessions</option>
|
||||||
|
<option value="recording">Recording</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-sessions"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/sessions"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Files Tab -->
|
||||||
|
<div id="data-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<select id="files-filter" onchange="filterFiles()"
|
||||||
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<option value="all">All Files</option>
|
||||||
|
<option value="audio">Audio</option>
|
||||||
|
<option value="data">Data</option>
|
||||||
|
<option value="log">Logs</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="exportProjectData()"
|
||||||
|
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Export All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-files"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/files"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading data files...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div id="settings-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Project Settings</h2>
|
||||||
|
|
||||||
|
<form id="project-settings-form" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project Name</label>
|
||||||
|
<input type="text" name="name" id="settings-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" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||||
|
<textarea name="description" id="settings-description" 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"></textarea>
|
||||||
|
</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">Client Name</label>
|
||||||
|
<input type="text" name="client_name" id="settings-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>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||||
|
<select name="status" id="settings-status"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</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" id="settings-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>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Coordinates</label>
|
||||||
|
<input type="text" name="site_coordinates" id="settings-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 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" id="settings-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</label>
|
||||||
|
<input type="date" name="end_date" id="settings-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 id="settings-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="loadProjectDetails()"
|
||||||
|
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">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Archive this project to remove it from active listings. All data will be preserved.
|
||||||
|
</p>
|
||||||
|
<button onclick="archiveProject()"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Archive Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Location Modal -->
|
||||||
|
<div id="location-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 id="location-modal-title" class="text-2xl font-bold text-gray-900 dark:text-white">Add Location</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Create or update a monitoring location</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeLocationModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="location-form" class="p-6 space-y-4">
|
||||||
|
<input type="hidden" id="location-id">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
|
||||||
|
<input type="text" name="name" id="location-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" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||||
|
<textarea name="description" id="location-description" 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"></textarea>
|
||||||
|
</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">Type</label>
|
||||||
|
<select name="location_type" id="location-type"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="sound">Sound</option>
|
||||||
|
<option value="vibration">Vibration</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||||
|
<input type="text" name="coordinates" id="location-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">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
|
<input type="text" name="address" id="location-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 id="location-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="closeLocationModal()"
|
||||||
|
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"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Save Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assign Unit Modal -->
|
||||||
|
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a device to this location</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="assign-form" class="p-6 space-y-4">
|
||||||
|
<input type="hidden" id="assign-location-id">
|
||||||
|
<input type="hidden" id="assign-location-type">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
||||||
|
<select id="assign-unit-id" name="unit_id"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||||
|
<option value="">Select a unit</option>
|
||||||
|
</select>
|
||||||
|
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available units match this location type.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
|
<textarea id="assign-notes" name="notes" rows="2"
|
||||||
|
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 id="assign-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="closeAssignModal()"
|
||||||
|
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"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Assign Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const projectId = "{{ project_id }}";
|
||||||
|
let editingLocationId = null;
|
||||||
|
let projectTypeId = null;
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
function switchTab(tabName) {
|
||||||
|
// Hide all tab panels
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset all tab buttons
|
||||||
|
document.querySelectorAll('.tab-button').forEach(button => {
|
||||||
|
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||||
|
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show selected tab panel
|
||||||
|
const panel = document.getElementById(`${tabName}-tab`);
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight selected tab button
|
||||||
|
const button = document.querySelector(`[data-tab="${tabName}"]`);
|
||||||
|
if (button) {
|
||||||
|
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project details
|
||||||
|
async function loadProjectDetails() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load project details');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
projectTypeId = data.project_type_id || null;
|
||||||
|
|
||||||
|
// Update breadcrumb
|
||||||
|
document.getElementById('project-name-breadcrumb').textContent = data.name || 'Project';
|
||||||
|
|
||||||
|
// Update settings form
|
||||||
|
document.getElementById('settings-name').value = data.name || '';
|
||||||
|
document.getElementById('settings-description').value = data.description || '';
|
||||||
|
document.getElementById('settings-client-name').value = data.client_name || '';
|
||||||
|
document.getElementById('settings-status').value = data.status || 'active';
|
||||||
|
document.getElementById('settings-site-address').value = data.site_address || '';
|
||||||
|
document.getElementById('settings-site-coordinates').value = data.site_coordinates || '';
|
||||||
|
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
||||||
|
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
||||||
|
|
||||||
|
// Update tab labels based on project type
|
||||||
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
|
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
||||||
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('settings-error').classList.add('hidden');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load project details:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project settings form submission
|
||||||
|
document.getElementById('project-settings-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('settings-name').value.trim(),
|
||||||
|
description: document.getElementById('settings-description').value.trim() || null,
|
||||||
|
client_name: document.getElementById('settings-client-name').value.trim() || null,
|
||||||
|
status: document.getElementById('settings-status').value,
|
||||||
|
site_address: document.getElementById('settings-site-address').value.trim() || null,
|
||||||
|
site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null,
|
||||||
|
start_date: document.getElementById('settings-start-date').value || null,
|
||||||
|
end_date: document.getElementById('settings-end-date').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update project');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload page to show updated data
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('settings-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to update project.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function refreshProjectDashboard() {
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/dashboard`, {
|
||||||
|
target: '#project-dashboard',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/header`, {
|
||||||
|
target: '#project-header',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location modal functions
|
||||||
|
function openLocationModal(defaultType) {
|
||||||
|
editingLocationId = null;
|
||||||
|
document.getElementById('location-modal-title').textContent = 'Add Location';
|
||||||
|
document.getElementById('location-id').value = '';
|
||||||
|
document.getElementById('location-name').value = '';
|
||||||
|
document.getElementById('location-description').value = '';
|
||||||
|
document.getElementById('location-address').value = '';
|
||||||
|
document.getElementById('location-coordinates').value = '';
|
||||||
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
|
locationTypeSelect.value = 'sound';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
locationTypeSelect.disabled = false;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
|
locationTypeSelect.value = defaultType || 'sound';
|
||||||
|
}
|
||||||
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditLocationModal(button) {
|
||||||
|
const data = JSON.parse(button.dataset.location);
|
||||||
|
editingLocationId = data.id;
|
||||||
|
document.getElementById('location-modal-title').textContent = 'Edit Location';
|
||||||
|
document.getElementById('location-id').value = data.id;
|
||||||
|
document.getElementById('location-name').value = data.name || '';
|
||||||
|
document.getElementById('location-description').value = data.description || '';
|
||||||
|
document.getElementById('location-address').value = data.address || '';
|
||||||
|
document.getElementById('location-coordinates').value = data.coordinates || '';
|
||||||
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
|
locationTypeSelect.value = 'sound';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
locationTypeSelect.disabled = false;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
|
locationTypeSelect.value = data.location_type || 'sound';
|
||||||
|
}
|
||||||
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLocationModal() {
|
||||||
|
document.getElementById('location-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('location-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const name = document.getElementById('location-name').value.trim();
|
||||||
|
const description = document.getElementById('location-description').value.trim();
|
||||||
|
const address = document.getElementById('location-address').value.trim();
|
||||||
|
const coordinates = document.getElementById('location-coordinates').value.trim();
|
||||||
|
let locationType = document.getElementById('location-type').value;
|
||||||
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
|
locationType = 'sound';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingLocationId) {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
description: description || null,
|
||||||
|
address: address || null,
|
||||||
|
coordinates: coordinates || null,
|
||||||
|
location_type: locationType
|
||||||
|
};
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to update location');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
formData.append('description', description);
|
||||||
|
formData.append('address', address);
|
||||||
|
formData.append('coordinates', coordinates);
|
||||||
|
formData.append('location_type', locationType);
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to create location');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLocationModal();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
// Refresh locations tab if visible
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||||
|
target: '#project-locations',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('location-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to save location.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteLocation(locationId) {
|
||||||
|
if (!confirm('Delete this location?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to delete location');
|
||||||
|
}
|
||||||
|
refreshProjectDashboard();
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||||
|
target: '#project-locations',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to delete location.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign modal functions
|
||||||
|
function openAssignModal(locationId, locationType) {
|
||||||
|
const safeType = locationType || 'sound';
|
||||||
|
document.getElementById('assign-location-id').value = locationId;
|
||||||
|
document.getElementById('assign-location-type').value = safeType;
|
||||||
|
document.getElementById('assign-unit-id').innerHTML = '<option value="">Loading units...</option>';
|
||||||
|
document.getElementById('assign-empty').classList.add('hidden');
|
||||||
|
document.getElementById('assign-error').classList.add('hidden');
|
||||||
|
document.getElementById('assign-modal').classList.remove('hidden');
|
||||||
|
loadAvailableUnits(safeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssignModal() {
|
||||||
|
document.getElementById('assign-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableUnits(locationType) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=${locationType}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load available units');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const select = document.getElementById('assign-unit-id');
|
||||||
|
select.innerHTML = '<option value="">Select a unit</option>';
|
||||||
|
if (!data.length) {
|
||||||
|
document.getElementById('assign-empty').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.forEach(unit => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = unit.id;
|
||||||
|
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('assign-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to load units.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const locationId = document.getElementById('assign-location-id').value;
|
||||||
|
const unitId = document.getElementById('assign-unit-id').value;
|
||||||
|
const notes = document.getElementById('assign-notes').value.trim();
|
||||||
|
|
||||||
|
if (!unitId) {
|
||||||
|
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
||||||
|
document.getElementById('assign-error').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('unit_id', unitId);
|
||||||
|
formData.append('notes', notes);
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to assign unit');
|
||||||
|
}
|
||||||
|
closeAssignModal();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||||
|
target: '#project-locations',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('assign-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to assign unit.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function unassignUnit(assignmentId) {
|
||||||
|
if (!confirm('Unassign this unit?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to unassign unit');
|
||||||
|
}
|
||||||
|
refreshProjectDashboard();
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||||
|
target: '#project-locations',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to unassign unit.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter functions
|
||||||
|
function filterSessions() {
|
||||||
|
const filter = document.getElementById('sessions-filter').value;
|
||||||
|
const url = filter === 'all'
|
||||||
|
? `/api/projects/${projectId}/sessions`
|
||||||
|
: `/api/projects/${projectId}/sessions?status=${filter}`;
|
||||||
|
|
||||||
|
htmx.ajax('GET', url, {
|
||||||
|
target: '#project-sessions',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFiles() {
|
||||||
|
const filter = document.getElementById('files-filter').value;
|
||||||
|
const url = filter === 'all'
|
||||||
|
? `/api/projects/${projectId}/files`
|
||||||
|
: `/api/projects/${projectId}/files?type=${filter}`;
|
||||||
|
|
||||||
|
htmx.ajax('GET', url, {
|
||||||
|
target: '#project-files',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function openScheduleModal() {
|
||||||
|
alert('Schedule modal coming soon');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportProjectData() {
|
||||||
|
window.location.href = `/api/projects/${projectId}/export`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveProject() {
|
||||||
|
if (!confirm('Archive this project? You can restore it later from the archived projects list.')) return;
|
||||||
|
|
||||||
|
document.getElementById('settings-status').value = 'archived';
|
||||||
|
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeLocationModal();
|
||||||
|
closeAssignModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside to close modals
|
||||||
|
document.getElementById('location-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeLocationModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeAssignModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load project details on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadProjectDetails();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
132
templates/slm_legacy_dashboard.html
Normal file
132
templates/slm_legacy_dashboard.html
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ unit_id }} - Sound Level Meter Control Center{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb Navigation -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
|
<a href="/sound-level-meters" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
Sound Level Meters
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" 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.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ unit_id }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Sound Level Meter Control Center
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button onclick="openConfigModal()"
|
||||||
|
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Configure
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live View Panel -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg">
|
||||||
|
<div id="live-view-content"
|
||||||
|
hx-get="/api/slm-dashboard/live-view/{{ unit_id }}"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading control center...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Modal -->
|
||||||
|
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Configure {{ unit_id }}</h2>
|
||||||
|
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="config-modal-content"
|
||||||
|
hx-get="/api/slm-dashboard/config/{{ unit_id }}"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div class="p-6 space-y-4 animate-pulse">
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Modal functions
|
||||||
|
function openConfigModal() {
|
||||||
|
const modal = document.getElementById('config-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
// Reload config when opening
|
||||||
|
htmx.ajax('GET', '/api/slm-dashboard/config/{{ unit_id }}', {
|
||||||
|
target: '#config-modal-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfigModal() {
|
||||||
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcut
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for config updates to refresh live view
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
if (event.detail.pathInfo.requestPath.includes('/config/') && event.detail.successful) {
|
||||||
|
// Refresh live view after config update
|
||||||
|
htmx.ajax('GET', '/api/slm-dashboard/live-view/{{ unit_id }}', {
|
||||||
|
target: '#live-view-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -21,46 +21,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
<!-- Main Content Grid -->
|
||||||
<<<<<<< Updated upstream
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<!-- SLM List -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
|
|
||||||
|
|
||||||
<!-- Search/Filter -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<input type="text"
|
|
||||||
placeholder="Search units..."
|
|
||||||
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"
|
|
||||||
hx-get="/api/slm-dashboard/units"
|
|
||||||
hx-trigger="keyup changed delay:300ms"
|
|
||||||
hx-target="#slm-list"
|
|
||||||
hx-include="this"
|
|
||||||
name="search">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SLM List -->
|
|
||||||
<div id="slm-list"
|
|
||||||
class="space-y-2 max-h-[600px] overflow-y-auto"
|
|
||||||
hx-get="/api/slm-dashboard/units"
|
|
||||||
hx-trigger="load, every 10s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<div class="animate-pulse space-y-2">
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
=======
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Projects Card -->
|
<!-- Projects Card -->
|
||||||
<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">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Projects</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Projects</h2>
|
||||||
<a href="/projects" class="text-sm text-seismo-orange hover:text-seismo-navy">View all</a>
|
<a href="/projects" class="text-sm text-seismo-orange hover:text-seismo-navy">View all</a>
|
||||||
>>>>>>> Stashed changes
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="slm-projects-list"
|
<div id="slm-projects-list"
|
||||||
@@ -83,14 +49,6 @@
|
|||||||
<a href="/roster" class="text-sm text-seismo-orange hover:text-seismo-navy">Manage roster</a>
|
<a href="/roster" class="text-sm text-seismo-orange hover:text-seismo-navy">Manage roster</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<<<<<<< Updated upstream
|
|
||||||
<div id="config-modal-content">
|
|
||||||
<!-- Content loaded via HTMX -->
|
|
||||||
<div class="animate-pulse space-y-4">
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
|
||||||
=======
|
|
||||||
<div id="slm-devices-list"
|
<div id="slm-devices-list"
|
||||||
class="space-y-3 max-h-[600px] overflow-y-auto"
|
class="space-y-3 max-h-[600px] overflow-y-auto"
|
||||||
hx-get="/api/slm-dashboard/units?include_measurement=true"
|
hx-get="/api/slm-dashboard/units?include_measurement=true"
|
||||||
@@ -100,166 +58,58 @@
|
|||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
>>>>>>> Stashed changes
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<<<<<<< Updated upstream
|
|
||||||
|
<!-- Configuration Modal -->
|
||||||
|
<div id="slm-config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
|
||||||
|
<button onclick="closeDeviceConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="slm-config-modal-content">
|
||||||
|
<div class="animate-pulse space-y-4">
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Function to select a unit and load live view
|
function openDeviceConfigModal(unitId) {
|
||||||
function selectUnit(unitId) {
|
const modal = document.getElementById('slm-config-modal');
|
||||||
// Remove active state from all items
|
|
||||||
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
|
||||||
item.classList.remove('bg-seismo-orange', 'text-white');
|
|
||||||
item.classList.add('bg-gray-100', 'dark:bg-gray-700');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add active state to clicked item
|
|
||||||
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
|
||||||
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
|
||||||
|
|
||||||
// Load live view for this unit
|
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
|
||||||
target: '#live-view-panel',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration modal functions
|
|
||||||
function openConfigModal(unitId) {
|
|
||||||
const modal = document.getElementById('config-modal');
|
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
// Load configuration form via HTMX
|
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
||||||
target: '#config-modal-content',
|
target: '#slm-config-modal-content',
|
||||||
swap: 'innerHTML'
|
swap: 'innerHTML'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeConfigModal() {
|
function closeDeviceConfigModal() {
|
||||||
document.getElementById('config-modal').classList.add('hidden');
|
document.getElementById('slm-config-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal on escape key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeConfigModal();
|
closeDeviceConfigModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal when clicking outside
|
document.getElementById('slm-config-modal')?.addEventListener('click', function(e) {
|
||||||
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
if (e.target === this) {
|
||||||
closeConfigModal();
|
closeDeviceConfigModal();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize WebSocket for selected unit
|
|
||||||
let currentWebSocket = null;
|
|
||||||
|
|
||||||
function initLiveDataStream(unitId) {
|
|
||||||
// Close existing connection if any
|
|
||||||
if (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket URL for SLMM backend via proxy
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
|
||||||
|
|
||||||
currentWebSocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
currentWebSocket.onopen = function() {
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
// Toggle button visibility
|
|
||||||
const startBtn = document.getElementById('start-stream-btn');
|
|
||||||
const stopBtn = document.getElementById('stop-stream-btn');
|
|
||||||
if (startBtn) startBtn.style.display = 'none';
|
|
||||||
if (stopBtn) stopBtn.style.display = 'flex';
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onmessage = function(event) {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
updateLiveChart(data);
|
|
||||||
updateLiveMetrics(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onerror = function(error) {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onclose = function() {
|
|
||||||
console.log('WebSocket closed');
|
|
||||||
// Toggle button visibility
|
|
||||||
const startBtn = document.getElementById('start-stream-btn');
|
|
||||||
const stopBtn = document.getElementById('stop-stream-btn');
|
|
||||||
if (startBtn) startBtn.style.display = 'flex';
|
|
||||||
if (stopBtn) stopBtn.style.display = 'none';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopLiveDataStream() {
|
|
||||||
if (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
currentWebSocket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update live chart with new data point
|
|
||||||
let chartData = {
|
|
||||||
timestamps: [],
|
|
||||||
lp: [],
|
|
||||||
leq: []
|
|
||||||
};
|
|
||||||
|
|
||||||
function updateLiveChart(data) {
|
|
||||||
const now = new Date();
|
|
||||||
chartData.timestamps.push(now.toLocaleTimeString());
|
|
||||||
chartData.lp.push(parseFloat(data.lp || 0));
|
|
||||||
chartData.leq.push(parseFloat(data.leq || 0));
|
|
||||||
|
|
||||||
// Keep only last 60 data points (1 minute at 1 sample/sec)
|
|
||||||
if (chartData.timestamps.length > 60) {
|
|
||||||
chartData.timestamps.shift();
|
|
||||||
chartData.lp.shift();
|
|
||||||
chartData.leq.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update chart (using Chart.js if available)
|
|
||||||
if (window.liveChart) {
|
|
||||||
window.liveChart.data.labels = chartData.timestamps;
|
|
||||||
window.liveChart.data.datasets[0].data = chartData.lp;
|
|
||||||
window.liveChart.data.datasets[1].data = chartData.leq;
|
|
||||||
window.liveChart.update('none'); // Update without animation for smooth real-time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLiveMetrics(data) {
|
|
||||||
// Update metric displays
|
|
||||||
if (document.getElementById('live-lp')) {
|
|
||||||
document.getElementById('live-lp').textContent = data.lp || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-leq')) {
|
|
||||||
document.getElementById('live-leq').textContent = data.leq || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lmax')) {
|
|
||||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lmin')) {
|
|
||||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup on page unload
|
|
||||||
window.addEventListener('beforeunload', function() {
|
|
||||||
if (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
=======
|
|
||||||
>>>>>>> Stashed changes
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user