Compare commits
19 Commits
8e292b1aca
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dd396acd8 | |||
| e89a04f58c | |||
| e4ef065db8 | |||
| 86010de60c | |||
| f89f04cd6f | |||
| 67a2faa2d3 | |||
| 14856e61ef | |||
| 2b69518b33 | |||
| 6070d03e83 | |||
| 240552751c | |||
| 015ce0a254 | |||
| ef8c046f31 | |||
| 3637cf5af8 | |||
| 7fde14d882 | |||
| bd3d937a82 | |||
| 291fa8e862 | |||
| b15d434fce | |||
| 7b4e12c127 | |||
| 742a98a8ed |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
|||||||
# Terra-View Specifics
|
# Terra-View Specifics
|
||||||
|
# Dev build counter (local only, never commit)
|
||||||
|
build_number.txt
|
||||||
|
|
||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|||||||
55
CHANGELOG.md
55
CHANGELOG.md
@@ -5,6 +5,60 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-03-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Project Status Management**: Projects can now be placed `on_hold` or `archived`, with automatic cancellation of pending scheduled actions
|
||||||
|
- **Hard Delete Projects**: Support for permanently deleting projects, in addition to soft-delete with auto-pruning
|
||||||
|
- **Vibration Location Detail**: New dedicated template for vibration project location detail views
|
||||||
|
- **Vibration Project Isolation**: Vibration projects no longer show SLM-specific project tabs
|
||||||
|
- **Manual SD Card Data Upload**: Upload offline NRL data directly from SD card via ZIP or multi-file select
|
||||||
|
- Accepts `.rnd`/`.rnh` files; parses `.rnh` metadata for session start/stop times, serial number, and store name
|
||||||
|
- Creates `MonitoringSession` and `DataFile` records automatically; no unit assignment required
|
||||||
|
- Upload panel on NRL detail Data Files tab with inline feedback and auto-refresh via HTMX
|
||||||
|
- **Standalone SLM Type**: New SLM device mode that operates without a modem (direct IP connection)
|
||||||
|
- **NL32 Data Support**: Report generator and web viewer now support NL32 measurement data format
|
||||||
|
- **Combined Report Wizard**: Multi-session combined Excel report generation tool
|
||||||
|
- Wizard UI grouped by location with period type badges (day/night)
|
||||||
|
- Each selected session produces one `.xlsx` in a ZIP archive
|
||||||
|
- Period type filtering: day sessions keep last calendar date (7AM–6:59PM); night sessions span both days (7PM–6:59AM)
|
||||||
|
- **Combined Report Preview**: Interactive spreadsheet-style preview before generating combined reports
|
||||||
|
- **Chart Preview**: Live chart preview in the report generator matching final report styling
|
||||||
|
- **SLM Model Schemas**: Per-model configuration schemas for NL32, NL43, NL53 devices
|
||||||
|
- **Data Collection Mode**: Projects now store a data collection mode field with UI controls and migration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **MonitoringSession rename**: `RecordingSession` renamed to `MonitoringSession` throughout codebase; DB table renamed from `recording_sessions` to `monitoring_sessions`
|
||||||
|
- Migration: `backend/migrate_rename_recording_to_monitoring_sessions.py`
|
||||||
|
- **Combined Report Split Logic**: Separate days now generate separate `.xlsx` files; NRLs remain one per sheet
|
||||||
|
- **Mass Upload Parsing**: Smarter file filtering — no longer imports unneeded Lp files or `.xlsx` files
|
||||||
|
- **SLM Start Time Grace Period**: 15-minute grace window added so data starting at session start time is included
|
||||||
|
- **NL32 Date Parsing**: Date now read from `start_time` field instead of file metadata
|
||||||
|
- **Project Data Labels**: Improved Jinja filters and UI label clarity for project data views
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Dev/Prod Separation**: Dev server now uses Docker Compose override; production deployment no longer affected by dev config
|
||||||
|
- **SLM Modal**: Bench/deploy toggle now correctly shown in SLM unit modal
|
||||||
|
- **Auto-Downloaded Files**: Files downloaded by scheduler now appear in project file listings
|
||||||
|
- **Duplicate Download**: Removed duplicate file download that occurred following a scheduled stop
|
||||||
|
- **SLMM Environment Variables**: `TCP_IDLE_TTL` and `TCP_MAX_AGE` now correctly passed to SLMM service via docker-compose
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- `session_label` and `period_type` stored on `monitoring_sessions` table (migration: `migrate_add_session_period_type.py`)
|
||||||
|
- `device_model` stored on `monitoring_sessions` table (migration: `migrate_add_session_device_model.py`)
|
||||||
|
- Upload endpoint: `POST /api/projects/{project_id}/nrl/{location_id}/upload-data`
|
||||||
|
- ZIP filename format: `{session_label}_{project_name}_report.xlsx` (label first)
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run the following migration scripts once per database before deploying:
|
||||||
|
```bash
|
||||||
|
python backend/migrate_rename_recording_to_monitoring_sessions.py
|
||||||
|
python backend/migrate_add_session_period_type.py
|
||||||
|
python backend/migrate_add_session_device_model.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.6.1] - 2026-02-16
|
## [0.6.1] - 2026-02-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -445,6 +499,7 @@ No database migration required for v0.4.0. All new features use existing databas
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
|
[0.7.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.6.1...v0.7.0
|
||||||
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
|
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
|
||||||
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
|
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
|
||||||
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
|
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Build number for dev builds (injected via --build-arg)
|
||||||
|
ARG BUILD_NUMBER=0
|
||||||
|
ENV BUILD_NUMBER=${BUILD_NUMBER}
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.6.1
|
# Terra-View v0.7.0
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -496,6 +496,16 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
||||||
|
### v0.7.0 — 2026-03-07
|
||||||
|
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
|
||||||
|
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
|
||||||
|
- **Combined Report Wizard**: Multi-session Excel report generation with location grouping, period type filtering, and ZIP download
|
||||||
|
- **NL32 Support**: Report generator and web viewer now handle NL32 measurement data
|
||||||
|
- **Chart Preview**: Live chart preview in the report generator matching final output styling
|
||||||
|
- **Standalone SLM Mode**: SLMs can now be configured without a paired modem (direct IP)
|
||||||
|
- **Vibration Project Isolation**: Vibration project views no longer show SLM-specific tabs
|
||||||
|
- **MonitoringSession Rename**: `RecordingSession` renamed to `MonitoringSession` throughout; run migration before deploying
|
||||||
|
|
||||||
### v0.6.1 — 2026-02-16
|
### v0.6.1 — 2026-02-16
|
||||||
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
|
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
|
||||||
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
|
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
|
||||||
@@ -584,11 +594,13 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.6.1** — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
|
**Current: 0.7.0** — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
|
||||||
|
|
||||||
Previous: 0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
Previous: 0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
|
||||||
|
|
||||||
Previous: 0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
||||||
|
|
||||||
|
0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
||||||
|
|
||||||
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.6.1"
|
VERSION = "0.7.0"
|
||||||
|
if ENVIRONMENT == "development":
|
||||||
|
_build = os.getenv("BUILD_NUMBER", "0")
|
||||||
|
if _build and _build != "0":
|
||||||
|
VERSION = f"{VERSION}-{_build}"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
@@ -364,6 +368,15 @@ async def nrl_detail_page(
|
|||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
# Parse connection_mode from location_metadata JSON
|
||||||
|
import json as _json
|
||||||
|
connection_mode = "connected"
|
||||||
|
try:
|
||||||
|
meta = _json.loads(location.location_metadata or "{}")
|
||||||
|
connection_mode = meta.get("connection_mode", "connected")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
|
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
|
||||||
return templates.TemplateResponse(template, {
|
return templates.TemplateResponse(template, {
|
||||||
"request": request,
|
"request": request,
|
||||||
@@ -376,6 +389,7 @@ async def nrl_detail_page(
|
|||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
"file_count": file_count,
|
"file_count": file_count,
|
||||||
"active_session": active_session,
|
"active_session": active_session,
|
||||||
|
"connection_mode": connection_mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
53
backend/migrate_add_project_data_collection_mode.py
Normal file
53
backend/migrate_add_project_data_collection_mode.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: Add data_collection_mode column to projects table.
|
||||||
|
|
||||||
|
Values:
|
||||||
|
"remote" — units have modems; data pulled via FTP/scheduler automatically
|
||||||
|
"manual" — no modem; SD cards retrieved daily and uploaded by hand
|
||||||
|
|
||||||
|
All existing projects are backfilled to "manual" (safe conservative default).
|
||||||
|
|
||||||
|
Run once inside the Docker container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_project_data_collection_mode.py
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path("data/seismo_fleet.db")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# ── 1. Add column (idempotent) ───────────────────────────────────────────
|
||||||
|
cur.execute("PRAGMA table_info(projects)")
|
||||||
|
existing_cols = {row["name"] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
if "data_collection_mode" not in existing_cols:
|
||||||
|
cur.execute("ALTER TABLE projects ADD COLUMN data_collection_mode TEXT DEFAULT 'manual'")
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Added column data_collection_mode to projects")
|
||||||
|
else:
|
||||||
|
print("○ Column data_collection_mode already exists — skipping ALTER TABLE")
|
||||||
|
|
||||||
|
# ── 2. Backfill NULLs to 'manual' ────────────────────────────────────────
|
||||||
|
cur.execute("UPDATE projects SET data_collection_mode = 'manual' WHERE data_collection_mode IS NULL")
|
||||||
|
updated = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
print(f"✓ Backfilled {updated} project(s) to data_collection_mode='manual'.")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
127
backend/migrate_add_session_device_model.py
Normal file
127
backend/migrate_add_session_device_model.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: Add device_model column to monitoring_sessions table.
|
||||||
|
|
||||||
|
Records which physical SLM model produced each session's data (e.g. "NL-43",
|
||||||
|
"NL-53", "NL-32"). Used by report generation to apply the correct parsing
|
||||||
|
logic without re-opening files to detect format.
|
||||||
|
|
||||||
|
Run once inside the Docker container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_session_device_model.py
|
||||||
|
|
||||||
|
Backfill strategy for existing rows:
|
||||||
|
1. If session.unit_id is set, use roster.slm_model for that unit.
|
||||||
|
2. Else, peek at the first .rnd file in the session: presence of the 'LAeq'
|
||||||
|
column header identifies AU2 / NL-32 format.
|
||||||
|
Sessions where neither hint is available remain NULL — the file-content
|
||||||
|
fallback in report code handles them transparently.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path("data/seismo_fleet.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _peek_first_row(abs_path: Path) -> dict:
|
||||||
|
"""Read only the header + first data row of an RND file. Very cheap."""
|
||||||
|
try:
|
||||||
|
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
return next(reader, None) or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_model_from_rnd(abs_path: Path) -> str | None:
|
||||||
|
"""Return 'NL-32' if file uses AU2 column format, else None."""
|
||||||
|
row = _peek_first_row(abs_path)
|
||||||
|
if "LAeq" in row:
|
||||||
|
return "NL-32"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# ── 1. Add column (idempotent) ───────────────────────────────────────────
|
||||||
|
cur.execute("PRAGMA table_info(monitoring_sessions)")
|
||||||
|
existing_cols = {row["name"] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
if "device_model" not in existing_cols:
|
||||||
|
cur.execute("ALTER TABLE monitoring_sessions ADD COLUMN device_model TEXT")
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Added column device_model to monitoring_sessions")
|
||||||
|
else:
|
||||||
|
print("○ Column device_model already exists — skipping ALTER TABLE")
|
||||||
|
|
||||||
|
# ── 2. Backfill existing NULL rows ───────────────────────────────────────
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, unit_id FROM monitoring_sessions WHERE device_model IS NULL"
|
||||||
|
)
|
||||||
|
sessions = cur.fetchall()
|
||||||
|
print(f"Backfilling {len(sessions)} session(s) with device_model=NULL...")
|
||||||
|
|
||||||
|
updated = skipped = 0
|
||||||
|
for row in sessions:
|
||||||
|
session_id = row["id"]
|
||||||
|
unit_id = row["unit_id"]
|
||||||
|
device_model = None
|
||||||
|
|
||||||
|
# Strategy A: look up unit's slm_model from the roster
|
||||||
|
if unit_id:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT slm_model FROM roster WHERE id = ?", (unit_id,)
|
||||||
|
)
|
||||||
|
unit_row = cur.fetchone()
|
||||||
|
if unit_row and unit_row["slm_model"]:
|
||||||
|
device_model = unit_row["slm_model"]
|
||||||
|
|
||||||
|
# Strategy B: detect from first .rnd file in the session
|
||||||
|
if device_model is None:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT file_path FROM data_files
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND lower(file_path) LIKE '%.rnd'
|
||||||
|
LIMIT 1""",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
file_row = cur.fetchone()
|
||||||
|
if file_row:
|
||||||
|
abs_path = Path("data") / file_row["file_path"]
|
||||||
|
device_model = _detect_model_from_rnd(abs_path)
|
||||||
|
# None here means NL-43/NL-53 format (or unreadable file) —
|
||||||
|
# leave as NULL so the existing fallback applies.
|
||||||
|
|
||||||
|
if device_model:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE monitoring_sessions SET device_model = ? WHERE id = ?",
|
||||||
|
(device_model, session_id),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"✓ Backfilled {updated} session(s) with a device_model.")
|
||||||
|
if skipped:
|
||||||
|
print(
|
||||||
|
f" {skipped} session(s) left as NULL "
|
||||||
|
"(no unit link and no AU2 file hint — NL-43/NL-53 or unknown; "
|
||||||
|
"file-content detection applies at report time)."
|
||||||
|
)
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
131
backend/migrate_add_session_period_type.py
Normal file
131
backend/migrate_add_session_period_type.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: Add session_label and period_type columns to monitoring_sessions.
|
||||||
|
|
||||||
|
session_label - user-editable display name, e.g. "NRL-1 Sun 2/23 Night"
|
||||||
|
period_type - one of: weekday_day | weekday_night | weekend_day | weekend_night
|
||||||
|
Auto-derived from started_at when NULL.
|
||||||
|
|
||||||
|
Period definitions (used in report stats table):
|
||||||
|
weekday_day Mon-Fri 07:00-22:00 -> Daytime (7AM-10PM)
|
||||||
|
weekday_night Mon-Fri 22:00-07:00 -> Nighttime (10PM-7AM)
|
||||||
|
weekend_day Sat-Sun 07:00-22:00 -> Daytime (7AM-10PM)
|
||||||
|
weekend_night Sat-Sun 22:00-07:00 -> Nighttime (10PM-7AM)
|
||||||
|
|
||||||
|
Run once inside the Docker container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_session_period_type.py
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DB_PATH = Path("data/seismo_fleet.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_period_type(started_at_str: str) -> str | None:
|
||||||
|
"""Derive period_type from a started_at ISO datetime string."""
|
||||||
|
if not started_at_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(started_at_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
is_weekend = dt.weekday() >= 5 # 5=Sat, 6=Sun
|
||||||
|
is_night = dt.hour >= 22 or dt.hour < 7
|
||||||
|
if is_weekend:
|
||||||
|
return "weekend_night" if is_night else "weekend_day"
|
||||||
|
else:
|
||||||
|
return "weekday_night" if is_night else "weekday_day"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_label(started_at_str: str, location_name: str | None, period_type: str | None) -> str | None:
|
||||||
|
"""Build a human-readable session label."""
|
||||||
|
if not started_at_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(started_at_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
day_abbr = dt.strftime("%a") # Mon, Tue, Sun, etc.
|
||||||
|
date_str = dt.strftime("%-m/%-d") # 2/23
|
||||||
|
|
||||||
|
period_labels = {
|
||||||
|
"weekday_day": "Day",
|
||||||
|
"weekday_night": "Night",
|
||||||
|
"weekend_day": "Day",
|
||||||
|
"weekend_night": "Night",
|
||||||
|
}
|
||||||
|
period_str = period_labels.get(period_type or "", "")
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if location_name:
|
||||||
|
parts.append(location_name)
|
||||||
|
parts.append(f"{day_abbr} {date_str}")
|
||||||
|
if period_str:
|
||||||
|
parts.append(period_str)
|
||||||
|
return " — ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 1. Add columns (idempotent)
|
||||||
|
cur.execute("PRAGMA table_info(monitoring_sessions)")
|
||||||
|
existing_cols = {row["name"] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
for col, typedef in [("session_label", "TEXT"), ("period_type", "TEXT")]:
|
||||||
|
if col not in existing_cols:
|
||||||
|
cur.execute(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {typedef}")
|
||||||
|
conn.commit()
|
||||||
|
print(f"✓ Added column {col} to monitoring_sessions")
|
||||||
|
else:
|
||||||
|
print(f"○ Column {col} already exists — skipping ALTER TABLE")
|
||||||
|
|
||||||
|
# 2. Backfill existing rows
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT ms.id, ms.started_at, ms.location_id
|
||||||
|
FROM monitoring_sessions ms
|
||||||
|
WHERE ms.period_type IS NULL OR ms.session_label IS NULL"""
|
||||||
|
)
|
||||||
|
sessions = cur.fetchall()
|
||||||
|
print(f"Backfilling {len(sessions)} session(s)...")
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for row in sessions:
|
||||||
|
session_id = row["id"]
|
||||||
|
started_at = row["started_at"]
|
||||||
|
location_id = row["location_id"]
|
||||||
|
|
||||||
|
# Look up location name
|
||||||
|
location_name = None
|
||||||
|
if location_id:
|
||||||
|
cur.execute("SELECT name FROM monitoring_locations WHERE id = ?", (location_id,))
|
||||||
|
loc_row = cur.fetchone()
|
||||||
|
if loc_row:
|
||||||
|
location_name = loc_row["name"]
|
||||||
|
|
||||||
|
period_type = _derive_period_type(started_at)
|
||||||
|
label = _build_label(started_at, location_name, period_type)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE monitoring_sessions SET period_type = ?, session_label = ? WHERE id = ?",
|
||||||
|
(period_type, label, session_id),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"✓ Backfilled {updated} session(s).")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -157,6 +157,11 @@ class Project(Base):
|
|||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||||
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
||||||
|
|
||||||
|
# Data collection mode: how field data reaches Terra-View.
|
||||||
|
# "remote" — units have modems; data pulled via FTP/scheduler automatically
|
||||||
|
# "manual" — no modem; SD cards retrieved daily and uploaded by hand
|
||||||
|
data_collection_mode = Column(String, default="manual") # remote | manual
|
||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
site_address = Column(String, nullable=True)
|
site_address = Column(String, nullable=True)
|
||||||
@@ -257,12 +262,24 @@ class MonitoringSession(Base):
|
|||||||
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
||||||
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
|
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
|
||||||
|
|
||||||
|
# Physical device model that produced this session's data (e.g. "NL-43", "NL-53", "NL-32").
|
||||||
|
# Null for older records; report code falls back to file-content detection when null.
|
||||||
|
device_model = Column(String, nullable=True)
|
||||||
|
|
||||||
session_type = Column(String, nullable=False) # sound | vibration
|
session_type = Column(String, nullable=False) # sound | vibration
|
||||||
started_at = Column(DateTime, nullable=False)
|
started_at = Column(DateTime, nullable=False)
|
||||||
stopped_at = Column(DateTime, nullable=True)
|
stopped_at = Column(DateTime, nullable=True)
|
||||||
duration_seconds = Column(Integer, nullable=True)
|
duration_seconds = Column(Integer, nullable=True)
|
||||||
status = Column(String, default="recording") # recording, completed, failed
|
status = Column(String, default="recording") # recording, completed, failed
|
||||||
|
|
||||||
|
# Human-readable label auto-derived from date/location, editable by user.
|
||||||
|
# e.g. "NRL-1 — Sun 2/23 — Night"
|
||||||
|
session_label = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Period classification for report stats columns.
|
||||||
|
# weekday_day | weekday_night | weekend_day | weekend_night
|
||||||
|
period_type = Column(String, nullable=True)
|
||||||
|
|
||||||
# Snapshot of device configuration at recording time
|
# Snapshot of device configuration at recording time
|
||||||
session_metadata = Column(Text, nullable=True) # JSON
|
session_metadata = Column(Text, nullable=True) # JSON
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,39 @@ from backend.templates_config import templates
|
|||||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Session period helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _derive_period_type(dt: datetime) -> str:
|
||||||
|
"""
|
||||||
|
Classify a session start time into one of four period types.
|
||||||
|
Night = 22:00–07:00, Day = 07:00–22:00.
|
||||||
|
Weekend = Saturday (5) or Sunday (6).
|
||||||
|
"""
|
||||||
|
is_weekend = dt.weekday() >= 5
|
||||||
|
is_night = dt.hour >= 22 or dt.hour < 7
|
||||||
|
if is_weekend:
|
||||||
|
return "weekend_night" if is_night else "weekend_day"
|
||||||
|
return "weekday_night" if is_night else "weekday_day"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
|
||||||
|
"""Build a human-readable session label, e.g. 'NRL-1 — Sun 2/23 — Night'.
|
||||||
|
Uses started_at date as-is; user can correct period_type in the wizard.
|
||||||
|
"""
|
||||||
|
day_abbr = dt.strftime("%a")
|
||||||
|
date_str = f"{dt.month}/{dt.day}"
|
||||||
|
period_str = {
|
||||||
|
"weekday_day": "Day",
|
||||||
|
"weekday_night": "Night",
|
||||||
|
"weekend_day": "Day",
|
||||||
|
"weekend_night": "Night",
|
||||||
|
}.get(period_type, "")
|
||||||
|
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
|
||||||
|
return " — ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Monitoring Locations CRUD
|
# Monitoring Locations CRUD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -634,6 +667,30 @@ async def upload_nrl_data(
|
|||||||
if not file_entries:
|
if not file_entries:
|
||||||
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
||||||
|
|
||||||
|
# --- Step 1b: Filter to only relevant files ---
|
||||||
|
# Keep: .rnh (metadata) and measurement .rnd files
|
||||||
|
# NL-43 generates two .rnd types: _Leq_ (15-min averages, wanted) and _Lp_ (1-sec granular, skip)
|
||||||
|
# AU2 (NL-23/older Rion) generates a single Au2_####.rnd per session — always keep those
|
||||||
|
# Drop: _Lp_ .rnd, .xlsx, .mp3, and anything else
|
||||||
|
def _is_wanted(fname: str) -> bool:
|
||||||
|
n = fname.lower()
|
||||||
|
if n.endswith(".rnh"):
|
||||||
|
return True
|
||||||
|
if n.endswith(".rnd"):
|
||||||
|
if "_leq_" in n: # NL-43 Leq file
|
||||||
|
return True
|
||||||
|
if n.startswith("au2_"): # AU2 format (NL-23) — always Leq equivalent
|
||||||
|
return True
|
||||||
|
if "_lp" not in n and "_leq_" not in n:
|
||||||
|
# Unknown .rnd format — include it so we don't silently drop data
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
file_entries = [(fname, fbytes) for fname, fbytes in file_entries if _is_wanted(fname)]
|
||||||
|
|
||||||
|
if not file_entries:
|
||||||
|
raise HTTPException(status_code=400, detail="No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files.")
|
||||||
|
|
||||||
# --- Step 2: Find and parse .rnh metadata ---
|
# --- Step 2: Find and parse .rnh metadata ---
|
||||||
rnh_meta = {}
|
rnh_meta = {}
|
||||||
for fname, fbytes in file_entries:
|
for fname, fbytes in file_entries:
|
||||||
@@ -652,6 +709,9 @@ async def upload_nrl_data(
|
|||||||
index_number = rnh_meta.get("index_number", "")
|
index_number = rnh_meta.get("index_number", "")
|
||||||
|
|
||||||
# --- Step 3: Create MonitoringSession ---
|
# --- Step 3: Create MonitoringSession ---
|
||||||
|
period_type = _derive_period_type(started_at) if started_at else None
|
||||||
|
session_label = _build_session_label(started_at, location.name, period_type) if started_at else None
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
monitoring_session = MonitoringSession(
|
monitoring_session = MonitoringSession(
|
||||||
id=session_id,
|
id=session_id,
|
||||||
@@ -663,6 +723,8 @@ async def upload_nrl_data(
|
|||||||
stopped_at=stopped_at,
|
stopped_at=stopped_at,
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
status="completed",
|
status="completed",
|
||||||
|
session_label=session_label,
|
||||||
|
period_type=period_type,
|
||||||
session_metadata=json.dumps({
|
session_metadata=json.dumps({
|
||||||
"source": "manual_upload",
|
"source": "manual_upload",
|
||||||
"store_name": store_name,
|
"store_name": store_name,
|
||||||
@@ -736,3 +798,67 @@ async def upload_nrl_data(
|
|||||||
"started_at": started_at.isoformat() if started_at else None,
|
"started_at": started_at.isoformat() if started_at else None,
|
||||||
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# NRL Live Status (connected NRLs only)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/nrl/{location_id}/live-status", response_class=HTMLResponse)
|
||||||
|
async def get_nrl_live_status(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fetch cached status from SLMM for the unit assigned to this NRL and
|
||||||
|
return a compact HTML status card. Used in the NRL overview tab for
|
||||||
|
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Find the assigned unit
|
||||||
|
assignment = db.query(UnitAssignment).filter(
|
||||||
|
and_(
|
||||||
|
UnitAssignment.location_id == location_id,
|
||||||
|
UnitAssignment.status == "active",
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
|
||||||
|
"request": request,
|
||||||
|
"status": None,
|
||||||
|
"error": "No unit assigned",
|
||||||
|
})
|
||||||
|
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
|
||||||
|
"request": request,
|
||||||
|
"status": None,
|
||||||
|
"error": "Assigned unit not found",
|
||||||
|
})
|
||||||
|
|
||||||
|
slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
status_data = None
|
||||||
|
error_msg = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(f"{slmm_base}/api/nl43/{unit.id}/status")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
status_data = resp.json()
|
||||||
|
else:
|
||||||
|
error_msg = f"SLMM returned {resp.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = "SLMM unreachable"
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit": unit,
|
||||||
|
"status": status_data,
|
||||||
|
"error": error_msg,
|
||||||
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ All routers should import `templates` from this module to get consistent
|
|||||||
filter and global function registration.
|
filter and global function registration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json as _json
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
# Import timezone utilities
|
# Import timezone utilities
|
||||||
@@ -32,8 +33,38 @@ def jinja_timezone_abbr():
|
|||||||
# Create templates instance
|
# Create templates instance
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
def jinja_local_date(dt, fmt="%m-%d-%y"):
|
||||||
|
"""Jinja filter: format a UTC datetime as a local date string (e.g. 02-19-26)."""
|
||||||
|
return format_local_datetime(dt, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_fromjson(s):
|
||||||
|
"""Jinja filter: parse a JSON string into a dict (returns {} on failure)."""
|
||||||
|
if not s:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return _json.loads(s)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_same_date(dt1, dt2) -> bool:
|
||||||
|
"""Jinja global: True if two datetimes fall on the same local date."""
|
||||||
|
if not dt1 or not dt2:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
d1 = format_local_datetime(dt1, "%Y-%m-%d")
|
||||||
|
d2 = format_local_datetime(dt2, "%Y-%m-%d")
|
||||||
|
return d1 == d2
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Register Jinja filters and globals
|
# Register Jinja filters and globals
|
||||||
templates.env.filters["local_datetime"] = jinja_local_datetime
|
templates.env.filters["local_datetime"] = jinja_local_datetime
|
||||||
templates.env.filters["local_time"] = jinja_local_time
|
templates.env.filters["local_time"] = jinja_local_time
|
||||||
|
templates.env.filters["local_date"] = jinja_local_date
|
||||||
|
templates.env.filters["fromjson"] = jinja_fromjson
|
||||||
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
||||||
templates.env.globals["get_user_timezone"] = get_user_timezone
|
templates.env.globals["get_user_timezone"] = get_user_timezone
|
||||||
|
templates.env.globals["same_date"] = jinja_same_date
|
||||||
|
|||||||
19
rebuild-dev.sh
Executable file
19
rebuild-dev.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Dev rebuild script — increments build number, rebuilds and restarts terra-view
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BUILD_FILE="$SCRIPT_DIR/build_number.txt"
|
||||||
|
|
||||||
|
# Read and increment build number
|
||||||
|
BUILD_NUMBER=$(cat "$BUILD_FILE" 2>/dev/null || echo "0")
|
||||||
|
BUILD_NUMBER=$((BUILD_NUMBER + 1))
|
||||||
|
echo "$BUILD_NUMBER" > "$BUILD_FILE"
|
||||||
|
|
||||||
|
echo "Building terra-view dev (build #$BUILD_NUMBER)..."
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
docker compose build --build-arg BUILD_NUMBER="$BUILD_NUMBER" terra-view
|
||||||
|
docker compose up -d terra-view
|
||||||
|
|
||||||
|
echo "Done — terra-view v0.6.1-$BUILD_NUMBER is running on :1001"
|
||||||
12
rebuild-prod.sh
Normal file
12
rebuild-prod.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Production rebuild script — rebuilds and restarts terra-view on :8001
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "Building terra-view production..."
|
||||||
|
docker compose -f docker-compose.yml build terra-view
|
||||||
|
docker compose -f docker-compose.yml up -d terra-view
|
||||||
|
|
||||||
|
echo "Done — terra-view production is running on :8001"
|
||||||
315
templates/combined_report_preview.html
Normal file
315
templates/combined_report_preview.html
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Combined Report Preview - {{ project.name }}{% 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">Combined Report Preview & Editor</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{{ location_data|length }} location{{ 's' if location_data|length != 1 else '' }}
|
||||||
|
{% if time_filter_desc %} | {{ time_filter_desc }}{% endif %}
|
||||||
|
| {{ total_rows }} total row{{ 's' if total_rows != 1 else '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="downloadCombinedReport()" id="download-btn"
|
||||||
|
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm font-medium">
|
||||||
|
<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>
|
||||||
|
Generate Reports (ZIP)
|
||||||
|
</button>
|
||||||
|
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
|
||||||
|
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 text-sm">
|
||||||
|
← Back to Config
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
|
||||||
|
|
||||||
|
<!-- Report Metadata -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location Tabs + Spreadsheet -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||||
|
<div class="flex min-w-max" id="tab-bar">
|
||||||
|
{% for loc in location_data %}
|
||||||
|
<button onclick="switchTab({{ loop.index0 }})"
|
||||||
|
id="tab-btn-{{ loop.index0 }}"
|
||||||
|
class="tab-btn px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
||||||
|
{% if loop.first %}border-emerald-500 text-emerald-600 dark:text-emerald-400
|
||||||
|
{% else %}border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300{% endif %}">
|
||||||
|
{{ loc.location_name }}
|
||||||
|
<span class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full
|
||||||
|
{% if loop.first %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400
|
||||||
|
{% else %}bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400{% endif %}"
|
||||||
|
id="tab-count-{{ loop.index0 }}">
|
||||||
|
{{ loc.filtered_count }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spreadsheet Panels -->
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white" id="active-tab-title">
|
||||||
|
{{ location_data[0].location_name if location_data else '' }}
|
||||||
|
</h3>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% for loc in location_data %}
|
||||||
|
<div id="panel-{{ loop.index0 }}" class="tab-panel {% if not loop.first %}hidden{% endif %} overflow-x-auto">
|
||||||
|
<div id="spreadsheet-{{ loop.index0 }}"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help -->
|
||||||
|
<div class="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 insert or delete rows</li>
|
||||||
|
<li>Press Enter to confirm edits, Escape to cancel</li>
|
||||||
|
<li>Switch between location tabs to edit each location's data independently</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>
|
||||||
|
const allLocationData = {{ locations_json | safe }};
|
||||||
|
const spreadsheets = {};
|
||||||
|
let activeTabIdx = 0;
|
||||||
|
|
||||||
|
const columnDef = [
|
||||||
|
{ title: 'Test #', width: 80, type: 'numeric' },
|
||||||
|
{ title: 'Date', width: 110, type: 'text' },
|
||||||
|
{ title: 'Time', width: 90, type: 'text' },
|
||||||
|
{ title: 'LAmax (dBA)', width: 110, type: 'numeric' },
|
||||||
|
{ title: 'LA01 (dBA)', width: 110, type: 'numeric' },
|
||||||
|
{ title: 'LA10 (dBA)', width: 110, type: 'numeric' },
|
||||||
|
{ title: 'Comments', width: 250, type: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const jssOptions = {
|
||||||
|
columns: columnDef,
|
||||||
|
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;',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
allLocationData.forEach(function(loc, idx) {
|
||||||
|
const el = document.getElementById('spreadsheet-' + idx);
|
||||||
|
if (!el) return;
|
||||||
|
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
|
||||||
|
spreadsheets[idx] = jspreadsheet(el, opts);
|
||||||
|
});
|
||||||
|
if (allLocationData.length > 0) {
|
||||||
|
switchTab(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function switchTab(idx) {
|
||||||
|
activeTabIdx = idx;
|
||||||
|
|
||||||
|
// Update panels
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(function(panel, i) {
|
||||||
|
panel.classList.toggle('hidden', i !== idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tab button styles
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(function(btn, i) {
|
||||||
|
const countBadge = document.getElementById('tab-count-' + i);
|
||||||
|
if (i === idx) {
|
||||||
|
btn.classList.add('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
|
||||||
|
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||||
|
if (countBadge) {
|
||||||
|
countBadge.classList.add('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
|
||||||
|
countBadge.classList.remove('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
|
||||||
|
btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||||
|
if (countBadge) {
|
||||||
|
countBadge.classList.remove('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
|
||||||
|
countBadge.classList.add('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update title
|
||||||
|
if (allLocationData[idx]) {
|
||||||
|
document.getElementById('active-tab-title').textContent = allLocationData[idx].location_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh jspreadsheet rendering after showing panel
|
||||||
|
if (spreadsheets[idx]) {
|
||||||
|
try { spreadsheets[idx].updateTable(); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadCombinedReport() {
|
||||||
|
const btn = document.getElementById('download-btn');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating ZIP...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locations = allLocationData.map(function(loc, idx) {
|
||||||
|
return {
|
||||||
|
session_id: loc.session_id || '',
|
||||||
|
session_label: loc.session_label || '',
|
||||||
|
period_type: loc.period_type || '',
|
||||||
|
started_at: loc.started_at || '',
|
||||||
|
location_name: loc.location_name,
|
||||||
|
spreadsheet_data: spreadsheets[idx] ? spreadsheets[idx].getData() : loc.spreadsheet_data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
report_title: document.getElementById('edit-report-title').value || 'Background Noise Study',
|
||||||
|
project_name: document.getElementById('edit-project-name').value || '',
|
||||||
|
client_name: document.getElementById('edit-client-name').value || '',
|
||||||
|
locations: locations,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/projects/{{ project_id }}/generate-combined-from-preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = 'combined_reports.zip';
|
||||||
|
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);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Dark mode jspreadsheet styles */
|
||||||
|
.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; }
|
||||||
|
.jexcel_content { max-height: 600px; overflow: auto; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
393
templates/combined_report_wizard.html
Normal file
393
templates/combined_report_wizard.html
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Combined Report Wizard - {{ project.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<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-4xl 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">Combined Report Wizard</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ project.name }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="/projects/{{ project_id }}"
|
||||||
|
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 text-sm w-fit">
|
||||||
|
← Back to Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||||
|
|
||||||
|
<!-- Report Settings Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Report Settings</h2>
|
||||||
|
|
||||||
|
<!-- Template Selection -->
|
||||||
|
<div class="flex items-end gap-2 mb-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Load Template
|
||||||
|
</label>
|
||||||
|
<select id="template-select" onchange="applyTemplate()"
|
||||||
|
class="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="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 class="mb-4">
|
||||||
|
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Report Title
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-title" value="Background Noise Study"
|
||||||
|
class="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 -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Project Name
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-project" value="{{ project.name }}"
|
||||||
|
class="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 mb-1">
|
||||||
|
Client Name
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-client" value="{{ project.client_name if project.client_name else '' }}"
|
||||||
|
class="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>
|
||||||
|
|
||||||
|
<!-- Sessions Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||||
|
<div class="flex gap-3 text-sm">
|
||||||
|
<button type="button" onclick="selectAllSessions()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button>
|
||||||
|
<button type="button" onclick="deselectAllSessions()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<span id="selected-count">0</span> session(s) selected — each selected session becomes one sheet in the ZIP.
|
||||||
|
Change the period type per session to control how stats are bucketed (Day vs Night).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if locations %}
|
||||||
|
{% for loc in locations %}
|
||||||
|
{% set loc_name = loc.name %}
|
||||||
|
{% set sessions = loc.sessions %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg mb-3 overflow-hidden">
|
||||||
|
<!-- Location header / toggle -->
|
||||||
|
<button type="button"
|
||||||
|
onclick="toggleLocation('loc-{{ loop.index }}')"
|
||||||
|
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-slate-700/50 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-left">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg id="chevron-loc-{{ loop.index }}" class="w-4 h-4 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ loc_name }}</span>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ sessions|length }} session{{ 's' if sessions|length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs" onclick="event.stopPropagation()">
|
||||||
|
<button type="button" onclick="selectLocation('loc-{{ loop.index }}')"
|
||||||
|
class="text-emerald-600 dark:text-emerald-400 hover:underline">All</button>
|
||||||
|
<button type="button" onclick="deselectLocation('loc-{{ loop.index }}')"
|
||||||
|
class="text-gray-400 hover:underline">None</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Session rows -->
|
||||||
|
<div id="loc-{{ loop.index }}" class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
|
{% for s in sessions %}
|
||||||
|
{% set pt_colors = {
|
||||||
|
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
} %}
|
||||||
|
{% set pt_labels = {
|
||||||
|
'weekday_day': 'Weekday Day',
|
||||||
|
'weekday_night': 'Weekday Night',
|
||||||
|
'weekend_day': 'Weekend Day',
|
||||||
|
'weekend_night': 'Weekend Night',
|
||||||
|
} %}
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<input type="checkbox"
|
||||||
|
class="session-cb loc-{{ loop.index }}-cb h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500 shrink-0"
|
||||||
|
value="{{ s.session_id }}"
|
||||||
|
checked
|
||||||
|
onchange="updateSelectionStats()">
|
||||||
|
|
||||||
|
<!-- Date/day info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ s.day_of_week }} {{ s.date_display }}
|
||||||
|
</span>
|
||||||
|
{% if s.session_label %}
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 truncate">{{ s.session_label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if s.status == 'recording' %}
|
||||||
|
<span class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||||
|
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{% if s.started_at %}
|
||||||
|
<span>{{ s.started_at }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if s.duration_h is not none %}
|
||||||
|
<span>{{ s.duration_h }}h {{ s.duration_m }}m</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Period type dropdown -->
|
||||||
|
<div class="relative shrink-0" id="wiz-period-wrap-{{ s.session_id }}">
|
||||||
|
<button type="button"
|
||||||
|
onclick="toggleWizPeriodMenu('{{ s.session_id }}')"
|
||||||
|
id="wiz-period-badge-{{ s.session_id }}"
|
||||||
|
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ pt_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||||
|
title="Click to change period type">
|
||||||
|
<span id="wiz-period-label-{{ s.session_id }}">{{ pt_labels.get(s.period_type, 'Set period') }}</span>
|
||||||
|
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="wiz-period-menu-{{ s.session_id }}"
|
||||||
|
class="hidden absolute right-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
|
||||||
|
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
|
||||||
|
<button type="button"
|
||||||
|
onclick="setWizPeriodType('{{ s.session_id }}', '{{ pt }}')"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
|
||||||
|
{{ pt_label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-10 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No monitoring sessions found.</p>
|
||||||
|
<p class="text-sm mt-1">Upload data files to create sessions first.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
|
||||||
|
<a href="/projects/{{ project_id }}"
|
||||||
|
class="w-full sm:w-auto px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors text-center text-sm font-medium">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="button" onclick="gotoPreview()" id="preview-btn"
|
||||||
|
{% if not locations %}disabled{% endif %}
|
||||||
|
class="w-full sm:w-auto px-6 py-2.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PROJECT_ID = '{{ project_id }}';
|
||||||
|
|
||||||
|
const PERIOD_COLORS = {
|
||||||
|
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
};
|
||||||
|
const PERIOD_LABELS = {
|
||||||
|
weekday_day: 'Weekday Day',
|
||||||
|
weekday_night: 'Weekday Night',
|
||||||
|
weekend_day: 'Weekend Day',
|
||||||
|
weekend_night: 'Weekend Night',
|
||||||
|
};
|
||||||
|
const ALL_PERIOD_BADGE_CLASSES = [
|
||||||
|
'bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400',
|
||||||
|
...new Set(Object.values(PERIOD_COLORS).flatMap(s => s.split(' ')))
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Location accordion ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleLocation(locId) {
|
||||||
|
const body = document.getElementById(locId);
|
||||||
|
const chevron = document.getElementById('chevron-' + locId);
|
||||||
|
body.classList.toggle('hidden');
|
||||||
|
chevron.style.transform = body.classList.contains('hidden') ? 'rotate(-90deg)' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocation(locId) {
|
||||||
|
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = true);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectLocation(locId) {
|
||||||
|
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = false);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllSessions() {
|
||||||
|
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = true);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAllSessions() {
|
||||||
|
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = false);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionStats() {
|
||||||
|
const count = document.querySelectorAll('.session-cb:checked').length;
|
||||||
|
document.getElementById('selected-count').textContent = count;
|
||||||
|
document.getElementById('preview-btn').disabled = count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period type dropdown (wizard) ─────────────────────────────────
|
||||||
|
|
||||||
|
function toggleWizPeriodMenu(sessionId) {
|
||||||
|
const menu = document.getElementById('wiz-period-menu-' + sessionId);
|
||||||
|
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => {
|
||||||
|
if (m.id !== 'wiz-period-menu-' + sessionId) m.classList.add('hidden');
|
||||||
|
});
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('[id^="wiz-period-wrap-"]')) {
|
||||||
|
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setWizPeriodType(sessionId, periodType) {
|
||||||
|
document.getElementById('wiz-period-menu-' + sessionId).classList.add('hidden');
|
||||||
|
const badge = document.getElementById('wiz-period-badge-' + sessionId);
|
||||||
|
badge.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({period_type: periodType}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
ALL_PERIOD_BADGE_CLASSES.forEach(c => badge.classList.remove(c));
|
||||||
|
const colorStr = PERIOD_COLORS[periodType] || 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
badge.classList.add(...colorStr.split(' ').filter(Boolean));
|
||||||
|
document.getElementById('wiz-period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
|
||||||
|
} catch(err) {
|
||||||
|
alert('Failed to update period type: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
badge.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template management ───────────────────────────────────────────
|
||||||
|
|
||||||
|
let reportTemplates = [];
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/report-templates?project_id=' + PROJECT_ID);
|
||||||
|
if (resp.ok) {
|
||||||
|
reportTemplates = await resp.json();
|
||||||
|
populateTemplateDropdown();
|
||||||
|
}
|
||||||
|
} catch(e) { console.error('Error loading templates:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateTemplateDropdown() {
|
||||||
|
const select = document.getElementById('template-select');
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '<option value="">-- Select a template --</option>';
|
||||||
|
reportTemplates.forEach(t => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.id;
|
||||||
|
opt.textContent = t.name;
|
||||||
|
opt.dataset.config = JSON.stringify(t);
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate() {
|
||||||
|
const select = document.getElementById('template-select');
|
||||||
|
const opt = select.options[select.selectedIndex];
|
||||||
|
if (!opt.value) return;
|
||||||
|
const t = JSON.parse(opt.dataset.config);
|
||||||
|
if (t.report_title) document.getElementById('report-title').value = t.report_title;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAsTemplate() {
|
||||||
|
const name = prompt('Enter a name for this template:');
|
||||||
|
if (!name) return;
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
project_id: PROJECT_ID,
|
||||||
|
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/report-templates', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (resp.ok) { alert('Template saved!'); loadTemplates(); }
|
||||||
|
else alert('Failed to save template');
|
||||||
|
} catch(e) { alert('Error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigate to preview ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function gotoPreview() {
|
||||||
|
const checked = Array.from(document.querySelectorAll('.session-cb:checked')).map(cb => cb.value);
|
||||||
|
if (checked.length === 0) {
|
||||||
|
alert('Please select at least one session.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||||||
|
project_name: document.getElementById('report-project').value || '',
|
||||||
|
client_name: document.getElementById('report-client').value || '',
|
||||||
|
selected_sessions: checked.join(','),
|
||||||
|
});
|
||||||
|
window.location.href = `/api/projects/${PROJECT_ID}/combined-report-preview?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateSelectionStats();
|
||||||
|
loadTemplates();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit and connection_mode == 'connected' %}
|
||||||
<button onclick="switchTab('command')"
|
<button onclick="switchTab('command')"
|
||||||
data-tab="command"
|
data-tab="command"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
@@ -214,23 +214,54 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
{% if connection_mode == 'connected' %}
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
|
||||||
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
|
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
{% if active_session %}
|
{% if active_session %}
|
||||||
<span class="text-green-600 dark:text-green-400">Recording</span>
|
<span class="text-green-600 dark:text-green-400">Monitoring</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-gray-500">Idle</span>
|
<span class="text-gray-500">Idle</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Mode</p>
|
||||||
|
<p class="text-lg font-semibold mt-2">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">Offline / Manual</span>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
{% if connection_mode == 'connected' %}
|
||||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" 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>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if connection_mode == 'connected' and assigned_unit %}
|
||||||
|
<!-- Live Status Row (connected NRLs only) -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Live Status</h3>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ assigned_unit.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div id="nrl-live-status"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/live-status"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-4 text-gray-500 text-sm">Loading status…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
@@ -281,8 +312,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Command Center Tab -->
|
<!-- Command Center Tab (connected NRLs only) -->
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit and connection_mode == 'connected' %}
|
||||||
<div id="command-tab" class="tab-panel hidden">
|
<div id="command-tab" class="tab-panel hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
@@ -354,16 +385,27 @@
|
|||||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||||
<div class="flex items-center gap-3 mt-3">
|
<div class="flex items-center gap-3 mt-3">
|
||||||
<button onclick="submitUpload()"
|
<button id="upload-btn" onclick="submitUpload()"
|
||||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
Import Files
|
Import Files
|
||||||
</button>
|
</button>
|
||||||
<button onclick="toggleUploadPanel()"
|
<button id="upload-cancel-btn" onclick="toggleUploadPanel()"
|
||||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<span id="upload-status" class="text-sm hidden"></span>
|
<span id="upload-status" class="text-sm hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Progress bar (hidden until upload starts) -->
|
||||||
|
<div id="upload-progress-wrap" class="hidden mt-3">
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
<span id="upload-progress-label">Uploading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div id="upload-progress-bar"
|
||||||
|
class="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="data-files-list"
|
<div id="data-files-list"
|
||||||
@@ -598,39 +640,73 @@ function toggleUploadPanel() {
|
|||||||
const panel = document.getElementById('upload-panel');
|
const panel = document.getElementById('upload-panel');
|
||||||
const status = document.getElementById('upload-status');
|
const status = document.getElementById('upload-status');
|
||||||
panel.classList.toggle('hidden');
|
panel.classList.toggle('hidden');
|
||||||
// Reset status when reopening
|
// Reset state when reopening
|
||||||
if (!panel.classList.contains('hidden')) {
|
if (!panel.classList.contains('hidden')) {
|
||||||
status.textContent = '';
|
status.textContent = '';
|
||||||
status.className = 'text-sm hidden';
|
status.className = 'text-sm hidden';
|
||||||
document.getElementById('upload-input').value = '';
|
document.getElementById('upload-input').value = '';
|
||||||
|
document.getElementById('upload-progress-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('upload-progress-bar').style.width = '0%';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitUpload() {
|
function submitUpload() {
|
||||||
const input = document.getElementById('upload-input');
|
const input = document.getElementById('upload-input');
|
||||||
const status = document.getElementById('upload-status');
|
const status = document.getElementById('upload-status');
|
||||||
|
const btn = document.getElementById('upload-btn');
|
||||||
|
const cancelBtn = document.getElementById('upload-cancel-btn');
|
||||||
|
const progressWrap = document.getElementById('upload-progress-wrap');
|
||||||
|
const progressBar = document.getElementById('upload-progress-bar');
|
||||||
|
const progressLabel = document.getElementById('upload-progress-label');
|
||||||
|
|
||||||
if (!input.files.length) {
|
if (!input.files.length) {
|
||||||
alert('Please select files to upload.');
|
alert('Please select files to upload.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileCount = input.files.length;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
formData.append('files', file);
|
formData.append('files', file);
|
||||||
}
|
}
|
||||||
|
|
||||||
status.textContent = 'Uploading\u2026';
|
// Disable controls and show progress bar
|
||||||
status.className = 'text-sm text-gray-500';
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading\u2026';
|
||||||
|
btn.classList.add('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
|
||||||
|
status.className = 'text-sm hidden';
|
||||||
|
progressWrap.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026`;
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const pct = Math.round((e.loaded / e.total) * 100);
|
||||||
|
progressBar.style.width = pct + '%';
|
||||||
|
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026 ${pct}%`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('load', () => {
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
progressLabel.textContent = 'Processing files on server\u2026';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Import Files';
|
||||||
|
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const data = JSON.parse(xhr.responseText);
|
||||||
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
{ method: 'POST', body: formData }
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
||||||
if (data.leq_files || data.lp_files) {
|
if (data.leq_files || data.lp_files) {
|
||||||
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
||||||
@@ -639,16 +715,30 @@ async function submitUpload() {
|
|||||||
status.textContent = parts.join(' ');
|
status.textContent = parts.join(' ');
|
||||||
status.className = 'text-sm text-green-600 dark:text-green-400';
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
input.value = '';
|
input.value = '';
|
||||||
// Refresh the file list
|
|
||||||
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
||||||
} else {
|
} else {
|
||||||
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
status.textContent = `Error: ${err.message}`;
|
status.textContent = 'Error: Unexpected server response';
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Import Files';
|
||||||
|
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
|
||||||
|
status.textContent = 'Error: Network error during upload';
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/projects/${projectId}/nrl/${locationId}/upload-data`);
|
||||||
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -75,6 +75,32 @@ Include this modal in pages that use the project picker.
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Data Collection <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange bg-orange-50 dark:bg-orange-900/20 rounded-lg cursor-pointer" id="qcp-mode-manual-label">
|
||||||
|
<input type="radio" name="data_collection_mode" value="manual" checked
|
||||||
|
onchange="qcpUpdateModeStyles()"
|
||||||
|
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Manual</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">SD card retrieved daily</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="qcp-mode-remote-label">
|
||||||
|
<input type="radio" name="data_collection_mode" value="remote"
|
||||||
|
onchange="qcpUpdateModeStyles()"
|
||||||
|
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Remote</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Modem, data pulled via FTP</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
<div id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,6 +124,24 @@ Include this modal in pages that use the project picker.
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function qcpUpdateModeStyles() {
|
||||||
|
const manualChecked = document.querySelector('input[name="data_collection_mode"][value="manual"]')?.checked;
|
||||||
|
const manualLabel = document.getElementById('qcp-mode-manual-label');
|
||||||
|
const remoteLabel = document.getElementById('qcp-mode-remote-label');
|
||||||
|
if (!manualLabel || !remoteLabel) return;
|
||||||
|
if (manualChecked) {
|
||||||
|
manualLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
manualLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
remoteLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
remoteLabel.classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
} else {
|
||||||
|
remoteLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
remoteLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
manualLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
manualLabel.classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Quick create project modal functions
|
// Quick create project modal functions
|
||||||
if (typeof openCreateProjectModal === 'undefined') {
|
if (typeof openCreateProjectModal === 'undefined') {
|
||||||
function openCreateProjectModal(searchQuery, pickerId = '') {
|
function openCreateProjectModal(searchQuery, pickerId = '') {
|
||||||
@@ -113,6 +157,7 @@ if (typeof openCreateProjectModal === 'undefined') {
|
|||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
document.getElementById('quickCreateProjectForm').reset();
|
document.getElementById('quickCreateProjectForm').reset();
|
||||||
|
qcpUpdateModeStyles();
|
||||||
if (errorDiv) errorDiv.classList.add('hidden');
|
if (errorDiv) errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
// Try to parse the search query intelligently
|
// Try to parse the search query intelligently
|
||||||
|
|||||||
89
templates/partials/projects/nrl_live_status.html
Normal file
89
templates/partials/projects/nrl_live_status.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<!-- Live Status Card content for connected NRLs -->
|
||||||
|
{% if error and not status %}
|
||||||
|
<div class="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% elif status %}
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
|
||||||
|
<!-- Measurement State -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">State</span>
|
||||||
|
{% set state = status.get('measurement_state', 'unknown') if status is mapping else 'unknown' %}
|
||||||
|
{% if state in ('measuring', 'recording') %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-green-600 dark:text-green-400">
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||||
|
Measuring
|
||||||
|
</span>
|
||||||
|
{% elif state == 'paused' %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-600 dark:text-yellow-400">
|
||||||
|
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
{% elif state == 'stopped' %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
|
||||||
|
Stopped
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400 capitalize">{{ state }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lp (instantaneous) -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Lp (dB)</span>
|
||||||
|
{% set lp = status.get('lp') if status is mapping else None %}
|
||||||
|
<span class="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{% if lp is not none %}{{ "%.1f"|format(lp) }}{% else %}—{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Battery -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Battery</span>
|
||||||
|
{% set batt = status.get('battery_level') if status is mapping else None %}
|
||||||
|
{% if batt is not none %}
|
||||||
|
<span class="text-sm font-semibold
|
||||||
|
{% if batt >= 60 %}text-green-600 dark:text-green-400
|
||||||
|
{% elif batt >= 30 %}text-yellow-600 dark:text-yellow-400
|
||||||
|
{% else %}text-red-600 dark:text-red-400{% endif %}">
|
||||||
|
{{ batt }}%
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-500">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Seen -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Seen</span>
|
||||||
|
{% set last_seen = status.get('last_seen') if status is mapping else None %}
|
||||||
|
{% if last_seen %}
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ last_seen|local_datetime }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-500">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if unit %}
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Unit: {{ unit.id }}
|
||||||
|
{% if unit.slm_model %} • {{ unit.slm_model }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<a href="/slm/{{ unit.id }}"
|
||||||
|
class="text-xs text-seismo-orange hover:text-seismo-navy transition-colors">
|
||||||
|
Open Unit →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">No status data available.</div>
|
||||||
|
{% endif %}
|
||||||
@@ -12,12 +12,27 @@
|
|||||||
{% if project_type %}
|
{% if project_type %}
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if project.data_collection_mode == 'remote' %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
|
<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="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>
|
||||||
|
Remote
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||||||
|
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
|
||||||
|
</svg>
|
||||||
|
Manual
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Project Actions -->
|
<!-- Project Actions -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||||
<a href="/api/projects/{{ project.id }}/generate-combined-report"
|
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
|
||||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
|||||||
@@ -1,79 +1,149 @@
|
|||||||
<!-- Monitoring Sessions List -->
|
<!-- Monitoring Sessions List -->
|
||||||
{% if sessions %}
|
{% if sessions %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
{% for item in sessions %}
|
{% for item in sessions %}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
{% set s = item.session %}
|
||||||
<div class="flex items-start justify-between gap-3">
|
{% set loc = item.location %}
|
||||||
|
{% set unit = item.unit %}
|
||||||
|
|
||||||
|
{# Period display maps #}
|
||||||
|
{% set period_labels = {
|
||||||
|
'weekday_day': 'Weekday Day',
|
||||||
|
'weekday_night': 'Weekday Night',
|
||||||
|
'weekend_day': 'Weekend Day',
|
||||||
|
'weekend_night': 'Weekend Night',
|
||||||
|
} %}
|
||||||
|
{% set period_colors = {
|
||||||
|
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||||
|
id="session-card-{{ s.id }}">
|
||||||
|
<div class="flex items-start justify-between gap-3 p-4 pb-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
<!-- Label + badges -->
|
||||||
Session {{ item.session.id[:8] }}...
|
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||||
</h4>
|
<span id="label-display-{{ s.id }}"
|
||||||
{% if item.session.status == 'recording' %}
|
class="font-semibold text-gray-900 dark:text-white text-sm cursor-pointer hover:text-seismo-orange"
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center">
|
title="Click to edit label"
|
||||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span>
|
onclick="startEditLabel('{{ s.id }}')">
|
||||||
Recording
|
{{ s.session_label or ('Session ' + s.id[:8] + '…') }}
|
||||||
</span>
|
</span>
|
||||||
{% elif item.session.status == 'completed' %}
|
<input id="label-input-{{ s.id }}"
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
class="hidden text-sm font-semibold bg-transparent border-b border-seismo-orange text-gray-900 dark:text-white focus:outline-none min-w-[180px]"
|
||||||
Completed
|
value="{{ s.session_label or '' }}"
|
||||||
</span>
|
onblur="saveLabel('{{ s.id }}')"
|
||||||
{% elif item.session.status == 'paused' %}
|
onkeydown="if(event.key==='Enter'){saveLabel('{{ s.id }}');}if(event.key==='Escape'){cancelEditLabel('{{ s.id }}');}">
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
|
||||||
Paused
|
{% if s.status == 'recording' %}
|
||||||
</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||||
{% elif item.session.status == 'failed' %}
|
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
|
||||||
Failed
|
|
||||||
</span>
|
</span>
|
||||||
|
{% elif s.status == 'completed' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
|
||||||
|
{% elif s.status == 'failed' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Period type badge (click to change) -->
|
||||||
|
<div class="relative" id="period-wrap-{{ s.id }}">
|
||||||
|
<button onclick="togglePeriodMenu('{{ s.id }}')"
|
||||||
|
id="period-badge-{{ s.id }}"
|
||||||
|
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||||
|
title="Click to change period type">
|
||||||
|
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
||||||
|
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="period-menu-{{ s.id }}"
|
||||||
|
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
|
||||||
|
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
|
||||||
|
<button onclick="setPeriodType('{{ s.id }}', '{{ pt }}')"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
|
||||||
|
{{ pt_label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
<!-- Info grid -->
|
||||||
{% if item.unit %}
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div>
|
{% if loc %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">Unit:</span>
|
<div class="flex items-center gap-1">
|
||||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{{ item.unit.id }}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
</a>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ loc.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div>
|
{% if s.started_at %}
|
||||||
<span class="text-xs text-gray-500">Started:</span>
|
<div class="flex items-center gap-1">
|
||||||
<span class="ml-1">{{ item.session.started_at|local_datetime if item.session.started_at else 'N/A' }}</span>
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
{% if item.session.stopped_at %}
|
<span>{{ s.started_at|local_datetime }}</span>
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Ended:</span>
|
|
||||||
<span class="ml-1">{{ item.session.stopped_at|local_datetime }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if item.session.duration_seconds %}
|
{% if s.stopped_at %}
|
||||||
<div>
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-xs text-gray-500">Duration:</span>
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span class="ml-1">{{ (item.session.duration_seconds // 3600) }}h {{ ((item.session.duration_seconds % 3600) // 60) }}m</span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Ended {{ s.stopped_at|local_datetime }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if s.duration_seconds %}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ (s.duration_seconds // 3600) }}h {{ ((s.duration_seconds % 3600) // 60) }}m</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit %}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2v-4M9 21H5a2 2 0 01-2-2v-4m0 0h18"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/slm/{{ unit.id }}?from_project={{ project_id }}"
|
||||||
|
class="text-seismo-orange hover:underline font-medium">{{ unit.id }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if s.device_model %}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ s.device_model }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if item.session.notes %}
|
{% if s.notes %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
|
||||||
{{ item.session.notes }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
{% if item.session.status == 'recording' %}
|
{% if s.status == 'recording' %}
|
||||||
<button onclick="stopRecording('{{ item.session.id }}')"
|
<button onclick="stopRecording('{{ s.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button onclick="viewSession('{{ item.session.id }}')"
|
<button onclick="viewSession('{{ s.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
@@ -84,24 +154,107 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" 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>
|
<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>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-1">No monitoring sessions yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500">Upload data to create sessions</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const PERIOD_COLORS = {
|
||||||
|
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
};
|
||||||
|
const PERIOD_LABELS = {
|
||||||
|
weekday_day: 'Weekday Day',
|
||||||
|
weekday_night: 'Weekday Night',
|
||||||
|
weekend_day: 'Weekend Day',
|
||||||
|
weekend_night: 'Weekend Night',
|
||||||
|
};
|
||||||
|
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
|
||||||
|
const ALL_BADGE_COLORS = [...new Set([
|
||||||
|
...FALLBACK_COLORS,
|
||||||
|
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
|
||||||
|
])];
|
||||||
|
|
||||||
|
function togglePeriodMenu(sessionId) {
|
||||||
|
const menu = document.getElementById('period-menu-' + sessionId);
|
||||||
|
document.querySelectorAll('[id^="period-menu-"]').forEach(m => {
|
||||||
|
if (m.id !== 'period-menu-' + sessionId) m.classList.add('hidden');
|
||||||
|
});
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('[id^="period-wrap-"]')) {
|
||||||
|
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setPeriodType(sessionId, periodType) {
|
||||||
|
document.getElementById('period-menu-' + sessionId).classList.add('hidden');
|
||||||
|
const badge = document.getElementById('period-badge-' + sessionId);
|
||||||
|
badge.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({period_type: periodType}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
||||||
|
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean));
|
||||||
|
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
|
||||||
|
} catch(err) {
|
||||||
|
alert('Failed to update period type: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
badge.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditLabel(sessionId) {
|
||||||
|
document.getElementById('label-display-' + sessionId).classList.add('hidden');
|
||||||
|
const input = document.getElementById('label-input-' + sessionId);
|
||||||
|
input.classList.remove('hidden');
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditLabel(sessionId) {
|
||||||
|
document.getElementById('label-input-' + sessionId).classList.add('hidden');
|
||||||
|
document.getElementById('label-display-' + sessionId).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLabel(sessionId) {
|
||||||
|
const display = document.getElementById('label-display-' + sessionId);
|
||||||
|
const input = document.getElementById('label-input-' + sessionId);
|
||||||
|
const newLabel = input.value.trim();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({session_label: newLabel}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
display.textContent = newLabel || ('Session ' + sessionId.slice(0, 8) + '…');
|
||||||
|
} catch(err) {
|
||||||
|
alert('Failed to save label: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
input.classList.add('hidden');
|
||||||
|
display.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function viewSession(sessionId) {
|
function viewSession(sessionId) {
|
||||||
// TODO: Implement session detail modal or page
|
|
||||||
alert('Session details coming soon: ' + sessionId);
|
alert('Session details coming soon: ' + sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording(sessionId) {
|
function stopRecording(sessionId) {
|
||||||
if (!confirm('Stop this monitoring session?')) return;
|
if (!confirm('Stop this monitoring session?')) return;
|
||||||
|
|
||||||
// TODO: Implement stop recording API call
|
|
||||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,12 +23,26 @@
|
|||||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
|
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
|
{% set meta = session.session_metadata|fromjson if session.session_metadata else {} %}
|
||||||
|
{% set is_manual = meta.get('source') in ('manual_upload', 'bulk_upload') %}
|
||||||
<div class="font-semibold text-gray-900 dark:text-white">
|
<div class="font-semibold text-gray-900 dark:text-white">
|
||||||
{{ session.started_at|local_datetime if session.started_at else 'Unknown Date' }}
|
{% if location %}{{ location.name }}{% else %}Unknown Location{% endif %}
|
||||||
|
{% if session.started_at %}
|
||||||
|
—
|
||||||
|
{% if session.stopped_at and not same_date(session.started_at, session.stopped_at) %}
|
||||||
|
{{ session.started_at|local_date }} to {{ session.stopped_at|local_date }}
|
||||||
|
{% else %}
|
||||||
|
{{ session.started_at|local_date }}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
|
{% if is_manual %}
|
||||||
{% if location %} @ {{ location.name }}{% endif %}
|
{% set store = meta.get('store_name') %}
|
||||||
|
Manual upload{% if store %} — Store {{ store }}{% endif %}
|
||||||
|
{% elif unit %}
|
||||||
|
{{ unit.id }}
|
||||||
|
{% endif %}
|
||||||
<span class="mx-2">•</span>
|
<span class="mx-2">•</span>
|
||||||
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
|
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,12 +40,12 @@
|
|||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
<span id="locations-tab-label">Locations</span>
|
<span id="locations-tab-label">Locations</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('units')"
|
<button id="units-tab-btn" onclick="switchTab('units')"
|
||||||
data-tab="units"
|
data-tab="units"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
Assigned Units
|
Assigned Units
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('schedules')"
|
<button id="schedules-tab-btn" onclick="switchTab('schedules')"
|
||||||
data-tab="schedules"
|
data-tab="schedules"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
Schedules
|
Schedules
|
||||||
@@ -230,6 +230,13 @@
|
|||||||
Project Files
|
Project Files
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="toggleUploadAll()"
|
||||||
|
class="px-3 py-2 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||||
|
</svg>
|
||||||
|
Upload Data
|
||||||
|
</button>
|
||||||
<button onclick="htmx.trigger('#unified-files', 'refresh')"
|
<button onclick="htmx.trigger('#unified-files', 'refresh')"
|
||||||
class="px-3 py-2 text-sm 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">
|
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -241,6 +248,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Data Panel -->
|
||||||
|
<div id="upload-all-panel" class="hidden border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bulk Import — Select Folder</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Select your data folder directly — no zipping needed. Expected structure:
|
||||||
|
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">[date]/[NRL name]/[Auto_####]/</code>.
|
||||||
|
NRL folders are matched to locations by name. MP3s are stored; Excel exports are skipped.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<input type="file" id="upload-all-input"
|
||||||
|
webkitdirectory directory multiple
|
||||||
|
class="block text-sm text-gray-500 dark:text-gray-400
|
||||||
|
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||||
|
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||||
|
<span id="upload-all-file-count" class="text-xs text-gray-500 dark:text-gray-400 hidden"></span>
|
||||||
|
<button id="upload-all-btn" onclick="submitUploadAll()"
|
||||||
|
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button id="upload-all-cancel-btn" onclick="toggleUploadAll()"
|
||||||
|
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span id="upload-all-status" class="text-sm hidden"></span>
|
||||||
|
</div>
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div id="upload-all-progress-wrap" class="hidden mt-3">
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
<span id="upload-all-progress-label">Uploading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div id="upload-all-progress-bar"
|
||||||
|
class="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Result summary -->
|
||||||
|
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="unified-files"
|
<div id="unified-files"
|
||||||
hx-get="/api/projects/{{ project_id }}/files-unified"
|
hx-get="/api/projects/{{ project_id }}/files-unified"
|
||||||
hx-trigger="load, refresh from:#unified-files"
|
hx-trigger="load, refresh from:#unified-files"
|
||||||
@@ -286,6 +336,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Data Collection</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-manual-label">
|
||||||
|
<input type="radio" name="data_collection_mode" id="settings-mode-manual" value="manual"
|
||||||
|
onchange="settingsUpdateModeStyles()"
|
||||||
|
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Manual</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">SD card retrieved daily</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-remote-label">
|
||||||
|
<input type="radio" name="data_collection_mode" id="settings-mode-remote" value="remote"
|
||||||
|
onchange="settingsUpdateModeStyles()"
|
||||||
|
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Remote</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Modem, data pulled via FTP</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Address</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Address</label>
|
||||||
<input type="text" name="site_address" id="settings-site-address"
|
<input type="text" name="site_address" id="settings-site-address"
|
||||||
@@ -402,7 +476,7 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label>
|
||||||
<select name="location_type" id="location-type"
|
<select name="location_type" id="location-type" onchange="updateConnectionModeVisibility()"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
<option value="sound">Sound</option>
|
<option value="sound">Sound</option>
|
||||||
<option value="vibration">Vibration</option>
|
<option value="vibration">Vibration</option>
|
||||||
@@ -415,6 +489,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Mode — sound locations only -->
|
||||||
|
<div id="connection-mode-field">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connection Mode</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange rounded-lg cursor-pointer bg-orange-50 dark:bg-orange-900/10" id="mode-connected-label">
|
||||||
|
<input type="radio" name="connection_mode" value="connected" checked
|
||||||
|
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white text-sm">Connected</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Remote unit accessible via modem. Supports live control and FTP download.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="mode-offline-label">
|
||||||
|
<input type="radio" name="connection_mode" value="offline"
|
||||||
|
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white text-sm">Offline / Manual Upload</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No network access. Data collected from SD card and uploaded manually.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
<input type="text" name="address" id="location-address"
|
<input type="text" name="address" id="location-address"
|
||||||
@@ -714,6 +811,12 @@ async function loadProjectDetails() {
|
|||||||
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
||||||
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
||||||
|
|
||||||
|
// Update data collection mode radio
|
||||||
|
const mode = data.data_collection_mode || 'manual';
|
||||||
|
const modeRadio = document.getElementById('settings-mode-' + mode);
|
||||||
|
if (modeRadio) modeRadio.checked = true;
|
||||||
|
settingsUpdateModeStyles();
|
||||||
|
|
||||||
// Update tab labels and visibility based on project type
|
// Update tab labels and visibility based on project type
|
||||||
const isSoundProject = projectTypeId === 'sound_monitoring';
|
const isSoundProject = projectTypeId === 'sound_monitoring';
|
||||||
if (isSoundProject) {
|
if (isSoundProject) {
|
||||||
@@ -721,9 +824,16 @@ async function loadProjectDetails() {
|
|||||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||||
}
|
}
|
||||||
// Monitoring Sessions and Data Files tabs are SLM-only
|
// Monitoring Sessions and Data Files tabs are sound-only
|
||||||
|
// Data Files also hides the FTP browser section for manual projects
|
||||||
|
const isRemote = mode === 'remote';
|
||||||
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||||
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||||
|
// Schedules and Assigned Units are remote-only (manual projects collect data by hand)
|
||||||
|
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
||||||
|
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
||||||
|
// FTP browser within Data Files tab
|
||||||
|
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
|
||||||
|
|
||||||
document.getElementById('settings-error').classList.add('hidden');
|
document.getElementById('settings-error').classList.add('hidden');
|
||||||
updateDangerZone();
|
updateDangerZone();
|
||||||
@@ -739,6 +849,24 @@ function formatDate(value) {
|
|||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settingsUpdateModeStyles() {
|
||||||
|
const manualChecked = document.getElementById('settings-mode-manual')?.checked;
|
||||||
|
const manualLabel = document.getElementById('settings-mode-manual-label');
|
||||||
|
const remoteLabel = document.getElementById('settings-mode-remote-label');
|
||||||
|
if (!manualLabel || !remoteLabel) return;
|
||||||
|
if (manualChecked) {
|
||||||
|
manualLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
manualLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
remoteLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
remoteLabel.classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
} else {
|
||||||
|
remoteLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
remoteLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
manualLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
manualLabel.classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Project settings form submission
|
// Project settings form submission
|
||||||
document.getElementById('project-settings-form').addEventListener('submit', async function(e) {
|
document.getElementById('project-settings-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -751,7 +879,8 @@ document.getElementById('project-settings-form').addEventListener('submit', asyn
|
|||||||
site_address: document.getElementById('settings-site-address').value.trim() || null,
|
site_address: document.getElementById('settings-site-address').value.trim() || null,
|
||||||
site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null,
|
site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null,
|
||||||
start_date: document.getElementById('settings-start-date').value || null,
|
start_date: document.getElementById('settings-start-date').value || null,
|
||||||
end_date: document.getElementById('settings-end-date').value || null
|
end_date: document.getElementById('settings-end-date').value || null,
|
||||||
|
data_collection_mode: document.querySelector('input[name="data_collection_mode"]:checked')?.value || 'manual'
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -794,6 +923,33 @@ function refreshProjectDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Location modal functions
|
// Location modal functions
|
||||||
|
function updateConnectionModeVisibility() {
|
||||||
|
const locType = document.getElementById('location-type').value;
|
||||||
|
const field = document.getElementById('connection-mode-field');
|
||||||
|
if (field) field.classList.toggle('hidden', locType !== 'sound');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModeLabels() {
|
||||||
|
const connected = document.querySelector('input[name="connection_mode"][value="connected"]');
|
||||||
|
const offline = document.querySelector('input[name="connection_mode"][value="offline"]');
|
||||||
|
const connLabel = document.getElementById('mode-connected-label');
|
||||||
|
const offLabel = document.getElementById('mode-offline-label');
|
||||||
|
if (!connected || !connLabel || !offLabel) return;
|
||||||
|
const activeClasses = ['border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/10'];
|
||||||
|
const inactiveClasses = ['border-gray-300', 'dark:border-gray-600'];
|
||||||
|
if (connected.checked) {
|
||||||
|
connLabel.classList.add(...activeClasses);
|
||||||
|
connLabel.classList.remove(...inactiveClasses);
|
||||||
|
offLabel.classList.remove(...activeClasses);
|
||||||
|
offLabel.classList.add(...inactiveClasses);
|
||||||
|
} else {
|
||||||
|
offLabel.classList.add(...activeClasses);
|
||||||
|
offLabel.classList.remove(...inactiveClasses);
|
||||||
|
connLabel.classList.remove(...activeClasses);
|
||||||
|
connLabel.classList.add(...inactiveClasses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openLocationModal(defaultType) {
|
function openLocationModal(defaultType) {
|
||||||
editingLocationId = null;
|
editingLocationId = null;
|
||||||
document.getElementById('location-modal-title').textContent = 'Add Location';
|
document.getElementById('location-modal-title').textContent = 'Add Location';
|
||||||
@@ -802,6 +958,9 @@ function openLocationModal(defaultType) {
|
|||||||
document.getElementById('location-description').value = '';
|
document.getElementById('location-description').value = '';
|
||||||
document.getElementById('location-address').value = '';
|
document.getElementById('location-address').value = '';
|
||||||
document.getElementById('location-coordinates').value = '';
|
document.getElementById('location-coordinates').value = '';
|
||||||
|
// Reset connection mode to connected
|
||||||
|
const connectedRadio = document.querySelector('input[name="connection_mode"][value="connected"]');
|
||||||
|
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
|
||||||
const locationTypeSelect = document.getElementById('location-type');
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
@@ -817,6 +976,7 @@ function openLocationModal(defaultType) {
|
|||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
locationTypeSelect.value = defaultType || 'sound';
|
locationTypeSelect.value = defaultType || 'sound';
|
||||||
}
|
}
|
||||||
|
updateConnectionModeVisibility();
|
||||||
document.getElementById('location-error').classList.add('hidden');
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
document.getElementById('location-modal').classList.remove('hidden');
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
@@ -830,6 +990,11 @@ function openEditLocationModal(button) {
|
|||||||
document.getElementById('location-description').value = data.description || '';
|
document.getElementById('location-description').value = data.description || '';
|
||||||
document.getElementById('location-address').value = data.address || '';
|
document.getElementById('location-address').value = data.address || '';
|
||||||
document.getElementById('location-coordinates').value = data.coordinates || '';
|
document.getElementById('location-coordinates').value = data.coordinates || '';
|
||||||
|
// Restore connection mode from metadata
|
||||||
|
let savedMode = 'connected';
|
||||||
|
try { savedMode = JSON.parse(data.location_metadata || '{}').connection_mode || 'connected'; } catch(e) {}
|
||||||
|
const modeRadio = document.querySelector(`input[name="connection_mode"][value="${savedMode}"]`);
|
||||||
|
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
|
||||||
const locationTypeSelect = document.getElementById('location-type');
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
@@ -845,6 +1010,7 @@ function openEditLocationModal(button) {
|
|||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
locationTypeSelect.value = data.location_type || 'sound';
|
locationTypeSelect.value = data.location_type || 'sound';
|
||||||
}
|
}
|
||||||
|
updateConnectionModeVisibility();
|
||||||
document.getElementById('location-error').classList.add('hidden');
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
document.getElementById('location-modal').classList.remove('hidden');
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
@@ -867,6 +1033,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
locationType = 'vibration';
|
locationType = 'vibration';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connectionMode = document.querySelector('input[name="connection_mode"]:checked')?.value || 'connected';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingLocationId) {
|
if (editingLocationId) {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -874,7 +1042,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
description: description || null,
|
description: description || null,
|
||||||
address: address || null,
|
address: address || null,
|
||||||
coordinates: coordinates || null,
|
coordinates: coordinates || null,
|
||||||
location_type: locationType
|
location_type: locationType,
|
||||||
|
location_metadata: JSON.stringify({ connection_mode: connectionMode }),
|
||||||
};
|
};
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
|
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -892,6 +1061,7 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
formData.append('address', address);
|
formData.append('address', address);
|
||||||
formData.append('coordinates', coordinates);
|
formData.append('coordinates', coordinates);
|
||||||
formData.append('location_type', locationType);
|
formData.append('location_type', locationType);
|
||||||
|
formData.append('location_metadata', JSON.stringify({ connection_mode: connectionMode }));
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
|
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
@@ -1473,6 +1643,161 @@ document.getElementById('schedule-modal')?.addEventListener('click', function(e)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Upload Data ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleUploadAll() {
|
||||||
|
const panel = document.getElementById('upload-all-panel');
|
||||||
|
panel.classList.toggle('hidden');
|
||||||
|
if (!panel.classList.contains('hidden')) {
|
||||||
|
document.getElementById('upload-all-status').textContent = '';
|
||||||
|
document.getElementById('upload-all-status').className = 'text-sm hidden';
|
||||||
|
document.getElementById('upload-all-results').classList.add('hidden');
|
||||||
|
document.getElementById('upload-all-results').innerHTML = '';
|
||||||
|
document.getElementById('upload-all-input').value = '';
|
||||||
|
document.getElementById('upload-all-file-count').classList.add('hidden');
|
||||||
|
document.getElementById('upload-all-progress-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('upload-all-progress-bar').style.width = '0%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show file count and filter info when folder is selected
|
||||||
|
document.getElementById('upload-all-input').addEventListener('change', function() {
|
||||||
|
const countEl = document.getElementById('upload-all-file-count');
|
||||||
|
const total = this.files.length;
|
||||||
|
if (!total) { countEl.classList.add('hidden'); return; }
|
||||||
|
const wanted = Array.from(this.files).filter(_isWantedFile).length;
|
||||||
|
countEl.textContent = `${wanted} of ${total} files will be uploaded (Leq + .rnh only)`;
|
||||||
|
countEl.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
function _isWantedFile(f) {
|
||||||
|
const n = (f.webkitRelativePath || f.name).toLowerCase();
|
||||||
|
const base = n.split('/').pop();
|
||||||
|
if (base.endsWith('.rnh')) return true;
|
||||||
|
if (base.endsWith('.rnd')) {
|
||||||
|
if (base.includes('_leq_')) return true; // NL-43 Leq
|
||||||
|
if (base.startsWith('au2_')) return true; // AU2/NL-23 format
|
||||||
|
if (!base.includes('_lp')) return true; // unknown format — keep
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitUploadAll() {
|
||||||
|
const input = document.getElementById('upload-all-input');
|
||||||
|
const status = document.getElementById('upload-all-status');
|
||||||
|
const resultsEl = document.getElementById('upload-all-results');
|
||||||
|
const btn = document.getElementById('upload-all-btn');
|
||||||
|
const cancelBtn = document.getElementById('upload-all-cancel-btn');
|
||||||
|
const progressWrap = document.getElementById('upload-all-progress-wrap');
|
||||||
|
const progressBar = document.getElementById('upload-all-progress-bar');
|
||||||
|
const progressLabel = document.getElementById('upload-all-progress-label');
|
||||||
|
|
||||||
|
if (!input.files.length) {
|
||||||
|
alert('Please select a folder to upload.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter client-side — only send Leq .rnd and .rnh files
|
||||||
|
const filesToSend = Array.from(input.files).filter(_isWantedFile);
|
||||||
|
if (!filesToSend.length) {
|
||||||
|
alert('No Leq .rnd or .rnh files found in selected folder.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const f of filesToSend) {
|
||||||
|
formData.append('files', f);
|
||||||
|
formData.append('paths', f.webkitRelativePath || f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable controls and show progress
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading\u2026';
|
||||||
|
btn.classList.add('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
|
||||||
|
status.className = 'text-sm hidden';
|
||||||
|
resultsEl.classList.add('hidden');
|
||||||
|
progressWrap.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`;
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const pct = Math.round((e.loaded / e.total) * 100);
|
||||||
|
progressBar.style.width = pct + '%';
|
||||||
|
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('load', () => {
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
progressLabel.textContent = 'Processing files on server\u2026';
|
||||||
|
});
|
||||||
|
|
||||||
|
function _resetControls() {
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Import';
|
||||||
|
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
_resetControls();
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const s = data.sessions_created;
|
||||||
|
const f = data.files_imported;
|
||||||
|
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
|
||||||
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
input.value = '';
|
||||||
|
document.getElementById('upload-all-file-count').classList.add('hidden');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (data.sessions && data.sessions.length) {
|
||||||
|
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
|
||||||
|
html += '<ul class="space-y-0.5 ml-2">';
|
||||||
|
for (const sess of data.sessions) {
|
||||||
|
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> — ${sess.files} files`;
|
||||||
|
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
|
||||||
|
if (sess.store_name) html += ` — ${sess.store_name}`;
|
||||||
|
html += '</li>';
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
if (data.unmatched_folders && data.unmatched_folders.length) {
|
||||||
|
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
|
||||||
|
}
|
||||||
|
if (html) {
|
||||||
|
resultsEl.innerHTML = html;
|
||||||
|
resultsEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
htmx.trigger(document.getElementById('unified-files'), 'refresh');
|
||||||
|
} else {
|
||||||
|
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
status.textContent = 'Error: Unexpected server response';
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
_resetControls();
|
||||||
|
status.textContent = 'Error: Network error during upload';
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/projects/{{ project_id }}/upload-all`);
|
||||||
|
xhr.send(formData);
|
||||||
|
}
|
||||||
|
|
||||||
// Load project details on page load and restore active tab from URL hash
|
// Load project details on page load and restore active tab from URL hash
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadProjectDetails();
|
loadProjectDetails();
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
|
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
|
||||||
{% if file and '_Leq_' in file.file_path %}
|
{% if is_leq %}
|
||||||
<!-- Generate Excel Report Button -->
|
<!-- Generate Excel Report Button -->
|
||||||
<button onclick="openReportModal()"
|
<button onclick="openReportModal()"
|
||||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
|
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user