Update main to 0.5.1. See changelog. #18
@@ -101,6 +101,10 @@ app.include_router(projects.router)
|
||||
app.include_router(project_locations.router)
|
||||
app.include_router(scheduler.router)
|
||||
|
||||
# Report templates router
|
||||
from backend.routers import report_templates
|
||||
app.include_router(report_templates.router)
|
||||
|
||||
# Start scheduler service on application startup
|
||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
||||
88
backend/migrate_add_report_templates.py
Normal file
88
backend/migrate_add_report_templates.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Migration script to add report_templates table.
|
||||
|
||||
This creates a new table for storing report generation configurations:
|
||||
- Template name and project association
|
||||
- Time filtering settings (start/end time)
|
||||
- Date range filtering (optional)
|
||||
- Report title defaults
|
||||
|
||||
Run this script once to migrate an existing database.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# Database path
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
def migrate_database():
|
||||
"""Create report_templates table"""
|
||||
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
print("The database will be created automatically when you run the application.")
|
||||
return
|
||||
|
||||
print(f"Migrating database: {DB_PATH}")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if report_templates table already exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='report_templates'")
|
||||
table_exists = cursor.fetchone()
|
||||
|
||||
if table_exists:
|
||||
print("Migration already applied - report_templates table exists")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("Creating report_templates table...")
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
CREATE TABLE report_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
report_title TEXT DEFAULT 'Background Noise Study',
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
print(" ✓ Created report_templates table")
|
||||
|
||||
# Insert default templates
|
||||
import uuid
|
||||
|
||||
default_templates = [
|
||||
(str(uuid.uuid4()), "Nighttime (7PM-7AM)", None, "Background Noise Study", "19:00", "07:00", None, None),
|
||||
(str(uuid.uuid4()), "Daytime (7AM-7PM)", None, "Background Noise Study", "07:00", "19:00", None, None),
|
||||
(str(uuid.uuid4()), "Full Day (All Data)", None, "Background Noise Study", None, None, None, None),
|
||||
]
|
||||
|
||||
cursor.executemany("""
|
||||
INSERT INTO report_templates (id, name, project_id, report_title, start_time, end_time, start_date, end_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", default_templates)
|
||||
print(" ✓ Inserted default templates (Nighttime, Daytime, Full Day)")
|
||||
|
||||
conn.commit()
|
||||
print("\nMigration completed successfully!")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"\nError during migration: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
@@ -278,3 +278,25 @@ class DataFile(Base):
|
||||
file_metadata = Column(Text, nullable=True) # JSON
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ReportTemplate(Base):
|
||||
"""
|
||||
Report templates: saved configurations for generating Excel reports.
|
||||
Allows users to save time filter presets, titles, etc. for reuse.
|
||||
"""
|
||||
__tablename__ = "report_templates"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
name = Column(String, nullable=False) # "Nighttime Report", "Full Day Report"
|
||||
project_id = Column(String, nullable=True) # Optional: project-specific template
|
||||
|
||||
# Template settings
|
||||
report_title = Column(String, default="Background Noise Study")
|
||||
start_time = Column(String, nullable=True) # "19:00" format
|
||||
end_time = Column(String, nullable=True) # "07:00" format
|
||||
start_date = Column(String, nullable=True) # "2025-01-15" format (optional)
|
||||
end_date = Column(String, nullable=True) # "2025-01-20" format (optional)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -1343,6 +1343,12 @@ async def generate_excel_report(
|
||||
file_id: str,
|
||||
report_title: str = Query("Background Noise Study", description="Title for the report"),
|
||||
location_name: str = Query("", description="Location name (e.g., 'NRL 1 - West Side')"),
|
||||
project_name: str = Query("", description="Project name override"),
|
||||
client_name: str = Query("", description="Client name for report header"),
|
||||
start_time: str = Query("", description="Filter start time (HH:MM format, e.g., '19:00')"),
|
||||
end_time: str = Query("", description="Filter end time (HH:MM format, e.g., '07:00')"),
|
||||
start_date: str = Query("", description="Filter start date (YYYY-MM-DD format)"),
|
||||
end_date: str = Query("", description="Filter end date (YYYY-MM-DD format)"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -1354,6 +1360,10 @@ async def generate_excel_report(
|
||||
- Line chart visualization
|
||||
- Time period summary statistics
|
||||
|
||||
Time filtering:
|
||||
- start_time/end_time: Filter to time window (handles overnight like 19:00-07:00)
|
||||
- start_date/end_date: Filter to date range
|
||||
|
||||
Column mapping from RND to Report:
|
||||
- Lmax(Main) -> LAmax (dBA)
|
||||
- LN1(Main) -> LA01 (dBA) [L1 percentile]
|
||||
@@ -1432,6 +1442,99 @@ async def generate_excel_report(
|
||||
logger.error(f"Error reading RND file: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
|
||||
|
||||
# Apply time and date filtering
|
||||
def filter_rows_by_time(rows, filter_start_time, filter_end_time, filter_start_date, filter_end_date):
|
||||
"""Filter rows by time window and date range."""
|
||||
if not filter_start_time and not filter_end_time and not filter_start_date and not filter_end_date:
|
||||
return rows
|
||||
|
||||
filtered = []
|
||||
|
||||
# Parse time filters
|
||||
start_hour = start_minute = end_hour = end_minute = None
|
||||
if filter_start_time:
|
||||
try:
|
||||
parts = filter_start_time.split(':')
|
||||
start_hour = int(parts[0])
|
||||
start_minute = int(parts[1]) if len(parts) > 1 else 0
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
if filter_end_time:
|
||||
try:
|
||||
parts = filter_end_time.split(':')
|
||||
end_hour = int(parts[0])
|
||||
end_minute = int(parts[1]) if len(parts) > 1 else 0
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Parse date filters
|
||||
start_dt = end_dt = None
|
||||
if filter_start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(filter_start_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
if filter_end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(filter_end_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for row in rows:
|
||||
start_time_str = row.get('Start Time', '')
|
||||
if not start_time_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||
row_date = dt.date()
|
||||
row_hour = dt.hour
|
||||
row_minute = dt.minute
|
||||
|
||||
# Date filtering
|
||||
if start_dt and row_date < start_dt:
|
||||
continue
|
||||
if end_dt and row_date > end_dt:
|
||||
continue
|
||||
|
||||
# Time filtering (handle overnight ranges like 19:00-07:00)
|
||||
if start_hour is not None and end_hour is not None:
|
||||
row_time_minutes = row_hour * 60 + row_minute
|
||||
start_time_minutes = start_hour * 60 + start_minute
|
||||
end_time_minutes = end_hour * 60 + end_minute
|
||||
|
||||
if start_time_minutes > end_time_minutes:
|
||||
# Overnight range (e.g., 19:00-07:00)
|
||||
if not (row_time_minutes >= start_time_minutes or row_time_minutes < end_time_minutes):
|
||||
continue
|
||||
else:
|
||||
# Same day range (e.g., 07:00-19:00)
|
||||
if not (start_time_minutes <= row_time_minutes < end_time_minutes):
|
||||
continue
|
||||
|
||||
filtered.append(row)
|
||||
except ValueError:
|
||||
# If we can't parse the time, include the row anyway
|
||||
filtered.append(row)
|
||||
|
||||
return filtered
|
||||
|
||||
# Apply filters
|
||||
original_count = len(rnd_rows)
|
||||
rnd_rows = filter_rows_by_time(rnd_rows, start_time, end_time, start_date, end_date)
|
||||
|
||||
if not rnd_rows:
|
||||
time_filter_desc = ""
|
||||
if start_time and end_time:
|
||||
time_filter_desc = f" between {start_time} and {end_time}"
|
||||
if start_date or end_date:
|
||||
time_filter_desc += f" from {start_date or 'start'} to {end_date or 'end'}"
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"No data found after applying filters{time_filter_desc}. Original file had {original_count} rows."
|
||||
)
|
||||
|
||||
# Create Excel workbook
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
@@ -1449,13 +1552,19 @@ async def generate_excel_report(
|
||||
header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid")
|
||||
|
||||
# Row 1: Report title
|
||||
final_project_name = project_name if project_name else (project.name if project else "")
|
||||
final_title = report_title
|
||||
if project:
|
||||
final_title = f"{report_title} - {project.name}"
|
||||
if final_project_name:
|
||||
final_title = f"{report_title} - {final_project_name}"
|
||||
ws['A1'] = final_title
|
||||
ws['A1'].font = title_font
|
||||
ws.merge_cells('A1:G1')
|
||||
|
||||
# Row 2: Client name (if provided)
|
||||
if client_name:
|
||||
ws['A2'] = f"Client: {client_name}"
|
||||
ws['A2'].font = Font(italic=True, size=10)
|
||||
|
||||
# Row 3: Location name
|
||||
final_location = location_name
|
||||
if not final_location and location:
|
||||
@@ -1464,6 +1573,15 @@ async def generate_excel_report(
|
||||
ws['A3'] = final_location
|
||||
ws['A3'].font = Font(bold=True, size=11)
|
||||
|
||||
# Row 4: Time filter info (if applied)
|
||||
if start_time and end_time:
|
||||
filter_info = f"Time Filter: {start_time} - {end_time}"
|
||||
if start_date or end_date:
|
||||
filter_info += f" | Date Range: {start_date or 'start'} to {end_date or 'end'}"
|
||||
filter_info += f" | {len(rnd_rows)} of {original_count} rows"
|
||||
ws['A4'] = filter_info
|
||||
ws['A4'].font = Font(italic=True, size=9, color="666666")
|
||||
|
||||
# Row 7: Headers
|
||||
headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
|
||||
for col, header in enumerate(headers, 1):
|
||||
@@ -1650,6 +1768,364 @@ async def generate_excel_report(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/files/{file_id}/preview-report")
|
||||
async def preview_report_data(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
file_id: str,
|
||||
report_title: str = Query("Background Noise Study", description="Title for the report"),
|
||||
location_name: str = Query("", description="Location name"),
|
||||
project_name: str = Query("", description="Project name override"),
|
||||
client_name: str = Query("", description="Client name"),
|
||||
start_time: str = Query("", description="Filter start time (HH:MM format)"),
|
||||
end_time: str = Query("", description="Filter end time (HH:MM format)"),
|
||||
start_date: str = Query("", description="Filter start date (YYYY-MM-DD format)"),
|
||||
end_date: str = Query("", description="Filter end date (YYYY-MM-DD format)"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Preview report data for editing in jspreadsheet.
|
||||
Returns an HTML page with the spreadsheet editor.
|
||||
"""
|
||||
from backend.models import DataFile, ReportTemplate
|
||||
from pathlib import Path
|
||||
import csv
|
||||
|
||||
# Get the file record
|
||||
file_record = db.query(DataFile).filter_by(id=file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Verify file belongs to this project
|
||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||
if not session or session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||
|
||||
# Get related data for report context
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||
|
||||
# Build full file path
|
||||
file_path = Path("data") / file_record.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Validate this is a Leq file
|
||||
if '_Leq_' not in file_record.file_path:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Reports can only be generated from Leq files (15-minute averaged data)."
|
||||
)
|
||||
|
||||
# Read and parse the Leq RND file
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
rnd_rows = []
|
||||
for row in reader:
|
||||
cleaned_row = {}
|
||||
for key, value in row.items():
|
||||
if key:
|
||||
cleaned_key = key.strip()
|
||||
cleaned_value = value.strip() if value else ''
|
||||
if cleaned_value and cleaned_value not in ['-.-', '-', '']:
|
||||
try:
|
||||
cleaned_value = float(cleaned_value)
|
||||
except ValueError:
|
||||
pass
|
||||
elif cleaned_value in ['-.-', '-']:
|
||||
cleaned_value = None
|
||||
cleaned_row[cleaned_key] = cleaned_value
|
||||
rnd_rows.append(cleaned_row)
|
||||
|
||||
if not rnd_rows:
|
||||
raise HTTPException(status_code=400, detail="No data found in RND file")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading RND file: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
|
||||
|
||||
# Apply time and date filtering (same logic as generate-report)
|
||||
def filter_rows(rows, filter_start_time, filter_end_time, filter_start_date, filter_end_date):
|
||||
if not filter_start_time and not filter_end_time and not filter_start_date and not filter_end_date:
|
||||
return rows
|
||||
|
||||
filtered = []
|
||||
start_hour = start_minute = end_hour = end_minute = None
|
||||
|
||||
if filter_start_time:
|
||||
try:
|
||||
parts = filter_start_time.split(':')
|
||||
start_hour = int(parts[0])
|
||||
start_minute = int(parts[1]) if len(parts) > 1 else 0
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
if filter_end_time:
|
||||
try:
|
||||
parts = filter_end_time.split(':')
|
||||
end_hour = int(parts[0])
|
||||
end_minute = int(parts[1]) if len(parts) > 1 else 0
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
start_dt = end_dt = None
|
||||
if filter_start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(filter_start_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
if filter_end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(filter_end_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for row in rows:
|
||||
start_time_str = row.get('Start Time', '')
|
||||
if not start_time_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||
row_date = dt.date()
|
||||
row_hour = dt.hour
|
||||
row_minute = dt.minute
|
||||
|
||||
if start_dt and row_date < start_dt:
|
||||
continue
|
||||
if end_dt and row_date > end_dt:
|
||||
continue
|
||||
|
||||
if start_hour is not None and end_hour is not None:
|
||||
row_time_minutes = row_hour * 60 + row_minute
|
||||
start_time_minutes = start_hour * 60 + start_minute
|
||||
end_time_minutes = end_hour * 60 + end_minute
|
||||
|
||||
if start_time_minutes > end_time_minutes:
|
||||
if not (row_time_minutes >= start_time_minutes or row_time_minutes < end_time_minutes):
|
||||
continue
|
||||
else:
|
||||
if not (start_time_minutes <= row_time_minutes < end_time_minutes):
|
||||
continue
|
||||
|
||||
filtered.append(row)
|
||||
except ValueError:
|
||||
filtered.append(row)
|
||||
|
||||
return filtered
|
||||
|
||||
original_count = len(rnd_rows)
|
||||
rnd_rows = filter_rows(rnd_rows, start_time, end_time, start_date, end_date)
|
||||
|
||||
# Convert to spreadsheet data format (array of arrays)
|
||||
spreadsheet_data = []
|
||||
for idx, row in enumerate(rnd_rows, 1):
|
||||
start_time_str = row.get('Start Time', '')
|
||||
date_str = ''
|
||||
time_str = ''
|
||||
if start_time_str:
|
||||
try:
|
||||
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||
date_str = dt.strftime('%Y-%m-%d')
|
||||
time_str = dt.strftime('%H:%M:%S')
|
||||
except ValueError:
|
||||
date_str = start_time_str
|
||||
time_str = ''
|
||||
|
||||
lmax = row.get('Lmax(Main)', '')
|
||||
ln1 = row.get('LN1(Main)', '')
|
||||
ln2 = row.get('LN2(Main)', '')
|
||||
|
||||
spreadsheet_data.append([
|
||||
idx, # Test #
|
||||
date_str,
|
||||
time_str,
|
||||
lmax if lmax else '',
|
||||
ln1 if ln1 else '',
|
||||
ln2 if ln2 else '',
|
||||
'' # Comments
|
||||
])
|
||||
|
||||
# Prepare context data
|
||||
final_project_name = project_name if project_name else (project.name if project else "")
|
||||
final_location = location_name if location_name else (location.name if location else "")
|
||||
|
||||
# Get templates for the dropdown
|
||||
templates = db.query(ReportTemplate).all()
|
||||
|
||||
return templates.TemplateResponse("report_preview.html", {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"file_id": file_id,
|
||||
"project": project,
|
||||
"location": location,
|
||||
"file": file_record,
|
||||
"spreadsheet_data": spreadsheet_data,
|
||||
"report_title": report_title,
|
||||
"project_name": final_project_name,
|
||||
"client_name": client_name,
|
||||
"location_name": final_location,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"original_count": original_count,
|
||||
"filtered_count": len(rnd_rows),
|
||||
"templates": templates,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{project_id}/files/{file_id}/generate-from-preview")
|
||||
async def generate_report_from_preview(
|
||||
project_id: str,
|
||||
file_id: str,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate an Excel report from edited spreadsheet data.
|
||||
Accepts the edited data from jspreadsheet and creates the final Excel file.
|
||||
"""
|
||||
from backend.models import DataFile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.chart import LineChart, Reference
|
||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=500, detail="openpyxl is not installed")
|
||||
|
||||
# Get the file record for filename generation
|
||||
file_record = db.query(DataFile).filter_by(id=file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||
if not session or session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||
|
||||
# Extract data from request
|
||||
spreadsheet_data = data.get('data', [])
|
||||
report_title = data.get('report_title', 'Background Noise Study')
|
||||
project_name = data.get('project_name', project.name if project else '')
|
||||
client_name = data.get('client_name', '')
|
||||
location_name = data.get('location_name', location.name if location else '')
|
||||
time_filter = data.get('time_filter', '')
|
||||
|
||||
if not spreadsheet_data:
|
||||
raise HTTPException(status_code=400, detail="No data provided")
|
||||
|
||||
# Create Excel workbook
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sound Level Data"
|
||||
|
||||
# Styles
|
||||
title_font = Font(bold=True, size=14)
|
||||
header_font = Font(bold=True, size=10)
|
||||
thin_border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid")
|
||||
|
||||
# Row 1: Title
|
||||
final_title = f"{report_title} - {project_name}" if project_name else report_title
|
||||
ws['A1'] = final_title
|
||||
ws['A1'].font = title_font
|
||||
ws.merge_cells('A1:G1')
|
||||
|
||||
# Row 2: Client
|
||||
if client_name:
|
||||
ws['A2'] = f"Client: {client_name}"
|
||||
ws['A2'].font = Font(italic=True, size=10)
|
||||
|
||||
# Row 3: Location
|
||||
if location_name:
|
||||
ws['A3'] = location_name
|
||||
ws['A3'].font = Font(bold=True, size=11)
|
||||
|
||||
# Row 4: Time filter info
|
||||
if time_filter:
|
||||
ws['A4'] = time_filter
|
||||
ws['A4'].font = Font(italic=True, size=9, color="666666")
|
||||
|
||||
# Row 7: Headers
|
||||
headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=7, column=col, value=header)
|
||||
cell.font = header_font
|
||||
cell.border = thin_border
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
# Column widths
|
||||
column_widths = [16, 12, 10, 12, 12, 12, 40]
|
||||
for i, width in enumerate(column_widths, 1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = width
|
||||
|
||||
# Data rows
|
||||
data_start_row = 8
|
||||
for idx, row_data in enumerate(spreadsheet_data):
|
||||
data_row = data_start_row + idx
|
||||
for col, value in enumerate(row_data, 1):
|
||||
cell = ws.cell(row=data_row, column=col, value=value if value != '' else None)
|
||||
cell.border = thin_border
|
||||
|
||||
data_end_row = data_start_row + len(spreadsheet_data) - 1
|
||||
|
||||
# Add chart if we have data
|
||||
if len(spreadsheet_data) > 0:
|
||||
chart = LineChart()
|
||||
chart.title = f"{location_name or 'Sound Level Data'} - Background Noise Study"
|
||||
chart.style = 10
|
||||
chart.y_axis.title = "Sound Level (dBA)"
|
||||
chart.x_axis.title = "Test Increment"
|
||||
chart.height = 12
|
||||
chart.width = 20
|
||||
|
||||
data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row)
|
||||
categories = Reference(ws, min_col=1, min_row=data_start_row, max_row=data_end_row)
|
||||
|
||||
chart.add_data(data_ref, titles_from_data=True)
|
||||
chart.set_categories(categories)
|
||||
|
||||
if len(chart.series) >= 3:
|
||||
chart.series[0].graphicalProperties.line.solidFill = "FF0000"
|
||||
chart.series[1].graphicalProperties.line.solidFill = "00B050"
|
||||
chart.series[2].graphicalProperties.line.solidFill = "0070C0"
|
||||
|
||||
ws.add_chart(chart, "I3")
|
||||
|
||||
# Save to buffer
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
# Generate filename
|
||||
filename = file_record.file_path.split('/')[-1].replace('.rnd', '')
|
||||
if location:
|
||||
filename = f"{location.name}_{filename}"
|
||||
filename = f"{filename}_report.xlsx"
|
||||
filename = "".join(c for c in filename if c.isalnum() or c in ('_', '-', '.')).rstrip()
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/generate-combined-report")
|
||||
async def generate_combined_excel_report(
|
||||
project_id: str,
|
||||
|
||||
187
backend/routers/report_templates.py
Normal file
187
backend/routers/report_templates.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Report Templates Router
|
||||
|
||||
CRUD operations for report template management.
|
||||
Templates store time filter presets and report configuration for reuse.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import ReportTemplate
|
||||
|
||||
router = APIRouter(prefix="/api/report-templates", tags=["report-templates"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(
|
||||
project_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List all report templates.
|
||||
Optionally filter by project_id (includes global templates with project_id=None).
|
||||
"""
|
||||
query = db.query(ReportTemplate)
|
||||
|
||||
if project_id:
|
||||
# Include global templates (project_id=None) AND project-specific templates
|
||||
query = query.filter(
|
||||
(ReportTemplate.project_id == None) | (ReportTemplate.project_id == project_id)
|
||||
)
|
||||
|
||||
templates = query.order_by(ReportTemplate.name).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"project_id": t.project_id,
|
||||
"report_title": t.report_title,
|
||||
"start_time": t.start_time,
|
||||
"end_time": t.end_time,
|
||||
"start_date": t.start_date,
|
||||
"end_date": t.end_date,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
||||
}
|
||||
for t in templates
|
||||
]
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_template(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a new report template.
|
||||
|
||||
Request body:
|
||||
- name: Template name (required)
|
||||
- project_id: Optional project ID for project-specific template
|
||||
- report_title: Default report title
|
||||
- start_time: Start time filter (HH:MM format)
|
||||
- end_time: End time filter (HH:MM format)
|
||||
- start_date: Start date filter (YYYY-MM-DD format)
|
||||
- end_date: End date filter (YYYY-MM-DD format)
|
||||
"""
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Template name is required")
|
||||
|
||||
template = ReportTemplate(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
project_id=data.get("project_id"),
|
||||
report_title=data.get("report_title", "Background Noise Study"),
|
||||
start_time=data.get("start_time"),
|
||||
end_time=data.get("end_time"),
|
||||
start_date=data.get("start_date"),
|
||||
end_date=data.get("end_date"),
|
||||
)
|
||||
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
return {
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"project_id": template.project_id,
|
||||
"report_title": template.report_title,
|
||||
"start_time": template.start_time,
|
||||
"end_time": template.end_time,
|
||||
"start_date": template.start_date,
|
||||
"end_date": template.end_date,
|
||||
"created_at": template.created_at.isoformat() if template.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
async def get_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a specific report template by ID."""
|
||||
template = db.query(ReportTemplate).filter_by(id=template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
return {
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"project_id": template.project_id,
|
||||
"report_title": template.report_title,
|
||||
"start_time": template.start_time,
|
||||
"end_time": template.end_time,
|
||||
"start_date": template.start_date,
|
||||
"end_date": template.end_date,
|
||||
"created_at": template.created_at.isoformat() if template.created_at else None,
|
||||
"updated_at": template.updated_at.isoformat() if template.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
async def update_template(
|
||||
template_id: str,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update an existing report template."""
|
||||
template = db.query(ReportTemplate).filter_by(id=template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
# Update fields if provided
|
||||
if "name" in data:
|
||||
template.name = data["name"]
|
||||
if "project_id" in data:
|
||||
template.project_id = data["project_id"]
|
||||
if "report_title" in data:
|
||||
template.report_title = data["report_title"]
|
||||
if "start_time" in data:
|
||||
template.start_time = data["start_time"]
|
||||
if "end_time" in data:
|
||||
template.end_time = data["end_time"]
|
||||
if "start_date" in data:
|
||||
template.start_date = data["start_date"]
|
||||
if "end_date" in data:
|
||||
template.end_date = data["end_date"]
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
return {
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"project_id": template.project_id,
|
||||
"report_title": template.report_title,
|
||||
"start_time": template.start_time,
|
||||
"end_time": template.end_time,
|
||||
"start_date": template.start_date,
|
||||
"end_date": template.end_date,
|
||||
"updated_at": template.updated_at.isoformat() if template.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def delete_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a report template."""
|
||||
template = db.query(ReportTemplate).filter_by(id=template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({"status": "success", "message": "Template deleted"})
|
||||
@@ -22,6 +22,16 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="showFTPSettings('{{ unit_item.unit.id }}')"
|
||||
id="settings-ftp-{{ unit_item.unit.id }}"
|
||||
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
|
||||
title="Configure FTP credentials">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
|
||||
Settings
|
||||
</button>
|
||||
<button onclick="enableFTP('{{ unit_item.unit.id }}')"
|
||||
id="enable-ftp-{{ unit_item.unit.id }}"
|
||||
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
@@ -605,3 +615,6 @@ setTimeout(function() {
|
||||
{% endfor %}
|
||||
}, 100);
|
||||
</script>
|
||||
|
||||
<!-- Include the unified SLM Settings Modal -->
|
||||
{% include 'partials/slm_settings_modal.html' %}
|
||||
|
||||
@@ -1307,123 +1307,6 @@ window.addEventListener('beforeunload', function() {
|
||||
// Timer will resume on next page load if measurement is still active
|
||||
stopMeasurementTimer();
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Settings Modal
|
||||
// ========================================
|
||||
async function openSettingsModal(unitId) {
|
||||
const modal = document.getElementById('settings-modal');
|
||||
const errorDiv = document.getElementById('settings-error');
|
||||
const successDiv = document.getElementById('settings-success');
|
||||
|
||||
// Clear previous messages
|
||||
errorDiv.classList.add('hidden');
|
||||
successDiv.classList.add('hidden');
|
||||
|
||||
// Store unit ID
|
||||
document.getElementById('settings-unit-id').value = unitId;
|
||||
|
||||
// Load current SLMM config
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/config`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load configuration');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const config = result.data || {};
|
||||
|
||||
// Populate form fields
|
||||
document.getElementById('settings-host').value = config.host || '';
|
||||
document.getElementById('settings-tcp-port').value = config.tcp_port || 2255;
|
||||
document.getElementById('settings-ftp-port').value = config.ftp_port || 21;
|
||||
document.getElementById('settings-ftp-username').value = config.ftp_username || '';
|
||||
document.getElementById('settings-ftp-password').value = config.ftp_password || '';
|
||||
document.getElementById('settings-tcp-enabled').checked = config.tcp_enabled !== false;
|
||||
document.getElementById('settings-ftp-enabled').checked = config.ftp_enabled === true;
|
||||
document.getElementById('settings-web-enabled').checked = config.web_enabled === true;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error('Failed to load SLMM config:', error);
|
||||
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
||||
errorDiv.classList.remove('hidden');
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
document.getElementById('settings-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('settings-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const unitId = document.getElementById('settings-unit-id').value;
|
||||
const errorDiv = document.getElementById('settings-error');
|
||||
const successDiv = document.getElementById('settings-success');
|
||||
|
||||
errorDiv.classList.add('hidden');
|
||||
successDiv.classList.add('hidden');
|
||||
|
||||
// Gather form data
|
||||
const configData = {
|
||||
host: document.getElementById('settings-host').value.trim(),
|
||||
tcp_port: parseInt(document.getElementById('settings-tcp-port').value),
|
||||
ftp_port: parseInt(document.getElementById('settings-ftp-port').value),
|
||||
ftp_username: document.getElementById('settings-ftp-username').value.trim() || null,
|
||||
ftp_password: document.getElementById('settings-ftp-password').value || null,
|
||||
tcp_enabled: document.getElementById('settings-tcp-enabled').checked,
|
||||
ftp_enabled: document.getElementById('settings-ftp-enabled').checked,
|
||||
web_enabled: document.getElementById('settings-web-enabled').checked
|
||||
};
|
||||
|
||||
// Validation
|
||||
if (!configData.host) {
|
||||
errorDiv.textContent = 'Host/IP address is required';
|
||||
errorDiv.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (configData.tcp_port < 1 || configData.tcp_port > 65535) {
|
||||
errorDiv.textContent = 'TCP port must be between 1 and 65535';
|
||||
errorDiv.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (configData.ftp_port < 1 || configData.ftp_port > 65535) {
|
||||
errorDiv.textContent = 'FTP port must be between 1 and 65535';
|
||||
errorDiv.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/config`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(configData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to update configuration');
|
||||
}
|
||||
|
||||
successDiv.textContent = 'Configuration saved successfully!';
|
||||
successDiv.classList.remove('hidden');
|
||||
|
||||
// Close modal after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
closeSettingsModal();
|
||||
// Optionally reload the page to reflect changes
|
||||
// window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
errorDiv.textContent = error.message;
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// FTP Browser Modal
|
||||
// ========================================
|
||||
@@ -2201,125 +2084,6 @@ document.getElementById('preview-modal')?.addEventListener('click', function(e)
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
|
||||
<button onclick="closeSettingsModal()" 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="settings-form" class="p-6 space-y-6">
|
||||
<input type="hidden" id="settings-unit-id">
|
||||
|
||||
<!-- Network Configuration -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Network Configuration</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host / IP Address</label>
|
||||
<input type="text" id="settings-host"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., 192.168.1.100" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||
<input type="number" id="settings-tcp-port"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="2255" min="1" max="65535" required>
|
||||
<p class="text-xs text-gray-500 mt-1">Default: 2255 for NL-43/NL-53</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
||||
<input type="number" id="settings-ftp-port"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="21" min="1" max="65535" required>
|
||||
<p class="text-xs text-gray-500 mt-1">Standard FTP port (default: 21)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FTP Credentials -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">FTP Credentials</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username</label>
|
||||
<input type="text" id="settings-ftp-username"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="anonymous">
|
||||
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
|
||||
<input type="password" id="settings-ftp-password"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Protocol Toggles -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Protocol Settings</h4>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">TCP Communication</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable TCP control commands</p>
|
||||
</div>
|
||||
<input type="checkbox" id="settings-tcp-enabled"
|
||||
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable FTP file browsing and downloads</p>
|
||||
</div>
|
||||
<input type="checkbox" id="settings-ftp-enabled"
|
||||
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Web Interface</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable web UI access (future feature)</p>
|
||||
</div>
|
||||
<input type="checkbox" id="settings-web-enabled"
|
||||
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
|
||||
<div id="settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="closeSettingsModal()"
|
||||
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-orange-600 text-white rounded-lg font-medium">
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FTP Browser Modal -->
|
||||
<div id="ftp-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-4xl max-h-[90vh] overflow-hidden m-4 flex flex-col">
|
||||
@@ -2407,3 +2171,6 @@ document.getElementById('preview-modal')?.addEventListener('click', function(e)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unified SLM Settings Modal -->
|
||||
{% include 'partials/slm_settings_modal.html' %}
|
||||
|
||||
534
templates/partials/slm_settings_modal.html
Normal file
534
templates/partials/slm_settings_modal.html
Normal file
@@ -0,0 +1,534 @@
|
||||
<!-- Unified SLM Settings Modal - Include this partial where SLM settings are needed -->
|
||||
<!-- Usage: include 'partials/slm_settings_modal.html' (with Jinja braces) -->
|
||||
<!-- Then call: openSLMSettingsModal(unitId) from JavaScript -->
|
||||
|
||||
<div id="slm-settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-8 h-8 text-seismo-orange" 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>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
|
||||
<p id="slm-settings-unit-display" class="text-sm text-gray-500 dark:text-gray-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="closeSLMSettingsModal()" 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="slm-settings-form" onsubmit="saveSLMSettings(event)" class="p-6 space-y-6">
|
||||
<input type="hidden" id="slm-settings-unit-id">
|
||||
|
||||
<!-- Network Configuration -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
||||
</svg>
|
||||
Network Configuration
|
||||
</h4>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Modem Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connected via Modem</label>
|
||||
<div class="flex gap-2">
|
||||
<select id="slm-settings-modem" class="flex-1 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="">Select a modem...</option>
|
||||
<!-- Modems loaded dynamically -->
|
||||
</select>
|
||||
<button type="button" onclick="testModemConnection()" id="slm-settings-test-modem-btn"
|
||||
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled title="Test modem connectivity">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem this SLM is connected through</p>
|
||||
</div>
|
||||
|
||||
<!-- Port Configuration -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||
<input type="number" id="slm-settings-tcp-port"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="2255" min="1" max="65535" value="2255">
|
||||
<p class="text-xs text-gray-500 mt-1">Control port (default: 2255)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
||||
<input type="number" id="slm-settings-ftp-port"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="21" min="1" max="65535" value="21">
|
||||
<p class="text-xs text-gray-500 mt-1">File transfer (default: 21)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FTP Credentials -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
||||
</svg>
|
||||
FTP Credentials
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username</label>
|
||||
<input type="text" id="slm-settings-ftp-username"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="anonymous">
|
||||
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
|
||||
<input type="password" id="slm-settings-ftp-password"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="Leave blank to keep existing">
|
||||
<p class="text-xs text-gray-500 mt-1">Leave blank to keep existing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Information -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-purple-500" 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>
|
||||
Device Information
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||
<select id="slm-settings-model" 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="">Select model...</option>
|
||||
<option value="NL-43">NL-43</option>
|
||||
<option value="NL-53">NL-53</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||
<input type="text" id="slm-settings-serial"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., SN123456">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 mt-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||
<select id="slm-settings-freq-weighting" 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="">Select...</option>
|
||||
<option value="A">A-weighting</option>
|
||||
<option value="C">C-weighting</option>
|
||||
<option value="Z">Z-weighting (Linear)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||
<select id="slm-settings-time-weighting" 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="">Select...</option>
|
||||
<option value="Fast">Fast (125ms)</option>
|
||||
<option value="Slow">Slow (1s)</option>
|
||||
<option value="Impulse">Impulse</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||
<select id="slm-settings-range" 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="">Select...</option>
|
||||
<option value="30-130">30-130 dB</option>
|
||||
<option value="40-140">40-140 dB</option>
|
||||
<option value="50-140">50-140 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FTP Enable Toggle -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<label class="flex items-center justify-between cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-orange-500" 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-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Enable FTP for file browsing and downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="checkbox" id="slm-settings-ftp-enabled"
|
||||
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div id="slm-settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
|
||||
<div id="slm-settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="closeSLMSettingsModal()"
|
||||
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="button" onclick="testSLMConnection()"
|
||||
class="px-6 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg">
|
||||
Test SLM Connection
|
||||
</button>
|
||||
<button type="submit" id="slm-settings-save-btn"
|
||||
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ========================================
|
||||
// Unified SLM Settings Modal JavaScript
|
||||
// ========================================
|
||||
|
||||
let slmSettingsModems = []; // Cache modems list
|
||||
|
||||
// Open the SLM Settings Modal
|
||||
async function openSLMSettingsModal(unitId) {
|
||||
const modal = document.getElementById('slm-settings-modal');
|
||||
const errorDiv = document.getElementById('slm-settings-error');
|
||||
const successDiv = document.getElementById('slm-settings-success');
|
||||
|
||||
// Clear previous messages
|
||||
errorDiv.classList.add('hidden');
|
||||
successDiv.classList.add('hidden');
|
||||
|
||||
// Store unit ID
|
||||
document.getElementById('slm-settings-unit-id').value = unitId;
|
||||
document.getElementById('slm-settings-unit-display').textContent = unitId;
|
||||
|
||||
// Load modems list if not cached
|
||||
if (slmSettingsModems.length === 0) {
|
||||
await loadModemsForSLMSettings();
|
||||
}
|
||||
|
||||
// Load current config from both Terra-View and SLMM
|
||||
try {
|
||||
// Fetch Terra-View unit data
|
||||
const unitResponse = await fetch(`/api/roster/${unitId}`);
|
||||
const unitData = unitResponse.ok ? await unitResponse.json() : {};
|
||||
|
||||
// Fetch SLMM config
|
||||
const slmmResponse = await fetch(`/api/slmm/${unitId}/config`);
|
||||
const slmmResult = slmmResponse.ok ? await slmmResponse.json() : {};
|
||||
const slmmData = slmmResult.data || slmmResult || {};
|
||||
|
||||
// Populate form fields
|
||||
// Modem selection
|
||||
const modemSelect = document.getElementById('slm-settings-modem');
|
||||
modemSelect.value = unitData.deployed_with_modem_id || '';
|
||||
updateTestModemButton();
|
||||
|
||||
// Ports
|
||||
document.getElementById('slm-settings-tcp-port').value = unitData.slm_tcp_port || slmmData.tcp_port || 2255;
|
||||
document.getElementById('slm-settings-ftp-port').value = unitData.slm_ftp_port || slmmData.ftp_port || 21;
|
||||
|
||||
// FTP credentials from SLMM
|
||||
document.getElementById('slm-settings-ftp-username').value = slmmData.ftp_username || '';
|
||||
document.getElementById('slm-settings-ftp-password').value = ''; // Don't pre-fill
|
||||
|
||||
// Device info from Terra-View
|
||||
document.getElementById('slm-settings-model').value = unitData.slm_model || '';
|
||||
document.getElementById('slm-settings-serial').value = unitData.slm_serial_number || '';
|
||||
document.getElementById('slm-settings-freq-weighting').value = unitData.slm_frequency_weighting || '';
|
||||
document.getElementById('slm-settings-time-weighting').value = unitData.slm_time_weighting || '';
|
||||
document.getElementById('slm-settings-range').value = unitData.slm_measurement_range || '';
|
||||
|
||||
// FTP enabled from SLMM
|
||||
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load SLM settings:', error);
|
||||
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Close the modal
|
||||
function closeSLMSettingsModal() {
|
||||
document.getElementById('slm-settings-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Alias for backwards compatibility with existing code
|
||||
function showFTPSettings(unitId) {
|
||||
openSLMSettingsModal(unitId);
|
||||
}
|
||||
function closeFTPSettings() {
|
||||
closeSLMSettingsModal();
|
||||
}
|
||||
function openSettingsModal(unitId) {
|
||||
openSLMSettingsModal(unitId);
|
||||
}
|
||||
function closeSettingsModal() {
|
||||
closeSLMSettingsModal();
|
||||
}
|
||||
function openConfigModal(unitId) {
|
||||
openSLMSettingsModal(unitId);
|
||||
}
|
||||
function closeConfigModal() {
|
||||
closeSLMSettingsModal();
|
||||
}
|
||||
function openDeviceConfigModal(unitId) {
|
||||
openSLMSettingsModal(unitId);
|
||||
}
|
||||
function closeDeviceConfigModal() {
|
||||
closeSLMSettingsModal();
|
||||
}
|
||||
|
||||
// Load modems for dropdown
|
||||
async function loadModemsForSLMSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/roster/modems');
|
||||
slmSettingsModems = await response.json();
|
||||
|
||||
const select = document.getElementById('slm-settings-modem');
|
||||
// Clear existing options except first
|
||||
select.innerHTML = '<option value="">Select a modem...</option>';
|
||||
|
||||
slmSettingsModems.forEach(modem => {
|
||||
const option = document.createElement('option');
|
||||
option.value = modem.id;
|
||||
const ipText = modem.ip_address ? ` (${modem.ip_address})` : '';
|
||||
const deployedText = modem.deployed ? '' : ' [Benched]';
|
||||
option.textContent = modem.id + ipText + deployedText;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load modems:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update test modem button state based on selection
|
||||
function updateTestModemButton() {
|
||||
const modemSelect = document.getElementById('slm-settings-modem');
|
||||
const testBtn = document.getElementById('slm-settings-test-modem-btn');
|
||||
testBtn.disabled = !modemSelect.value;
|
||||
}
|
||||
|
||||
// Listen for modem selection changes
|
||||
document.getElementById('slm-settings-modem')?.addEventListener('change', updateTestModemButton);
|
||||
|
||||
// Test modem connection
|
||||
async function testModemConnection() {
|
||||
const modemId = document.getElementById('slm-settings-modem').value;
|
||||
if (!modemId) return;
|
||||
|
||||
const errorDiv = document.getElementById('slm-settings-error');
|
||||
const successDiv = document.getElementById('slm-settings-success');
|
||||
|
||||
errorDiv.classList.add('hidden');
|
||||
successDiv.textContent = 'Pinging modem...';
|
||||
successDiv.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slm-dashboard/test-modem/${modemId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.status === 'success') {
|
||||
const ipAddr = data.ip_address || modemId;
|
||||
const respTime = data.response_time || 'N/A';
|
||||
successDiv.textContent = `✓ Modem responding! ${ipAddr} - ${respTime}ms`;
|
||||
} else {
|
||||
successDiv.classList.add('hidden');
|
||||
errorDiv.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
successDiv.classList.add('hidden');
|
||||
errorDiv.textContent = 'Failed to ping modem: ' + error.message;
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Test SLM connection
|
||||
async function testSLMConnection() {
|
||||
const unitId = document.getElementById('slm-settings-unit-id').value;
|
||||
const errorDiv = document.getElementById('slm-settings-error');
|
||||
const successDiv = document.getElementById('slm-settings-success');
|
||||
|
||||
errorDiv.classList.add('hidden');
|
||||
successDiv.textContent = 'Testing SLM connection...';
|
||||
successDiv.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/status`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.status === 'online') {
|
||||
successDiv.textContent = '✓ SLM connection successful! Device is responding.';
|
||||
} else {
|
||||
successDiv.classList.add('hidden');
|
||||
errorDiv.textContent = '⚠ SLM not responding or offline. Check network settings.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
successDiv.classList.add('hidden');
|
||||
errorDiv.textContent = 'Connection test failed: ' + error.message;
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Save SLM settings
|
||||
async function saveSLMSettings(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const unitId = document.getElementById('slm-settings-unit-id').value;
|
||||
const saveBtn = document.getElementById('slm-settings-save-btn');
|
||||
const errorDiv = document.getElementById('slm-settings-error');
|
||||
const successDiv = document.getElementById('slm-settings-success');
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
errorDiv.classList.add('hidden');
|
||||
successDiv.classList.add('hidden');
|
||||
|
||||
// Get selected modem and resolve its IP
|
||||
const modemId = document.getElementById('slm-settings-modem').value;
|
||||
let modemIp = '';
|
||||
if (modemId) {
|
||||
const modem = slmSettingsModems.find(m => m.id === modemId);
|
||||
modemIp = modem?.ip_address || '';
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!modemId) {
|
||||
errorDiv.textContent = 'Please select a modem';
|
||||
errorDiv.classList.remove('hidden');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Configuration';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modemIp) {
|
||||
errorDiv.textContent = 'Selected modem has no IP address configured';
|
||||
errorDiv.classList.remove('hidden');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Configuration';
|
||||
return;
|
||||
}
|
||||
|
||||
const tcpPort = parseInt(document.getElementById('slm-settings-tcp-port').value) || 2255;
|
||||
const ftpPort = parseInt(document.getElementById('slm-settings-ftp-port').value) || 21;
|
||||
|
||||
if (tcpPort < 1 || tcpPort > 65535 || ftpPort < 1 || ftpPort > 65535) {
|
||||
errorDiv.textContent = 'Port values must be between 1 and 65535';
|
||||
errorDiv.classList.remove('hidden');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Configuration';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Update Terra-View database (device info + modem assignment)
|
||||
const terraViewData = {
|
||||
deployed_with_modem_id: modemId,
|
||||
slm_model: document.getElementById('slm-settings-model').value || null,
|
||||
slm_serial_number: document.getElementById('slm-settings-serial').value || null,
|
||||
slm_frequency_weighting: document.getElementById('slm-settings-freq-weighting').value || null,
|
||||
slm_time_weighting: document.getElementById('slm-settings-time-weighting').value || null,
|
||||
slm_measurement_range: document.getElementById('slm-settings-range').value || null,
|
||||
slm_tcp_port: tcpPort,
|
||||
slm_ftp_port: ftpPort
|
||||
};
|
||||
|
||||
const terraResponse = await fetch(`/api/slm-dashboard/config/${unitId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(terraViewData)
|
||||
});
|
||||
|
||||
if (!terraResponse.ok) {
|
||||
throw new Error('Failed to save Terra-View configuration');
|
||||
}
|
||||
|
||||
// 2. Update SLMM config (network + FTP credentials)
|
||||
const slmmData = {
|
||||
host: modemIp,
|
||||
tcp_port: tcpPort,
|
||||
ftp_port: ftpPort,
|
||||
ftp_username: document.getElementById('slm-settings-ftp-username').value.trim() || null,
|
||||
ftp_enabled: document.getElementById('slm-settings-ftp-enabled').checked
|
||||
};
|
||||
|
||||
// Only include password if entered
|
||||
const password = document.getElementById('slm-settings-ftp-password').value;
|
||||
if (password) {
|
||||
slmmData.ftp_password = password;
|
||||
}
|
||||
|
||||
const slmmResponse = await fetch(`/api/slmm/${unitId}/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(slmmData)
|
||||
});
|
||||
|
||||
if (!slmmResponse.ok) {
|
||||
const errData = await slmmResponse.json().catch(() => ({}));
|
||||
throw new Error(errData.detail || 'Failed to save SLMM configuration');
|
||||
}
|
||||
|
||||
successDiv.textContent = 'Configuration saved successfully!';
|
||||
successDiv.classList.remove('hidden');
|
||||
|
||||
// Close modal after delay and refresh if needed
|
||||
setTimeout(() => {
|
||||
closeSLMSettingsModal();
|
||||
// Try to refresh any FTP status or unit lists on the page
|
||||
if (typeof checkFTPStatus === 'function') {
|
||||
checkFTPStatus(unitId);
|
||||
}
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.trigger('#slm-list', 'load');
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
errorDiv.textContent = 'Error: ' + error.message;
|
||||
errorDiv.classList.remove('hidden');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Configuration';
|
||||
}
|
||||
}
|
||||
|
||||
// Alias for backwards compatibility
|
||||
async function saveFTPSettings(event) {
|
||||
return saveSLMSettings(event);
|
||||
}
|
||||
|
||||
// Close modal on background click
|
||||
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeSLMSettingsModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
309
templates/report_preview.html
Normal file
309
templates/report_preview.html
Normal file
@@ -0,0 +1,309 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Report Preview - {{ project.name if project else 'Sound Level Data' }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- jspreadsheet CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/jspreadsheet.min.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.css" />
|
||||
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
|
||||
<!-- Header -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Report Preview & Editor
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{% if file %}{{ file.file_path.split('/')[-1] }}{% endif %}
|
||||
{% if location %} @ {{ location.name }}{% endif %}
|
||||
{% if start_time and end_time %} | Time: {{ start_time }} - {{ end_time }}{% endif %}
|
||||
| {{ filtered_count }} of {{ original_count }} rows
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="downloadReport()"
|
||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Download Excel
|
||||
</button>
|
||||
<a href="/api/projects/{{ project_id }}/files/{{ file_id }}/view-rnd"
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
Back to Viewer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Info Section -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report Title</label>
|
||||
<input type="text" id="edit-report-title" value="{{ report_title }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
|
||||
<input type="text" id="edit-project-name" value="{{ project_name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client Name</label>
|
||||
<input type="text" id="edit-client-name" value="{{ client_name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Location</label>
|
||||
<input type="text" id="edit-location-name" value="{{ location_name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spreadsheet Editor -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Data Table</h2>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Right-click for options</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span>Double-click to edit</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spreadsheet" class="overflow-x-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="mt-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">Editing Tips</h3>
|
||||
<ul class="text-sm text-blue-700 dark:text-blue-400 list-disc list-inside space-y-1">
|
||||
<li>Double-click any cell to edit its value</li>
|
||||
<li>Use the Comments column to add notes about specific measurements</li>
|
||||
<li>Right-click a row to delete it from the report</li>
|
||||
<li>Right-click to add new rows if needed</li>
|
||||
<li>Press Enter to confirm edits, Escape to cancel</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- jspreadsheet JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/index.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Initialize spreadsheet data from server
|
||||
const initialData = {{ spreadsheet_data | tojson }};
|
||||
|
||||
// Create jspreadsheet instance
|
||||
let spreadsheet = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
spreadsheet = jspreadsheet(document.getElementById('spreadsheet'), {
|
||||
data: initialData,
|
||||
columns: [
|
||||
{ title: 'Test #', width: 80, type: 'numeric' },
|
||||
{ title: 'Date', width: 110, type: 'text' },
|
||||
{ title: 'Time', width: 90, type: 'text' },
|
||||
{ title: 'LAmax (dBA)', width: 100, type: 'numeric' },
|
||||
{ title: 'LA01 (dBA)', width: 100, type: 'numeric' },
|
||||
{ title: 'LA10 (dBA)', width: 100, type: 'numeric' },
|
||||
{ title: 'Comments', width: 250, type: 'text' }
|
||||
],
|
||||
allowInsertRow: true,
|
||||
allowDeleteRow: true,
|
||||
allowInsertColumn: false,
|
||||
allowDeleteColumn: false,
|
||||
rowDrag: true,
|
||||
columnSorting: true,
|
||||
search: true,
|
||||
pagination: 50,
|
||||
paginationOptions: [25, 50, 100, 200],
|
||||
defaultColWidth: 100,
|
||||
minDimensions: [7, 1],
|
||||
tableOverflow: true,
|
||||
tableWidth: '100%',
|
||||
contextMenu: function(instance, col, row, e) {
|
||||
const items = [];
|
||||
|
||||
if (row !== null) {
|
||||
items.push({
|
||||
title: 'Insert row above',
|
||||
onclick: function() {
|
||||
instance.insertRow(1, row, true);
|
||||
}
|
||||
});
|
||||
items.push({
|
||||
title: 'Insert row below',
|
||||
onclick: function() {
|
||||
instance.insertRow(1, row + 1, false);
|
||||
}
|
||||
});
|
||||
items.push({
|
||||
title: 'Delete this row',
|
||||
onclick: function() {
|
||||
instance.deleteRow(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
style: {
|
||||
A: 'text-align: center;',
|
||||
B: 'text-align: center;',
|
||||
C: 'text-align: center;',
|
||||
D: 'text-align: right;',
|
||||
E: 'text-align: right;',
|
||||
F: 'text-align: right;',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function downloadReport() {
|
||||
// Get current data from spreadsheet
|
||||
const data = spreadsheet.getData();
|
||||
|
||||
// Get report settings
|
||||
const reportTitle = document.getElementById('edit-report-title').value;
|
||||
const projectName = document.getElementById('edit-project-name').value;
|
||||
const clientName = document.getElementById('edit-client-name').value;
|
||||
const locationName = document.getElementById('edit-location-name').value;
|
||||
|
||||
// Build time filter info
|
||||
let timeFilter = '';
|
||||
{% if start_time and end_time %}
|
||||
timeFilter = 'Time Filter: {{ start_time }} - {{ end_time }}';
|
||||
{% if start_date or end_date %}
|
||||
timeFilter += ' | Date Range: {{ start_date or "start" }} to {{ end_date or "end" }}';
|
||||
{% endif %}
|
||||
timeFilter += ' | {{ filtered_count }} of {{ original_count }} rows';
|
||||
{% endif %}
|
||||
|
||||
// Send to server to generate Excel
|
||||
try {
|
||||
const response = await fetch('/api/projects/{{ project_id }}/files/{{ file_id }}/generate-from-preview', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: data,
|
||||
report_title: reportTitle,
|
||||
project_name: projectName,
|
||||
client_name: clientName,
|
||||
location_name: locationName,
|
||||
time_filter: timeFilter
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Download the file
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
// Get filename from Content-Disposition header if available
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = 'report.xlsx';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="(.+)"/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('Error generating report: ' + (error.detail || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error generating report: ' + error.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Custom styles for jspreadsheet to match dark mode */
|
||||
.dark .jexcel {
|
||||
background-color: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .jexcel thead td {
|
||||
background-color: #334155 !important;
|
||||
color: #e2e8f0 !important;
|
||||
border-color: #475569 !important;
|
||||
}
|
||||
|
||||
.dark .jexcel tbody td {
|
||||
background-color: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.dark .jexcel tbody td:hover {
|
||||
background-color: #334155;
|
||||
}
|
||||
|
||||
.dark .jexcel tbody tr:nth-child(even) td {
|
||||
background-color: #0f172a;
|
||||
}
|
||||
|
||||
.dark .jexcel_pagination {
|
||||
background-color: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.dark .jexcel_pagination a {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .jexcel_search {
|
||||
background-color: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.dark .jexcel_search input {
|
||||
background-color: #334155;
|
||||
color: #e2e8f0;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.dark .jexcel_content {
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
.dark .jexcel_contextmenu {
|
||||
background-color: #1e293b;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.dark .jexcel_contextmenu a {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .jexcel_contextmenu a:hover {
|
||||
background-color: #334155;
|
||||
}
|
||||
|
||||
/* Ensure proper sizing */
|
||||
.jexcel_content {
|
||||
max-height: 600px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -454,42 +454,227 @@ function escapeHtml(str) {
|
||||
}
|
||||
|
||||
// Report Generation Modal Functions
|
||||
let reportTemplates = [];
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const response = await fetch('/api/report-templates?project_id={{ project_id }}');
|
||||
if (response.ok) {
|
||||
reportTemplates = await response.json();
|
||||
populateTemplateDropdown();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function populateTemplateDropdown() {
|
||||
const select = document.getElementById('template-select');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = '<option value="">-- Select a template --</option>';
|
||||
reportTemplates.forEach(template => {
|
||||
const option = document.createElement('option');
|
||||
option.value = template.id;
|
||||
option.textContent = template.name;
|
||||
option.dataset.config = JSON.stringify(template);
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function applyTemplate() {
|
||||
const select = document.getElementById('template-select');
|
||||
const selectedOption = select.options[select.selectedIndex];
|
||||
|
||||
if (!selectedOption.value) return;
|
||||
|
||||
const template = JSON.parse(selectedOption.dataset.config);
|
||||
|
||||
if (template.report_title) {
|
||||
document.getElementById('report-title').value = template.report_title;
|
||||
}
|
||||
if (template.start_time) {
|
||||
document.getElementById('start-time').value = template.start_time;
|
||||
}
|
||||
if (template.end_time) {
|
||||
document.getElementById('end-time').value = template.end_time;
|
||||
}
|
||||
if (template.start_date) {
|
||||
document.getElementById('start-date').value = template.start_date;
|
||||
}
|
||||
if (template.end_date) {
|
||||
document.getElementById('end-date').value = template.end_date;
|
||||
}
|
||||
|
||||
// Update preset buttons
|
||||
updatePresetButtons();
|
||||
}
|
||||
|
||||
function setTimePreset(preset) {
|
||||
const startTimeInput = document.getElementById('start-time');
|
||||
const endTimeInput = document.getElementById('end-time');
|
||||
|
||||
// Remove active state from all preset buttons
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.classList.remove('bg-emerald-600', 'text-white');
|
||||
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||
});
|
||||
|
||||
// Set time values based on preset
|
||||
switch(preset) {
|
||||
case 'night':
|
||||
startTimeInput.value = '19:00';
|
||||
endTimeInput.value = '07:00';
|
||||
break;
|
||||
case 'day':
|
||||
startTimeInput.value = '07:00';
|
||||
endTimeInput.value = '19:00';
|
||||
break;
|
||||
case 'all':
|
||||
startTimeInput.value = '';
|
||||
endTimeInput.value = '';
|
||||
break;
|
||||
case 'custom':
|
||||
// Just enable custom input, don't change values
|
||||
break;
|
||||
}
|
||||
|
||||
// Highlight active preset
|
||||
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePresetButtons() {
|
||||
const startTime = document.getElementById('start-time').value;
|
||||
const endTime = document.getElementById('end-time').value;
|
||||
|
||||
// Remove active state from all
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.classList.remove('bg-emerald-600', 'text-white');
|
||||
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||
});
|
||||
|
||||
// Check which preset matches
|
||||
let preset = 'custom';
|
||||
if (startTime === '19:00' && endTime === '07:00') preset = 'night';
|
||||
else if (startTime === '07:00' && endTime === '19:00') preset = 'day';
|
||||
else if (!startTime && !endTime) preset = 'all';
|
||||
|
||||
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
||||
}
|
||||
}
|
||||
|
||||
function openReportModal() {
|
||||
document.getElementById('report-modal').classList.remove('hidden');
|
||||
// Pre-fill location name if available
|
||||
loadTemplates();
|
||||
|
||||
// Pre-fill fields if available
|
||||
const locationInput = document.getElementById('report-location');
|
||||
if (locationInput && !locationInput.value) {
|
||||
locationInput.value = '{{ location.name if location else "" }}';
|
||||
}
|
||||
const projectInput = document.getElementById('report-project');
|
||||
if (projectInput && !projectInput.value) {
|
||||
projectInput.value = '{{ project.name if project else "" }}';
|
||||
}
|
||||
const clientInput = document.getElementById('report-client');
|
||||
if (clientInput && !clientInput.value) {
|
||||
clientInput.value = '{{ project.client_name if project and project.client_name else "" }}';
|
||||
}
|
||||
|
||||
// Set default to "All Day"
|
||||
setTimePreset('all');
|
||||
}
|
||||
|
||||
function closeReportModal() {
|
||||
document.getElementById('report-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function generateReport() {
|
||||
function generateReport(preview = false) {
|
||||
const reportTitle = document.getElementById('report-title').value || 'Background Noise Study';
|
||||
const projectName = document.getElementById('report-project').value || '';
|
||||
const clientName = document.getElementById('report-client').value || '';
|
||||
const locationName = document.getElementById('report-location').value || '';
|
||||
const startTime = document.getElementById('start-time').value || '';
|
||||
const endTime = document.getElementById('end-time').value || '';
|
||||
const startDate = document.getElementById('start-date').value || '';
|
||||
const endDate = document.getElementById('end-date').value || '';
|
||||
|
||||
// Build the URL with query parameters
|
||||
const params = new URLSearchParams({
|
||||
report_title: reportTitle,
|
||||
location_name: locationName
|
||||
project_name: projectName,
|
||||
client_name: clientName,
|
||||
location_name: locationName,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
});
|
||||
|
||||
// Trigger download
|
||||
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/generate-report?${params.toString()}`;
|
||||
if (preview) {
|
||||
// Open preview page
|
||||
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/preview-report?${params.toString()}`;
|
||||
} else {
|
||||
// Direct download
|
||||
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/generate-report?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Close modal
|
||||
closeReportModal();
|
||||
}
|
||||
|
||||
async function saveAsTemplate() {
|
||||
const name = prompt('Enter a name for this template:');
|
||||
if (!name) return;
|
||||
|
||||
const templateData = {
|
||||
name: name,
|
||||
project_id: '{{ project_id }}',
|
||||
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||||
start_time: document.getElementById('start-time').value || null,
|
||||
end_time: document.getElementById('end-time').value || null,
|
||||
start_date: document.getElementById('start-date').value || null,
|
||||
end_date: document.getElementById('end-date').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/report-templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(templateData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Template saved successfully!');
|
||||
loadTemplates();
|
||||
} else {
|
||||
alert('Failed to save template');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error saving template: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeReportModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Update preset buttons when time inputs change
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const startTimeInput = document.getElementById('start-time');
|
||||
const endTimeInput = document.getElementById('end-time');
|
||||
if (startTimeInput) startTimeInput.addEventListener('change', updatePresetButtons);
|
||||
if (endTimeInput) endTimeInput.addEventListener('change', updatePresetButtons);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Report Generation Modal -->
|
||||
@@ -499,7 +684,7 @@ document.addEventListener('keydown', function(e) {
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 transition-opacity" onclick="closeReportModal()"></div>
|
||||
|
||||
<!-- Modal panel -->
|
||||
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
|
||||
<div class="bg-white dark:bg-slate-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 sm:mx-0 sm:h-10 sm:w-10">
|
||||
@@ -511,15 +696,53 @@ document.addEventListener('keydown', function(e) {
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
|
||||
Generate Excel Report
|
||||
</h3>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- Template Selection -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Load Template
|
||||
</label>
|
||||
<select id="template-select" onchange="applyTemplate()"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||
<option value="">-- Select a template --</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" onclick="saveAsTemplate()"
|
||||
class="mt-6 px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
title="Save current settings as template">
|
||||
<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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Report Title -->
|
||||
<div>
|
||||
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Report Title
|
||||
</label>
|
||||
<input type="text" id="report-title" value="Background Noise Study"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
|
||||
placeholder="e.g., Background Noise Study - Commercial Street Bridge">
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Project and Client in a row -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Project Name
|
||||
</label>
|
||||
<input type="text" id="report-project" value=""
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="report-client" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Client Name
|
||||
</label>
|
||||
<input type="text" id="report-client" value=""
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Name -->
|
||||
@@ -528,8 +751,67 @@ document.addEventListener('keydown', function(e) {
|
||||
Location Name
|
||||
</label>
|
||||
<input type="text" id="report-location" value=""
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
|
||||
placeholder="e.g., NRL 1 - West Side">
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Time Filter Section -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Time Filter
|
||||
</label>
|
||||
|
||||
<!-- Preset Buttons -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button type="button" onclick="setTimePreset('night')" data-preset="night"
|
||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
7PM - 7AM
|
||||
</button>
|
||||
<button type="button" onclick="setTimePreset('day')" data-preset="day"
|
||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
7AM - 7PM
|
||||
</button>
|
||||
<button type="button" onclick="setTimePreset('all')" data-preset="all"
|
||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700 transition-colors">
|
||||
All Day
|
||||
</button>
|
||||
<button type="button" onclick="setTimePreset('custom')" data-preset="custom"
|
||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom Time Inputs -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400">Start Time</label>
|
||||
<input type="time" id="start-time" value=""
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end-time" class="block text-xs text-gray-500 dark:text-gray-400">End Time</label>
|
||||
<input type="time" id="end-time" value=""
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range (Optional) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Date Range <span class="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||
<input type="date" id="start-date" value=""
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end-date" class="block text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||
<input type="date" id="end-date" value=""
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info about what's included -->
|
||||
@@ -547,16 +829,24 @@ document.addEventListener('keydown', function(e) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2">
|
||||
<button type="button" onclick="generateReport()"
|
||||
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:px-6 flex flex-col sm:flex-row-reverse gap-2">
|
||||
<button type="button" onclick="generateReport(false)"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-emerald-600 text-base font-medium text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Generate & Download
|
||||
Download Excel
|
||||
</button>
|
||||
<button type="button" onclick="generateReport(true)"
|
||||
class="w-full inline-flex justify-center rounded-md border border-emerald-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
Preview & Edit
|
||||
</button>
|
||||
<button type="button" onclick="closeReportModal()"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:mt-0 sm:w-auto sm:text-sm">
|
||||
class="w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
{% if not from_project and not from_nrl %}
|
||||
<!-- Configure button only shown in administrative context (accessed from roster/SLM dashboard) -->
|
||||
<div class="flex gap-3">
|
||||
<button onclick="openConfigModal()"
|
||||
<button onclick="openSLMSettingsModal('{{ unit_id }}')"
|
||||
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>
|
||||
@@ -104,73 +104,7 @@
|
||||
</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>
|
||||
<!-- Unified SLM Settings Modal -->
|
||||
{% include 'partials/slm_settings_modal.html' %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -137,27 +137,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Unified SLM Settings Modal -->
|
||||
{% include 'partials/slm_settings_modal.html' %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
@@ -365,33 +346,23 @@ function updateDashboardChart(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration modal
|
||||
// Configuration modal - use unified SLM settings modal
|
||||
function openDeviceConfigModal(unitId) {
|
||||
const modal = document.getElementById('slm-config-modal');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
||||
target: '#slm-config-modal-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
// Call the unified modal function from slm_settings_modal.html
|
||||
if (typeof openSLMSettingsModal === 'function') {
|
||||
openSLMSettingsModal(unitId);
|
||||
} else {
|
||||
console.error('openSLMSettingsModal not found');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeviceConfigModal() {
|
||||
document.getElementById('slm-config-modal').classList.add('hidden');
|
||||
// Call the unified modal close function
|
||||
if (typeof closeSLMSettingsModal === 'function') {
|
||||
closeSLMSettingsModal();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDeviceConfigModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('slm-config-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeDeviceConfigModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
stopDashboardStream();
|
||||
|
||||
Reference in New Issue
Block a user