Compare commits
46 Commits
v0.6.1
...
0e3f512203
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e3f512203 | |||
| e4d1f0d684 | |||
|
|
b571dc29bc | ||
|
|
e2c841d5d7 | ||
| cc94493331 | |||
|
|
5a5426cceb | ||
|
|
66eddd6fe2 | ||
|
|
c77794787c | ||
| 61c84bc71d | |||
| fbf7f2a65d | |||
|
|
202fcaf91c | ||
|
|
3a411d0a89 | ||
| 0c2186f5d8 | |||
| c138e8c6a0 | |||
| 1dd396acd8 | |||
| e89a04f58c | |||
| e4ef065db8 | |||
| 86010de60c | |||
| f89f04cd6f | |||
| 67a2faa2d3 | |||
| 14856e61ef | |||
| 2b69518b33 | |||
| 6070d03e83 | |||
| 240552751c | |||
| 015ce0a254 | |||
| ef8c046f31 | |||
| 3637cf5af8 | |||
| 7fde14d882 | |||
| bd3d937a82 | |||
| 291fa8e862 | |||
| 8e292b1aca | |||
| 7516bbea70 | |||
| da4e5f66c5 | |||
| dae2595303 | |||
| 0c4e7aa5e6 | |||
| 229499ccf6 | |||
| fdc4adeaee | |||
| b3bf91880a | |||
| 17b3f91dfc | |||
| 6c1d0bc467 | |||
|
|
abd059983f | ||
|
|
0f17841218 | ||
|
|
65362bab21 | ||
|
|
dc77a362ce | ||
|
|
28942600ab | ||
|
|
80861997af |
@@ -1,3 +1,5 @@
|
|||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# Python cache / compiled
|
# Python cache / compiled
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -28,6 +30,7 @@ ENV/
|
|||||||
|
|
||||||
# Runtime data (mounted volumes)
|
# Runtime data (mounted volumes)
|
||||||
data/
|
data/
|
||||||
|
data-dev/
|
||||||
|
|
||||||
# Editors / OS junk
|
# Editors / OS junk
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -1,3 +1,17 @@
|
|||||||
|
# Terra-View Specifics
|
||||||
|
# Dev build counter (local only, never commit)
|
||||||
|
build_number.txt
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
data/
|
||||||
|
data-dev/
|
||||||
|
.aider*
|
||||||
|
.aider*
|
||||||
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[codz]
|
||||||
@@ -206,10 +220,14 @@ marimo/_static/
|
|||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
# Seismo Fleet Manager
|
# Seismo Fleet Manager
|
||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
data/
|
/data/
|
||||||
|
/data-dev/
|
||||||
.aider*
|
.aider*
|
||||||
.aider*
|
.aider*
|
||||||
|
=======
|
||||||
|
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027
|
||||||
|
|||||||
76
CHANGELOG.md
76
CHANGELOG.md
@@ -5,6 +5,81 @@ 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.1] - 2026-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel
|
||||||
|
- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Migration Scripts**: Fixed database path references in several migration scripts
|
||||||
|
- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run the following migration script once per database before deploying:
|
||||||
|
```bash
|
||||||
|
python backend/migrate_add_out_for_calibration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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 +520,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.1
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from backend.models import (
|
|||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile,
|
DataFile,
|
||||||
)
|
)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
@@ -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.1"
|
||||||
|
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",
|
||||||
@@ -312,7 +316,7 @@ async def nrl_detail_page(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""NRL (Noise Recording Location) detail page with tabs"""
|
"""NRL (Noise Recording Location) detail page with tabs"""
|
||||||
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
|
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, MonitoringSession, DataFile
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
# Get project
|
# Get project
|
||||||
@@ -348,23 +352,33 @@ async def nrl_detail_page(
|
|||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
|
||||||
# Get session count
|
# Get session count
|
||||||
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
|
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
||||||
|
|
||||||
# Get file count (DataFile links to session, not directly to location)
|
# Get file count (DataFile links to session, not directly to location)
|
||||||
file_count = db.query(DataFile).join(
|
file_count = db.query(DataFile).join(
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile.session_id == RecordingSession.id
|
DataFile.session_id == MonitoringSession.id
|
||||||
).filter(RecordingSession.location_id == location_id).count()
|
).filter(MonitoringSession.location_id == location_id).count()
|
||||||
|
|
||||||
# Check for active session
|
# Check for active session
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == location_id,
|
MonitoringSession.location_id == location_id,
|
||||||
RecordingSession.status == "recording"
|
MonitoringSession.status == "recording"
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
return templates.TemplateResponse("nrl_detail.html", {
|
# 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"
|
||||||
|
return templates.TemplateResponse(template, {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"location_id": location_id,
|
"location_id": location_id,
|
||||||
@@ -375,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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -644,6 +659,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": unit_data.get("last", "Never"),
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": True,
|
"deployed": True,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -668,6 +684,32 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": unit_data.get("last", "Never"),
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
|
"ignored": False,
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"project_id": unit_data.get("project_id", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add out-for-calibration units
|
||||||
|
for unit_id, unit_data in snapshot["out_for_calibration"].items():
|
||||||
|
units_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": "Out for Calibration",
|
||||||
|
"age": "N/A",
|
||||||
|
"last_seen": "N/A",
|
||||||
|
"deployed": False,
|
||||||
|
"retired": False,
|
||||||
|
"out_for_calibration": True,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -692,6 +734,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": "N/A",
|
"last_seen": "N/A",
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": True,
|
"retired": True,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -716,6 +759,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": "N/A",
|
"last_seen": "N/A",
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": True,
|
"ignored": True,
|
||||||
"note": unit_data.get("note", unit_data.get("reason", "")),
|
"note": unit_data.get("note", unit_data.get("reason", "")),
|
||||||
"device_type": unit_data.get("device_type", "unknown"),
|
"device_type": unit_data.get("device_type", "unknown"),
|
||||||
@@ -733,15 +777,17 @@ async def devices_all_partial(request: Request):
|
|||||||
|
|
||||||
# Sort by status category, then by ID
|
# Sort by status category, then by ID
|
||||||
def sort_key(unit):
|
def sort_key(unit):
|
||||||
# Priority: deployed (active) -> benched -> retired -> ignored
|
# Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored
|
||||||
if unit["deployed"]:
|
if unit["deployed"]:
|
||||||
return (0, unit["id"])
|
return (0, unit["id"])
|
||||||
elif not unit["retired"] and not unit["ignored"]:
|
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
|
||||||
return (1, unit["id"])
|
return (1, unit["id"])
|
||||||
elif unit["retired"]:
|
elif unit["out_for_calibration"]:
|
||||||
return (2, unit["id"])
|
return (2, unit["id"])
|
||||||
else:
|
elif unit["retired"]:
|
||||||
return (3, unit["id"])
|
return (3, unit["id"])
|
||||||
|
else:
|
||||||
|
return (4, unit["id"])
|
||||||
|
|
||||||
units_list.sort(key=sort_key)
|
units_list.sort(key=sort_key)
|
||||||
|
|
||||||
|
|||||||
54
backend/migrate_add_out_for_calibration.py
Normal file
54
backend/migrate_add_out_for_calibration.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Database Migration: Add out_for_calibration field to roster table
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Adds out_for_calibration BOOLEAN column (default FALSE) to roster table
|
||||||
|
- Safe to run multiple times (idempotent)
|
||||||
|
- No data loss
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backend/migrate_add_out_for_calibration.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
print("=" * 60)
|
||||||
|
print("Migration: Add out_for_calibration to roster")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Check if column already exists
|
||||||
|
result = db.execute(text("PRAGMA table_info(roster)")).fetchall()
|
||||||
|
columns = [row[1] for row in result]
|
||||||
|
|
||||||
|
if "out_for_calibration" in columns:
|
||||||
|
print("Column out_for_calibration already exists. Skipping.")
|
||||||
|
else:
|
||||||
|
db.execute(text("ALTER TABLE roster ADD COLUMN out_for_calibration BOOLEAN DEFAULT FALSE"))
|
||||||
|
db.commit()
|
||||||
|
print("Added out_for_calibration column to roster table.")
|
||||||
|
|
||||||
|
print("Migration complete.")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
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()
|
||||||
56
backend/migrate_add_project_deleted_at.py
Normal file
56
backend/migrate_add_project_deleted_at.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add deleted_at column to projects table
|
||||||
|
|
||||||
|
Adds columns:
|
||||||
|
- projects.deleted_at: Timestamp set when status='deleted'; data hard-deleted after 60 days
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: str):
|
||||||
|
"""Run the migration."""
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("projects table does not exist. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(projects)")
|
||||||
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
if 'deleted_at' not in existing_cols:
|
||||||
|
print("Adding deleted_at column to projects...")
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN deleted_at DATETIME")
|
||||||
|
else:
|
||||||
|
print("deleted_at column already exists. Skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db_path = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate(db_path)
|
||||||
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()
|
||||||
54
backend/migrate_rename_recording_to_monitoring_sessions.py
Normal file
54
backend/migrate_rename_recording_to_monitoring_sessions.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Migration: Rename recording_sessions table to monitoring_sessions
|
||||||
|
|
||||||
|
Renames the table and updates the model name from RecordingSession to MonitoringSession.
|
||||||
|
Run once per database: python backend/migrate_rename_recording_to_monitoring_sessions.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: str):
|
||||||
|
"""Run the migration."""
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='recording_sessions'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='monitoring_sessions'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("monitoring_sessions table already exists. Skipping migration.")
|
||||||
|
else:
|
||||||
|
print("recording_sessions table does not exist. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Renaming recording_sessions -> monitoring_sessions...")
|
||||||
|
cursor.execute("ALTER TABLE recording_sessions RENAME TO monitoring_sessions")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db_path = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate(db_path)
|
||||||
@@ -32,6 +32,7 @@ class RosterUnit(Base):
|
|||||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
||||||
deployed = Column(Boolean, default=True)
|
deployed = Column(Boolean, default=True)
|
||||||
retired = Column(Boolean, default=False)
|
retired = Column(Boolean, default=False)
|
||||||
|
out_for_calibration = Column(Boolean, default=False)
|
||||||
note = Column(String, nullable=True)
|
note = Column(String, nullable=True)
|
||||||
project_id = Column(String, nullable=True)
|
project_id = Column(String, nullable=True)
|
||||||
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
||||||
@@ -155,7 +156,12 @@ class Project(Base):
|
|||||||
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||||
status = Column(String, default="active") # active, completed, archived
|
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")
|
||||||
@@ -166,6 +172,7 @@ class Project(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
|
||||||
|
|
||||||
|
|
||||||
class MonitoringLocation(Base):
|
class MonitoringLocation(Base):
|
||||||
@@ -244,17 +251,21 @@ class ScheduledAction(Base):
|
|||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class RecordingSession(Base):
|
class MonitoringSession(Base):
|
||||||
"""
|
"""
|
||||||
Recording sessions: tracks actual monitoring sessions.
|
Monitoring sessions: tracks actual monitoring sessions.
|
||||||
Created when recording starts, updated when it stops.
|
Created when monitoring starts, updated when it stops.
|
||||||
"""
|
"""
|
||||||
__tablename__ = "recording_sessions"
|
__tablename__ = "monitoring_sessions"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||||
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=False, index=True) # FK to RosterUnit.id
|
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)
|
||||||
@@ -262,6 +273,14 @@ class RecordingSession(Base):
|
|||||||
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
|
||||||
|
|
||||||
@@ -277,7 +296,7 @@ class DataFile(Base):
|
|||||||
__tablename__ = "data_files"
|
__tablename__ = "data_files"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id
|
session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id
|
||||||
|
|
||||||
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
||||||
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
||||||
@@ -476,3 +495,6 @@ class JobReservationUnit(Base):
|
|||||||
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
|
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
|
||||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||||
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
|
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
|
||||||
|
|
||||||
|
# Power requirements for this deployment slot
|
||||||
|
power_type = Column(String, nullable=True) # "ac" | "solar" | None
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
@@ -48,10 +49,18 @@ def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
|
|||||||
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Exclude actions from paused/removed projects
|
||||||
|
paused_project_ids = [
|
||||||
|
p.id for p in db.query(Project.id).filter(
|
||||||
|
Project.status.in_(["on_hold", "archived", "deleted"])
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
|
||||||
# Query today's actions
|
# Query today's actions
|
||||||
actions = db.query(ScheduledAction).filter(
|
actions = db.query(ScheduledAction).filter(
|
||||||
ScheduledAction.scheduled_time >= today_start_utc,
|
ScheduledAction.scheduled_time >= today_start_utc,
|
||||||
ScheduledAction.scheduled_time < today_end_utc,
|
ScheduledAction.scheduled_time < today_end_utc,
|
||||||
|
ScheduledAction.project_id.notin_(paused_project_ids),
|
||||||
).order_by(ScheduledAction.scheduled_time.asc()).all()
|
).order_by(ScheduledAction.scheduled_time.asc()).all()
|
||||||
|
|
||||||
# Enrich with location/project info and parse results
|
# Enrich with location/project info and parse results
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ async def get_reservation(
|
|||||||
|
|
||||||
unit_ids = [a.unit_id for a in assignments]
|
unit_ids = [a.unit_id for a in assignments]
|
||||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||||
|
units_by_id = {u.id: u for u in units}
|
||||||
|
# Build power_type and notes lookup from assignments
|
||||||
|
power_type_map = {a.unit_id: a.power_type for a in assignments}
|
||||||
|
notes_map = {a.unit_id: a.notes for a in assignments}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": reservation.id,
|
"id": reservation.id,
|
||||||
@@ -239,11 +243,13 @@ async def get_reservation(
|
|||||||
"color": reservation.color,
|
"color": reservation.color,
|
||||||
"assigned_units": [
|
"assigned_units": [
|
||||||
{
|
{
|
||||||
"id": u.id,
|
"id": uid,
|
||||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
"last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None,
|
||||||
"deployed": u.deployed
|
"deployed": units_by_id[uid].deployed if uid in units_by_id else False,
|
||||||
|
"power_type": power_type_map.get(uid),
|
||||||
|
"notes": notes_map.get(uid)
|
||||||
}
|
}
|
||||||
for u in units
|
for uid in unit_ids
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,52 +343,52 @@ async def assign_units_to_reservation(
|
|||||||
|
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
unit_ids = data.get("unit_ids", [])
|
unit_ids = data.get("unit_ids", [])
|
||||||
|
# Optional per-unit power types: {"BE17354": "ac", "BE9441": "solar"}
|
||||||
|
power_types = data.get("power_types", {})
|
||||||
|
location_notes = data.get("location_notes", {})
|
||||||
|
|
||||||
if not unit_ids:
|
# Verify units exist (allow empty list to clear all assignments)
|
||||||
raise HTTPException(status_code=400, detail="No units specified")
|
if unit_ids:
|
||||||
|
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
||||||
|
found_ids = {u.id for u in units}
|
||||||
|
missing = set(unit_ids) - found_ids
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
||||||
|
|
||||||
# Verify units exist
|
# Full replace: delete all existing assignments for this reservation first
|
||||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
|
||||||
found_ids = {u.id for u in units}
|
db.flush()
|
||||||
missing = set(unit_ids) - found_ids
|
|
||||||
if missing:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
|
||||||
|
|
||||||
# Check for conflicts (already assigned to overlapping reservations)
|
# Check for conflicts with other reservations and insert new assignments
|
||||||
conflicts = []
|
conflicts = []
|
||||||
for unit_id in unit_ids:
|
for unit_id in unit_ids:
|
||||||
# Check if unit is already assigned to this reservation
|
|
||||||
existing = db.query(JobReservationUnit).filter_by(
|
|
||||||
reservation_id=reservation_id,
|
|
||||||
unit_id=unit_id
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
continue # Already assigned, skip
|
|
||||||
|
|
||||||
# Check overlapping reservations
|
# Check overlapping reservations
|
||||||
overlapping = db.query(JobReservation).join(
|
if reservation.end_date:
|
||||||
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
overlapping = db.query(JobReservation).join(
|
||||||
).filter(
|
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
||||||
JobReservationUnit.unit_id == unit_id,
|
).filter(
|
||||||
JobReservation.id != reservation_id,
|
JobReservationUnit.unit_id == unit_id,
|
||||||
JobReservation.start_date <= reservation.end_date,
|
JobReservation.id != reservation_id,
|
||||||
JobReservation.end_date >= reservation.start_date
|
JobReservation.start_date <= reservation.end_date,
|
||||||
).first()
|
JobReservation.end_date >= reservation.start_date
|
||||||
|
).first()
|
||||||
|
|
||||||
if overlapping:
|
if overlapping:
|
||||||
conflicts.append({
|
conflicts.append({
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"conflict_reservation": overlapping.name,
|
"conflict_reservation": overlapping.name,
|
||||||
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Add assignment
|
# Add assignment
|
||||||
assignment = JobReservationUnit(
|
assignment = JobReservationUnit(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
reservation_id=reservation_id,
|
reservation_id=reservation_id,
|
||||||
unit_id=unit_id,
|
unit_id=unit_id,
|
||||||
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific"
|
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
|
||||||
|
power_type=power_types.get(unit_id),
|
||||||
|
notes=location_notes.get(unit_id)
|
||||||
)
|
)
|
||||||
db.add(assignment)
|
db.add(assignment)
|
||||||
|
|
||||||
@@ -511,9 +517,8 @@ async def get_reservations_list(
|
|||||||
else:
|
else:
|
||||||
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
||||||
|
|
||||||
# Include TBD reservations that started before window end
|
# Include TBD reservations that started before window end — show ALL device types
|
||||||
reservations = db.query(JobReservation).filter(
|
reservations = db.query(JobReservation).filter(
|
||||||
JobReservation.device_type == device_type,
|
|
||||||
JobReservation.start_date <= end_date,
|
JobReservation.start_date <= end_date,
|
||||||
or_(
|
or_(
|
||||||
JobReservation.end_date >= start_date,
|
JobReservation.end_date >= start_date,
|
||||||
@@ -524,9 +529,25 @@ async def get_reservations_list(
|
|||||||
# Get assignment counts
|
# Get assignment counts
|
||||||
reservation_data = []
|
reservation_data = []
|
||||||
for res in reservations:
|
for res in reservations:
|
||||||
assigned_count = db.query(JobReservationUnit).filter_by(
|
assignments = db.query(JobReservationUnit).filter_by(
|
||||||
reservation_id=res.id
|
reservation_id=res.id
|
||||||
).count()
|
).all()
|
||||||
|
assigned_count = len(assignments)
|
||||||
|
|
||||||
|
# Enrich assignments with unit details
|
||||||
|
unit_ids = [a.unit_id for a in assignments]
|
||||||
|
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||||
|
units_by_id = {u.id: u for u in units}
|
||||||
|
assigned_units = [
|
||||||
|
{
|
||||||
|
"id": a.unit_id,
|
||||||
|
"power_type": a.power_type,
|
||||||
|
"notes": a.notes,
|
||||||
|
"deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False,
|
||||||
|
"last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None,
|
||||||
|
}
|
||||||
|
for a in assignments
|
||||||
|
]
|
||||||
|
|
||||||
# Check for calibration conflicts
|
# Check for calibration conflicts
|
||||||
conflicts = check_calibration_conflicts(db, res.id)
|
conflicts = check_calibration_conflicts(db, res.id)
|
||||||
@@ -534,6 +555,7 @@ async def get_reservations_list(
|
|||||||
reservation_data.append({
|
reservation_data.append({
|
||||||
"reservation": res,
|
"reservation": res,
|
||||||
"assigned_count": assigned_count,
|
"assigned_count": assigned_count,
|
||||||
|
"assigned_units": assigned_units,
|
||||||
"has_conflicts": len(conflicts) > 0,
|
"has_conflicts": len(conflicts) > 0,
|
||||||
"conflict_count": len(conflicts)
|
"conflict_count": len(conflicts)
|
||||||
})
|
})
|
||||||
@@ -549,6 +571,56 @@ async def get_reservations_list(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/fleet-calendar/planner-availability", response_class=JSONResponse)
|
||||||
|
async def get_planner_availability(
|
||||||
|
device_type: str = "seismograph",
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
exclude_reservation_id: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get available units for the reservation planner split-panel UI.
|
||||||
|
Dates are optional — if omitted, returns all non-retired units regardless of reservations.
|
||||||
|
"""
|
||||||
|
if start_date and end_date:
|
||||||
|
try:
|
||||||
|
start = date.fromisoformat(start_date)
|
||||||
|
end = date.fromisoformat(end_date)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||||
|
units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
|
||||||
|
else:
|
||||||
|
# No dates: return all non-retired units of this type
|
||||||
|
from backend.models import RosterUnit as RU
|
||||||
|
from datetime import timedelta
|
||||||
|
all_units = db.query(RU).filter(
|
||||||
|
RU.device_type == device_type,
|
||||||
|
RU.retired == False
|
||||||
|
).all()
|
||||||
|
units = []
|
||||||
|
for u in all_units:
|
||||||
|
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||||
|
units.append({
|
||||||
|
"id": u.id,
|
||||||
|
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||||
|
"expiry_date": expiry.isoformat() if expiry else None,
|
||||||
|
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
|
||||||
|
"deployed": u.deployed,
|
||||||
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
|
"note": u.note or ""
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort: benched first (easier to assign), then deployed, then by ID
|
||||||
|
units.sort(key=lambda u: (1 if u["deployed"] else 0, u["id"]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"units": units,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"count": len(units)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
||||||
async def get_available_units_partial(
|
async def get_available_units_partial(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ from typing import Optional
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from fastapi import UploadFile, File
|
||||||
|
import zipfile
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
Project,
|
Project,
|
||||||
@@ -21,13 +27,47 @@ from backend.models import (
|
|||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
RosterUnit,
|
RosterUnit,
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
|
DataFile,
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -70,8 +110,8 @@ async def get_project_locations(
|
|||||||
if assignment:
|
if assignment:
|
||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
|
||||||
# Count recording sessions
|
# Count monitoring sessions
|
||||||
session_count = db.query(RecordingSession).filter_by(
|
session_count = db.query(MonitoringSession).filter_by(
|
||||||
location_id=location.id
|
location_id=location.id
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
@@ -370,19 +410,19 @@ async def unassign_unit(
|
|||||||
if not assignment:
|
if not assignment:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
# Check if there are active recording sessions
|
# Check if there are active monitoring sessions
|
||||||
active_sessions = db.query(RecordingSession).filter(
|
active_sessions = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == assignment.location_id,
|
MonitoringSession.location_id == assignment.location_id,
|
||||||
RecordingSession.unit_id == assignment.unit_id,
|
MonitoringSession.unit_id == assignment.unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
if active_sessions > 0:
|
if active_sessions > 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Cannot unassign unit with active recording sessions. Stop recording first.",
|
detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.",
|
||||||
)
|
)
|
||||||
|
|
||||||
assignment.status = "completed"
|
assignment.status = "completed"
|
||||||
@@ -451,14 +491,12 @@ async def get_nrl_sessions(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get recording sessions for a specific NRL.
|
Get monitoring sessions for a specific NRL.
|
||||||
Returns HTML partial with session list.
|
Returns HTML partial with session list.
|
||||||
"""
|
"""
|
||||||
from backend.models import RecordingSession, RosterUnit
|
sessions = db.query(MonitoringSession).filter_by(
|
||||||
|
|
||||||
sessions = db.query(RecordingSession).filter_by(
|
|
||||||
location_id=location_id
|
location_id=location_id
|
||||||
).order_by(RecordingSession.started_at.desc()).all()
|
).order_by(MonitoringSession.started_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with unit details
|
# Enrich with unit details
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
@@ -491,14 +529,12 @@ async def get_nrl_files(
|
|||||||
Get data files for a specific NRL.
|
Get data files for a specific NRL.
|
||||||
Returns HTML partial with file list.
|
Returns HTML partial with file list.
|
||||||
"""
|
"""
|
||||||
from backend.models import DataFile, RecordingSession
|
# Join DataFile with MonitoringSession to filter by location_id
|
||||||
|
|
||||||
# Join DataFile with RecordingSession to filter by location_id
|
|
||||||
files = db.query(DataFile).join(
|
files = db.query(DataFile).join(
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile.session_id == RecordingSession.id
|
DataFile.session_id == MonitoringSession.id
|
||||||
).filter(
|
).filter(
|
||||||
RecordingSession.location_id == location_id
|
MonitoringSession.location_id == location_id
|
||||||
).order_by(DataFile.created_at.desc()).all()
|
).order_by(DataFile.created_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with session details
|
# Enrich with session details
|
||||||
@@ -506,7 +542,7 @@ async def get_nrl_files(
|
|||||||
for file in files:
|
for file in files:
|
||||||
session = None
|
session = None
|
||||||
if file.session_id:
|
if file.session_id:
|
||||||
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file.session_id).first()
|
||||||
|
|
||||||
files_data.append({
|
files_data.append({
|
||||||
"file": file,
|
"file": file,
|
||||||
@@ -519,3 +555,310 @@ async def get_nrl_files(
|
|||||||
"location_id": location_id,
|
"location_id": location_id,
|
||||||
"files": files_data,
|
"files": files_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Manual SD Card Data Upload
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _parse_rnh(content: bytes) -> dict:
|
||||||
|
"""
|
||||||
|
Parse a Rion .rnh metadata file (INI-style with [Section] headers).
|
||||||
|
Returns a dict of key metadata fields.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8", errors="replace")
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("["):
|
||||||
|
continue
|
||||||
|
if "," in line:
|
||||||
|
key, _, value = line.partition(",")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if key == "Serial Number":
|
||||||
|
result["serial_number"] = value
|
||||||
|
elif key == "Store Name":
|
||||||
|
result["store_name"] = value
|
||||||
|
elif key == "Index Number":
|
||||||
|
result["index_number"] = value
|
||||||
|
elif key == "Measurement Start Time":
|
||||||
|
result["start_time_str"] = value
|
||||||
|
elif key == "Measurement Stop Time":
|
||||||
|
result["stop_time_str"] = value
|
||||||
|
elif key == "Total Measurement Time":
|
||||||
|
result["total_time_str"] = value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rnh_datetime(s: str):
|
||||||
|
"""Parse RNH datetime string: '2026/02/17 19:00:19' -> datetime"""
|
||||||
|
from datetime import datetime
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_file(filename: str) -> str:
|
||||||
|
"""Classify a file by name into a DataFile file_type."""
|
||||||
|
name = filename.lower()
|
||||||
|
if name.endswith(".rnh"):
|
||||||
|
return "log"
|
||||||
|
if name.endswith(".rnd"):
|
||||||
|
return "measurement"
|
||||||
|
if name.endswith(".zip"):
|
||||||
|
return "archive"
|
||||||
|
return "data"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/nrl/{location_id}/upload-data")
|
||||||
|
async def upload_nrl_data(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
files: list[UploadFile] = File(...),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Manually upload SD card data for an offline NRL.
|
||||||
|
|
||||||
|
Accepts either:
|
||||||
|
- A single .zip file (the Auto_#### folder zipped) — auto-extracted
|
||||||
|
- Multiple .rnd / .rnh files selected directly from the SD card folder
|
||||||
|
|
||||||
|
Creates a MonitoringSession from .rnh metadata and DataFile records
|
||||||
|
for each measurement file. No unit assignment required.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Verify project and location exist
|
||||||
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
|
id=location_id, project_id=project_id
|
||||||
|
).first()
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
|
# --- Step 1: Normalize to (filename, bytes) list ---
|
||||||
|
file_entries: list[tuple[str, bytes]] = []
|
||||||
|
|
||||||
|
if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
|
||||||
|
raw = await files[0].read()
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
name = Path(info.filename).name # strip folder path
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
file_entries.append((name, zf.read(info)))
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.")
|
||||||
|
else:
|
||||||
|
for uf in files:
|
||||||
|
data = await uf.read()
|
||||||
|
file_entries.append((uf.filename, data))
|
||||||
|
|
||||||
|
if not file_entries:
|
||||||
|
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 ---
|
||||||
|
rnh_meta = {}
|
||||||
|
for fname, fbytes in file_entries:
|
||||||
|
if fname.lower().endswith(".rnh"):
|
||||||
|
rnh_meta = _parse_rnh(fbytes)
|
||||||
|
break
|
||||||
|
|
||||||
|
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||||
|
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||||
|
duration_seconds = None
|
||||||
|
if started_at and stopped_at:
|
||||||
|
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||||
|
|
||||||
|
store_name = rnh_meta.get("store_name", "")
|
||||||
|
serial_number = rnh_meta.get("serial_number", "")
|
||||||
|
index_number = rnh_meta.get("index_number", "")
|
||||||
|
|
||||||
|
# --- 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())
|
||||||
|
monitoring_session = MonitoringSession(
|
||||||
|
id=session_id,
|
||||||
|
project_id=project_id,
|
||||||
|
location_id=location_id,
|
||||||
|
unit_id=None,
|
||||||
|
session_type="sound",
|
||||||
|
started_at=started_at,
|
||||||
|
stopped_at=stopped_at,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
status="completed",
|
||||||
|
session_label=session_label,
|
||||||
|
period_type=period_type,
|
||||||
|
session_metadata=json.dumps({
|
||||||
|
"source": "manual_upload",
|
||||||
|
"store_name": store_name,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"index_number": index_number,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
db.add(monitoring_session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(monitoring_session)
|
||||||
|
|
||||||
|
# --- Step 4: Write files to disk and create DataFile records ---
|
||||||
|
output_dir = Path("data/Projects") / project_id / session_id
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
leq_count = 0
|
||||||
|
lp_count = 0
|
||||||
|
metadata_count = 0
|
||||||
|
files_imported = 0
|
||||||
|
|
||||||
|
for fname, fbytes in file_entries:
|
||||||
|
file_type = _classify_file(fname)
|
||||||
|
fname_lower = fname.lower()
|
||||||
|
|
||||||
|
# Track counts for summary
|
||||||
|
if fname_lower.endswith(".rnd"):
|
||||||
|
if "_leq_" in fname_lower:
|
||||||
|
leq_count += 1
|
||||||
|
elif "_lp" in fname_lower:
|
||||||
|
lp_count += 1
|
||||||
|
elif fname_lower.endswith(".rnh"):
|
||||||
|
metadata_count += 1
|
||||||
|
|
||||||
|
# Write to disk
|
||||||
|
dest = output_dir / fname
|
||||||
|
dest.write_bytes(fbytes)
|
||||||
|
|
||||||
|
# Compute checksum
|
||||||
|
checksum = hashlib.sha256(fbytes).hexdigest()
|
||||||
|
|
||||||
|
# Store relative path from data/ dir
|
||||||
|
rel_path = str(dest.relative_to("data"))
|
||||||
|
|
||||||
|
data_file = DataFile(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
session_id=session_id,
|
||||||
|
file_path=rel_path,
|
||||||
|
file_type=file_type,
|
||||||
|
file_size_bytes=len(fbytes),
|
||||||
|
downloaded_at=datetime.utcnow(),
|
||||||
|
checksum=checksum,
|
||||||
|
file_metadata=json.dumps({
|
||||||
|
"source": "manual_upload",
|
||||||
|
"original_filename": fname,
|
||||||
|
"store_name": store_name,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
db.add(data_file)
|
||||||
|
files_imported += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"session_id": session_id,
|
||||||
|
"files_imported": files_imported,
|
||||||
|
"leq_files": leq_count,
|
||||||
|
"lp_files": lp_count,
|
||||||
|
"metadata_files": metadata_count,
|
||||||
|
"store_name": store_name,
|
||||||
|
"started_at": started_at.isoformat() if started_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
@@ -497,6 +497,9 @@ async def get_schedule_list_partial(
|
|||||||
"""
|
"""
|
||||||
Return HTML partial for schedule list.
|
Return HTML partial for schedule list.
|
||||||
"""
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
project_status = project.status if project else "active"
|
||||||
|
|
||||||
schedules = db.query(RecurringSchedule).filter_by(
|
schedules = db.query(RecurringSchedule).filter_by(
|
||||||
project_id=project_id
|
project_id=project_id
|
||||||
).order_by(RecurringSchedule.created_at.desc()).all()
|
).order_by(RecurringSchedule.created_at.desc()).all()
|
||||||
@@ -515,4 +518,5 @@ async def get_schedule_list_partial(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"schedules": schedule_data,
|
"schedules": schedule_data,
|
||||||
|
"project_status": project_status,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ async def add_roster_unit(
|
|||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: str = Form(None),
|
||||||
retired: str = Form(None),
|
retired: str = Form(None),
|
||||||
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -177,6 +178,7 @@ async def add_roster_unit(
|
|||||||
# Convert boolean strings to actual booleans
|
# Convert boolean strings to actual booleans
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
|
||||||
|
|
||||||
# Convert port strings to integers
|
# Convert port strings to integers
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
@@ -211,6 +213,7 @@ async def add_roster_unit(
|
|||||||
unit_type=unit_type,
|
unit_type=unit_type,
|
||||||
deployed=deployed_bool,
|
deployed=deployed_bool,
|
||||||
retired=retired_bool,
|
retired=retired_bool,
|
||||||
|
out_for_calibration=out_for_calibration_bool,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location=location,
|
location=location,
|
||||||
@@ -463,6 +466,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"unit_type": unit.unit_type,
|
"unit_type": unit.unit_type,
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
|
"out_for_calibration": unit.out_for_calibration or False,
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"location": unit.location or "",
|
||||||
@@ -494,6 +498,7 @@ async def edit_roster_unit(
|
|||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: str = Form(None),
|
||||||
retired: str = Form(None),
|
retired: str = Form(None),
|
||||||
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -535,6 +540,7 @@ async def edit_roster_unit(
|
|||||||
# Convert boolean strings to actual booleans
|
# Convert boolean strings to actual booleans
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
|
||||||
|
|
||||||
# Convert port strings to integers
|
# Convert port strings to integers
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
@@ -564,12 +570,14 @@ async def edit_roster_unit(
|
|||||||
old_note = unit.note
|
old_note = unit.note
|
||||||
old_deployed = unit.deployed
|
old_deployed = unit.deployed
|
||||||
old_retired = unit.retired
|
old_retired = unit.retired
|
||||||
|
old_out_for_calibration = unit.out_for_calibration
|
||||||
|
|
||||||
# Update all fields
|
# Update all fields
|
||||||
unit.device_type = device_type
|
unit.device_type = device_type
|
||||||
unit.unit_type = unit_type
|
unit.unit_type = unit_type
|
||||||
unit.deployed = deployed_bool
|
unit.deployed = deployed_bool
|
||||||
unit.retired = retired_bool
|
unit.retired = retired_bool
|
||||||
|
unit.out_for_calibration = out_for_calibration_bool
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
unit.location = location
|
unit.location = location
|
||||||
@@ -677,6 +685,11 @@ async def edit_roster_unit(
|
|||||||
old_status_text = "retired" if old_retired else "active"
|
old_status_text = "retired" if old_retired else "active"
|
||||||
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
if old_out_for_calibration != out_for_calibration_bool:
|
||||||
|
status_text = "out_for_calibration" if out_for_calibration_bool else "available"
|
||||||
|
old_status_text = "out_for_calibration" if old_out_for_calibration else "available"
|
||||||
|
record_history(db, unit_id, "calibration_status_change", "out_for_calibration", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
# Handle cascade to paired device
|
# Handle cascade to paired device
|
||||||
cascaded_unit_id = None
|
cascaded_unit_id = None
|
||||||
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
||||||
|
|||||||
@@ -92,15 +92,15 @@ async def rename_unit(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update unit_assignments: {e}")
|
logger.warning(f"Could not update unit_assignments: {e}")
|
||||||
|
|
||||||
# Update recording_sessions table (if exists)
|
# Update monitoring_sessions table (if exists)
|
||||||
try:
|
try:
|
||||||
from backend.models import RecordingSession
|
from backend.models import MonitoringSession
|
||||||
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
|
db.query(MonitoringSession).filter(MonitoringSession.unit_id == old_id).update(
|
||||||
{"unit_id": new_id},
|
{"unit_id": new_id},
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update recording_sessions: {e}")
|
logger.warning(f"Could not update monitoring_sessions: {e}")
|
||||||
|
|
||||||
# Commit all changes
|
# Commit all changes
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
total = len(seismos)
|
total = len(seismos)
|
||||||
deployed = sum(1 for s in seismos if s.deployed)
|
deployed = sum(1 for s in seismos if s.deployed)
|
||||||
benched = sum(1 for s in seismos if not s.deployed)
|
benched = sum(1 for s in seismos if not s.deployed and not s.out_for_calibration)
|
||||||
|
out_for_calibration = sum(1 for s in seismos if s.out_for_calibration)
|
||||||
|
|
||||||
# Count modems assigned to deployed seismographs
|
# Count modems assigned to deployed seismographs
|
||||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||||
@@ -41,6 +42,7 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
"total": total,
|
"total": total,
|
||||||
"deployed": deployed,
|
"deployed": deployed,
|
||||||
"benched": benched,
|
"benched": benched,
|
||||||
|
"out_for_calibration": out_for_calibration,
|
||||||
"with_modem": with_modem,
|
"with_modem": with_modem,
|
||||||
"without_modem": without_modem
|
"without_modem": without_modem
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,9 @@ async def get_seismo_units(
|
|||||||
if status == "deployed":
|
if status == "deployed":
|
||||||
query = query.filter(RosterUnit.deployed == True)
|
query = query.filter(RosterUnit.deployed == True)
|
||||||
elif status == "benched":
|
elif status == "benched":
|
||||||
query = query.filter(RosterUnit.deployed == False)
|
query = query.filter(RosterUnit.deployed == False, RosterUnit.out_for_calibration == False)
|
||||||
|
elif status == "out_for_calibration":
|
||||||
|
query = query.filter(RosterUnit.out_for_calibration == True)
|
||||||
|
|
||||||
# Apply modem filter
|
# Apply modem filter
|
||||||
if modem == "with":
|
if modem == "with":
|
||||||
|
|||||||
@@ -646,22 +646,20 @@ def get_available_units_for_period(
|
|||||||
if unit.id in reserved_unit_ids:
|
if unit.id in reserved_unit_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check calibration through end of period
|
if unit.last_calibrated:
|
||||||
if not unit.last_calibrated:
|
expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||||
continue # Needs calibration
|
cal_status = get_calibration_status(unit, end_date, warning_days)
|
||||||
|
else:
|
||||||
expiry_date = unit.last_calibrated + timedelta(days=365)
|
expiry_date = None
|
||||||
if expiry_date <= end_date:
|
cal_status = "needs_calibration"
|
||||||
continue # Calibration expires during period
|
|
||||||
|
|
||||||
cal_status = get_calibration_status(unit, end_date, warning_days)
|
|
||||||
|
|
||||||
available_units.append({
|
available_units.append({
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"last_calibrated": unit.last_calibrated.isoformat(),
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||||
"expiry_date": expiry_date.isoformat(),
|
"expiry_date": expiry_date.isoformat() if expiry_date else None,
|
||||||
"calibration_status": cal_status,
|
"calibration_status": cal_status,
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
|
"out_for_calibration": unit.out_for_calibration or False,
|
||||||
"note": unit.note or ""
|
"note": unit.note or ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment
|
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment, Project
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -332,10 +332,12 @@ class RecurringScheduleService:
|
|||||||
)
|
)
|
||||||
actions.append(start_action)
|
actions.append(start_action)
|
||||||
|
|
||||||
# Create STOP action
|
# Create STOP action (stop_cycle handles download when include_download is True)
|
||||||
stop_notes = json.dumps({
|
stop_notes = json.dumps({
|
||||||
"schedule_name": schedule.name,
|
"schedule_name": schedule.name,
|
||||||
"schedule_id": schedule.id,
|
"schedule_id": schedule.id,
|
||||||
|
"schedule_type": "weekly_calendar",
|
||||||
|
"include_download": schedule.include_download,
|
||||||
})
|
})
|
||||||
stop_action = ScheduledAction(
|
stop_action = ScheduledAction(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -350,27 +352,6 @@ class RecurringScheduleService:
|
|||||||
)
|
)
|
||||||
actions.append(stop_action)
|
actions.append(stop_action)
|
||||||
|
|
||||||
# Create DOWNLOAD action if enabled (1 minute after stop)
|
|
||||||
if schedule.include_download:
|
|
||||||
download_time = end_utc + timedelta(minutes=1)
|
|
||||||
download_notes = json.dumps({
|
|
||||||
"schedule_name": schedule.name,
|
|
||||||
"schedule_id": schedule.id,
|
|
||||||
"schedule_type": "weekly_calendar",
|
|
||||||
})
|
|
||||||
download_action = ScheduledAction(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
project_id=schedule.project_id,
|
|
||||||
location_id=schedule.location_id,
|
|
||||||
unit_id=unit_id,
|
|
||||||
action_type="download",
|
|
||||||
device_type=schedule.device_type,
|
|
||||||
scheduled_time=download_time,
|
|
||||||
execution_status="pending",
|
|
||||||
notes=download_notes,
|
|
||||||
)
|
|
||||||
actions.append(download_action)
|
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
def _generate_interval_actions(
|
def _generate_interval_actions(
|
||||||
@@ -613,8 +594,16 @@ class RecurringScheduleService:
|
|||||||
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
|
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
def get_enabled_schedules(self) -> List[RecurringSchedule]:
|
def get_enabled_schedules(self) -> List[RecurringSchedule]:
|
||||||
"""Get all enabled recurring schedules."""
|
"""Get all enabled recurring schedules for projects that are not on hold or deleted."""
|
||||||
return self.db.query(RecurringSchedule).filter_by(enabled=True).all()
|
active_project_ids = [
|
||||||
|
p.id for p in self.db.query(Project.id).filter(
|
||||||
|
Project.status.notin_(["on_hold", "archived", "deleted"])
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
return self.db.query(RecurringSchedule).filter(
|
||||||
|
RecurringSchedule.enabled == True,
|
||||||
|
RecurringSchedule.project_id.in_(active_project_ids),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:
|
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
from backend.database import SessionLocal
|
from backend.database import SessionLocal
|
||||||
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
|
from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule
|
||||||
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
||||||
from backend.services.alert_service import get_alert_service
|
from backend.services.alert_service import get_alert_service
|
||||||
import uuid
|
import uuid
|
||||||
@@ -107,10 +107,19 @@ class SchedulerService:
|
|||||||
try:
|
try:
|
||||||
# Find pending actions that are due
|
# Find pending actions that are due
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Only execute actions for active/completed projects (not on_hold, archived, or deleted)
|
||||||
|
active_project_ids = [
|
||||||
|
p.id for p in db.query(Project.id).filter(
|
||||||
|
Project.status.notin_(["on_hold", "archived", "deleted"])
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
|
||||||
pending_actions = db.query(ScheduledAction).filter(
|
pending_actions = db.query(ScheduledAction).filter(
|
||||||
and_(
|
and_(
|
||||||
ScheduledAction.execution_status == "pending",
|
ScheduledAction.execution_status == "pending",
|
||||||
ScheduledAction.scheduled_time <= now,
|
ScheduledAction.scheduled_time <= now,
|
||||||
|
ScheduledAction.project_id.in_(active_project_ids),
|
||||||
)
|
)
|
||||||
).order_by(ScheduledAction.scheduled_time).all()
|
).order_by(ScheduledAction.scheduled_time).all()
|
||||||
|
|
||||||
@@ -263,7 +272,7 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create recording session
|
# Create recording session
|
||||||
session = RecordingSession(
|
session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=action.project_id,
|
project_id=action.project_id,
|
||||||
location_id=action.location_id,
|
location_id=action.location_id,
|
||||||
@@ -295,9 +304,20 @@ class SchedulerService:
|
|||||||
stop_cycle handles:
|
stop_cycle handles:
|
||||||
1. Stop measurement
|
1. Stop measurement
|
||||||
2. Enable FTP
|
2. Enable FTP
|
||||||
3. Download measurement folder
|
3. Download measurement folder to SLMM local storage
|
||||||
4. Verify download
|
|
||||||
|
After stop_cycle, if download succeeded, this method fetches the ZIP
|
||||||
|
from SLMM and extracts it into Terra-View's project directory, creating
|
||||||
|
DataFile records for each file.
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
import httpx
|
||||||
|
from pathlib import Path
|
||||||
|
from backend.models import DataFile
|
||||||
|
|
||||||
# Parse notes for download preference
|
# Parse notes for download preference
|
||||||
include_download = True
|
include_download = True
|
||||||
try:
|
try:
|
||||||
@@ -308,7 +328,7 @@ class SchedulerService:
|
|||||||
pass # Notes is plain text, not JSON
|
pass # Notes is plain text, not JSON
|
||||||
|
|
||||||
# Execute the full stop cycle via device controller
|
# Execute the full stop cycle via device controller
|
||||||
# SLMM handles stop, FTP enable, and download
|
# SLMM handles stop, FTP enable, and download to SLMM-local storage
|
||||||
cycle_response = await self.device_controller.stop_cycle(
|
cycle_response = await self.device_controller.stop_cycle(
|
||||||
unit_id,
|
unit_id,
|
||||||
action.device_type,
|
action.device_type,
|
||||||
@@ -316,11 +336,11 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Find and update the active recording session
|
# Find and update the active recording session
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == action.location_id,
|
MonitoringSession.location_id == action.location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -340,10 +360,81 @@ class SchedulerService:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If SLMM downloaded the folder successfully, fetch the ZIP from SLMM
|
||||||
|
# and extract it into Terra-View's project directory, creating DataFile records
|
||||||
|
files_created = 0
|
||||||
|
if include_download and cycle_response.get("download_success") and active_session:
|
||||||
|
folder_name = cycle_response.get("downloaded_folder") # e.g. "Auto_0058"
|
||||||
|
remote_path = f"/NL-43/{folder_name}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
async with httpx.AsyncClient(timeout=600.0) as client:
|
||||||
|
zip_response = await client.post(
|
||||||
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder",
|
||||||
|
json={"remote_path": remote_path}
|
||||||
|
)
|
||||||
|
|
||||||
|
if zip_response.is_success and len(zip_response.content) > 22:
|
||||||
|
base_dir = Path(f"data/Projects/{action.project_id}/{active_session.id}/{folder_name}")
|
||||||
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
file_type_map = {
|
||||||
|
'.wav': 'audio', '.mp3': 'audio',
|
||||||
|
'.csv': 'data', '.txt': 'data', '.json': 'data', '.dat': 'data',
|
||||||
|
'.rnd': 'data', '.rnh': 'data',
|
||||||
|
'.log': 'log',
|
||||||
|
'.zip': 'archive',
|
||||||
|
'.jpg': 'image', '.jpeg': 'image', '.png': 'image',
|
||||||
|
'.pdf': 'document',
|
||||||
|
}
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_response.content)) as zf:
|
||||||
|
for zip_info in zf.filelist:
|
||||||
|
if zip_info.is_dir():
|
||||||
|
continue
|
||||||
|
file_data = zf.read(zip_info.filename)
|
||||||
|
file_path = base_dir / zip_info.filename
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
f.write(file_data)
|
||||||
|
checksum = hashlib.sha256(file_data).hexdigest()
|
||||||
|
ext = os.path.splitext(zip_info.filename)[1].lower()
|
||||||
|
data_file = DataFile(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
session_id=active_session.id,
|
||||||
|
file_path=str(file_path.relative_to("data")),
|
||||||
|
file_type=file_type_map.get(ext, 'data'),
|
||||||
|
file_size_bytes=len(file_data),
|
||||||
|
downloaded_at=datetime.utcnow(),
|
||||||
|
checksum=checksum,
|
||||||
|
file_metadata=json.dumps({
|
||||||
|
"source": "stop_cycle",
|
||||||
|
"remote_path": remote_path,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"folder_name": folder_name,
|
||||||
|
"relative_path": zip_info.filename,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
db.add(data_file)
|
||||||
|
files_created += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Created {files_created} DataFile records for session {active_session.id} from {folder_name}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"ZIP from SLMM for {folder_name} was empty or failed, skipping DataFile creation")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to extract ZIP and create DataFile records for {folder_name}: {e}")
|
||||||
|
# Don't fail the stop action — the device was stopped successfully
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "stopped",
|
"status": "stopped",
|
||||||
"session_id": active_session.id if active_session else None,
|
"session_id": active_session.id if active_session else None,
|
||||||
"cycle_response": cycle_response,
|
"cycle_response": cycle_response,
|
||||||
|
"files_created": files_created,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _execute_download(
|
async def _execute_download(
|
||||||
@@ -526,11 +617,11 @@ class SchedulerService:
|
|||||||
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
||||||
|
|
||||||
# Close out the old recording session
|
# Close out the old recording session
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == action.location_id,
|
MonitoringSession.location_id == action.location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -557,7 +648,7 @@ class SchedulerService:
|
|||||||
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
||||||
|
|
||||||
# Create new recording session
|
# Create new recording session
|
||||||
new_session = RecordingSession(
|
new_session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=action.project_id,
|
project_id=action.project_id,
|
||||||
location_id=action.location_id,
|
location_id=action.location_id,
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ class SLMMClient:
|
|||||||
|
|
||||||
# Format as Auto_XXXX folder name
|
# Format as Auto_XXXX folder name
|
||||||
folder_name = f"Auto_{index_number:04d}"
|
folder_name = f"Auto_{index_number:04d}"
|
||||||
remote_path = f"/NL43_DATA/{folder_name}"
|
remote_path = f"/NL-43/{folder_name}"
|
||||||
|
|
||||||
# Download the folder
|
# Download the folder
|
||||||
result = await self.download_folder(unit_id, remote_path)
|
result = await self.download_folder(unit_id, remote_path)
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ def emit_status_snapshot():
|
|||||||
age = "N/A"
|
age = "N/A"
|
||||||
last_seen = None
|
last_seen = None
|
||||||
fname = ""
|
fname = ""
|
||||||
|
elif r.out_for_calibration:
|
||||||
|
# Out for calibration units get separated later
|
||||||
|
status = "Out for Calibration"
|
||||||
|
age = "N/A"
|
||||||
|
last_seen = None
|
||||||
|
fname = ""
|
||||||
else:
|
else:
|
||||||
if e:
|
if e:
|
||||||
last_seen = ensure_utc(e.last_seen)
|
last_seen = ensure_utc(e.last_seen)
|
||||||
@@ -103,6 +109,7 @@ def emit_status_snapshot():
|
|||||||
"deployed": r.deployed,
|
"deployed": r.deployed,
|
||||||
"note": r.note or "",
|
"note": r.note or "",
|
||||||
"retired": r.retired,
|
"retired": r.retired,
|
||||||
|
"out_for_calibration": r.out_for_calibration or False,
|
||||||
# Device type and type-specific fields
|
# Device type and type-specific fields
|
||||||
"device_type": r.device_type or "seismograph",
|
"device_type": r.device_type or "seismograph",
|
||||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
@@ -133,6 +140,7 @@ def emit_status_snapshot():
|
|||||||
"deployed": False, # default
|
"deployed": False, # default
|
||||||
"note": "",
|
"note": "",
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
# Device type and type-specific fields (defaults for unknown units)
|
# Device type and type-specific fields (defaults for unknown units)
|
||||||
"device_type": "seismograph", # default
|
"device_type": "seismograph", # default
|
||||||
"last_calibrated": None,
|
"last_calibrated": None,
|
||||||
@@ -179,12 +187,12 @@ def emit_status_snapshot():
|
|||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
if not u["retired"] and u["deployed"] and uid not in ignored
|
if not u["retired"] and not u["out_for_calibration"] and u["deployed"] and uid not in ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
benched_units = {
|
benched_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
if not u["retired"] and not u["deployed"] and uid not in ignored
|
if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
retired_units = {
|
retired_units = {
|
||||||
@@ -192,6 +200,11 @@ def emit_status_snapshot():
|
|||||||
if u["retired"]
|
if u["retired"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out_for_calibration_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if u["out_for_calibration"]
|
||||||
|
}
|
||||||
|
|
||||||
# Unknown units - emitters that aren't in the roster and aren't ignored
|
# Unknown units - emitters that aren't in the roster and aren't ignored
|
||||||
unknown_units = {
|
unknown_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
@@ -204,12 +217,14 @@ def emit_status_snapshot():
|
|||||||
"active": active_units,
|
"active": active_units,
|
||||||
"benched": benched_units,
|
"benched": benched_units,
|
||||||
"retired": retired_units,
|
"retired": retired_units,
|
||||||
|
"out_for_calibration": out_for_calibration_units,
|
||||||
"unknown": unknown_units,
|
"unknown": unknown_units,
|
||||||
"summary": {
|
"summary": {
|
||||||
"total": len(active_units) + len(benched_units),
|
"total": len(active_units) + len(benched_units),
|
||||||
"active": len(active_units),
|
"active": len(active_units),
|
||||||
"benched": len(benched_units),
|
"benched": len(benched_units),
|
||||||
"retired": len(retired_units),
|
"retired": len(retired_units),
|
||||||
|
"out_for_calibration": len(out_for_calibration_units),
|
||||||
"unknown": len(unknown_units),
|
"unknown": len(unknown_units),
|
||||||
# Status counts only for deployed units (active_units)
|
# Status counts only for deployed units (active_units)
|
||||||
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"filename": "snapshot_20251216_201738.db",
|
|
||||||
"created_at": "20251216_201738",
|
|
||||||
"created_at_iso": "2025-12-16T20:17:38.638982",
|
|
||||||
"description": "Auto-backup before restore",
|
|
||||||
"size_bytes": 57344,
|
|
||||||
"size_mb": 0.05,
|
|
||||||
"original_db_size_bytes": 57344,
|
|
||||||
"type": "manual"
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"filename": "snapshot_uploaded_20251216_201732.db",
|
|
||||||
"created_at": "20251216_201732",
|
|
||||||
"created_at_iso": "2025-12-16T20:17:32.574205",
|
|
||||||
"description": "Uploaded: snapshot_20251216_200259.db",
|
|
||||||
"size_bytes": 77824,
|
|
||||||
"size_mb": 0.07,
|
|
||||||
"type": "uploaded"
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# --- TERRA-VIEW PRODUCTION ---
|
|
||||||
terra-view:
|
terra-view:
|
||||||
build: .
|
build: .
|
||||||
container_name: terra-view
|
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -24,36 +22,11 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# --- TERRA-VIEW DEVELOPMENT ---
|
|
||||||
terra-view-dev:
|
|
||||||
build: .
|
|
||||||
container_name: terra-view-dev
|
|
||||||
ports:
|
|
||||||
- "1001:8001"
|
|
||||||
volumes:
|
|
||||||
- ./data-dev:/app/data
|
|
||||||
environment:
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
- ENVIRONMENT=development
|
|
||||||
- SLMM_BASE_URL=http://host.docker.internal:8100
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- slmm
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
|
|
||||||
# --- SLMM (Sound Level Meter Manager) ---
|
# --- SLMM (Sound Level Meter Manager) ---
|
||||||
slmm:
|
slmm:
|
||||||
build:
|
build:
|
||||||
context: ../slmm
|
context: ../slmm
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: slmm
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
- ../slmm/data:/app/data
|
- ../slmm/data:/app/data
|
||||||
@@ -61,6 +34,8 @@ services:
|
|||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PORT=8100
|
- PORT=8100
|
||||||
- CORS_ORIGINS=*
|
- CORS_ORIGINS=*
|
||||||
|
- TCP_IDLE_TTL=-1
|
||||||
|
- TCP_MAX_AGE=-1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
||||||
@@ -71,4 +46,3 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
data-dev:
|
|
||||||
|
|||||||
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
Executable file
12
rebuild-prod.sh
Executable 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"
|
||||||
@@ -8,7 +8,7 @@ import sys
|
|||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
DATABASE_URL = "sqlite:///data/sfm.db"
|
DATABASE_URL = "sqlite:///data/seismo_fleet.db"
|
||||||
|
|
||||||
def rename_unit(old_id: str, new_id: str):
|
def rename_unit(old_id: str, new_id: str):
|
||||||
"""
|
"""
|
||||||
@@ -90,14 +90,14 @@ def rename_unit(old_id: str, new_id: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Table may not exist
|
pass # Table may not exist
|
||||||
|
|
||||||
# Update recording_sessions table (if exists)
|
# Update monitoring_sessions table (if exists)
|
||||||
try:
|
try:
|
||||||
result = session.execute(
|
result = session.execute(
|
||||||
text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
text("UPDATE monitoring_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||||
{"new_id": new_id, "old_id": old_id}
|
{"new_id": new_id, "old_id": old_id}
|
||||||
)
|
)
|
||||||
if result.rowcount > 0:
|
if result.rowcount > 0:
|
||||||
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)")
|
print(f" ✓ Updated monitoring_sessions ({result.rowcount} rows)")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Table may not exist
|
pass # Table may not exist
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<!-- Sidebar (Responsive) -->
|
<!-- Sidebar (Responsive) -->
|
||||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<a href="/" class="block">
|
<a href="/" class="block">
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
<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>
|
</svg>
|
||||||
Fleet Calendar
|
Reservation Planner
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
@@ -193,14 +193,14 @@
|
|||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="main-content flex-1 overflow-y-auto">
|
<main class="main-content flex-1 overflow-y-auto">
|
||||||
<div class="p-8">
|
<div class="{% if request.query_params.get('embed') == '1' %}p-4{% else %}p-8{% endif %}">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation (Mobile Only) -->
|
<!-- Bottom Navigation (Mobile Only) -->
|
||||||
<nav class="bottom-nav">
|
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||||
<div class="grid grid-cols-4 h-16">
|
<div class="grid grid-cols-4 h-16">
|
||||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
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 %}
|
||||||
@@ -223,10 +223,23 @@
|
|||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Calendar</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Calendar</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Plan unit assignments and track calibrations</p>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Plan unit assignments and track calibrations</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- View Tabs -->
|
||||||
|
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
|
||||||
|
<button id="tab-btn-planner" onclick="switchTab('planner')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
|
||||||
|
Reservation Planner
|
||||||
|
</button>
|
||||||
|
<button id="tab-btn-calendar" onclick="switchTab('calendar')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Calendar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="view-calendar" class="hidden">
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 shadow">
|
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 shadow">
|
||||||
@@ -376,12 +389,234 @@
|
|||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Reservations</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Reservations</h2>
|
||||||
<div id="reservations-list"
|
<div id="reservations-list"
|
||||||
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
|
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
|
||||||
hx-trigger="load"
|
hx-trigger="calendar-reservations-refresh from:body"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<p class="text-gray-500">Loading reservations...</p>
|
<p class="text-gray-500">Loading reservations...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div><!-- end #view-calendar -->
|
||||||
|
|
||||||
|
<!-- Reservation Planner View -->
|
||||||
|
<div id="view-planner">
|
||||||
|
<div class="flex flex-col lg:flex-row gap-6 min-h-[70vh]">
|
||||||
|
|
||||||
|
<!-- LEFT PANEL: sub-tabs switch content here only -->
|
||||||
|
<div class="lg:w-2/5 flex flex-col gap-4">
|
||||||
|
|
||||||
|
<!-- Sub-tab bar -->
|
||||||
|
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 w-fit">
|
||||||
|
<button id="ptab-btn-list" onclick="switchPlannerTab('list')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
|
||||||
|
Reservations
|
||||||
|
</button>
|
||||||
|
<button id="ptab-btn-assign" onclick="switchPlannerTab('assign')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Assign Units
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-tab: Reservations list -->
|
||||||
|
<div id="ptab-list" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Project Reservations</h2>
|
||||||
|
<button onclick="plannerReset(); switchPlannerTab('assign')"
|
||||||
|
class="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm 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="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="planner-reservations-list" class="overflow-y-visible"
|
||||||
|
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-gray-500">Loading reservations...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-tab: Assign Units form -->
|
||||||
|
<div id="ptab-assign" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4 flex-1">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="switchPlannerTab('list')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" title="Back to reservations">
|
||||||
|
<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="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="planner-form-title">New Reservation</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata fields: only shown when creating a new reservation -->
|
||||||
|
<div id="planner-meta-fields">
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Job / Reservation Name *</label>
|
||||||
|
<input type="text" id="planner-name" placeholder="e.g., Pine Street – May Deployment"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Type -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Device Type *</label>
|
||||||
|
<div class="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
|
||||||
|
<label class="flex-1 cursor-pointer">
|
||||||
|
<input type="radio" name="planner_device_type" value="seismograph" checked class="sr-only peer" onchange="plannerDatesChanged()">
|
||||||
|
<span class="block text-center px-4 py-2 text-sm font-medium bg-white dark:bg-slate-700 peer-checked:bg-blue-600 peer-checked:text-white text-gray-700 dark:text-gray-300 transition-colors">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex-1 cursor-pointer border-l border-gray-300 dark:border-gray-600">
|
||||||
|
<input type="radio" name="planner_device_type" value="slm" class="sr-only peer" onchange="plannerDatesChanged()">
|
||||||
|
<span class="block text-center px-4 py-2 text-sm font-medium bg-white dark:bg-slate-700 peer-checked:bg-blue-600 peer-checked:text-white text-gray-700 dark:text-gray-300 transition-colors">
|
||||||
|
Sound Level Meter
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Link to Project (optional)</label>
|
||||||
|
<select id="planner-project"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">-- No project --</option>
|
||||||
|
{% for project in projects %}
|
||||||
|
<option value="{{ project.id }}">{{ project.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dates -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date *</label>
|
||||||
|
<input type="date" id="planner-start"
|
||||||
|
onchange="plannerDatesChanged()"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date *</label>
|
||||||
|
<input type="date" id="planner-end"
|
||||||
|
onchange="plannerDatesChanged()"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
|
||||||
|
<div class="flex gap-2" id="planner-colors">
|
||||||
|
{% for color in ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] %}
|
||||||
|
<label class="cursor-pointer">
|
||||||
|
<input type="radio" name="planner_color" value="{{ color }}" {% if loop.first %}checked{% endif %} class="sr-only peer">
|
||||||
|
<span class="block w-7 h-7 rounded-full peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-900 dark:peer-checked:ring-white"
|
||||||
|
style="background-color: {{ color }}"></span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estimated Units Needed -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estimated Units Needed</label>
|
||||||
|
<input type="number" id="planner-est-units" min="1" placeholder="e.g. 5"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- end #planner-meta-fields -->
|
||||||
|
|
||||||
|
<!-- Monitoring Locations -->
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Monitoring Locations</h3>
|
||||||
|
<button onclick="plannerAddSlot()"
|
||||||
|
class="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-700 dark:text-gray-300">
|
||||||
|
+ Add Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="planner-slots" class="flex flex-col gap-2 overflow-y-auto max-h-72">
|
||||||
|
<!-- Locations rendered by JS -->
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4" id="planner-slots-empty">
|
||||||
|
Set dates and click "+ Add Location" to start adding units
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes (optional)</label>
|
||||||
|
<textarea id="planner-notes" rows="2" placeholder="Optional notes"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<div class="flex gap-3 pt-2 border-t border-gray-200 dark:border-gray-700 mt-auto">
|
||||||
|
<button onclick="plannerReset()"
|
||||||
|
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-sm">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button onclick="plannerSave()"
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium" id="planner-save-btn">
|
||||||
|
Save Reservation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div><!-- end ptab-assign -->
|
||||||
|
|
||||||
|
</div><!-- end left panel -->
|
||||||
|
|
||||||
|
<!-- RIGHT: Available Units (always visible) -->
|
||||||
|
<div class="lg:w-3/5 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Available Units
|
||||||
|
<span id="planner-avail-count" class="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400"></span>
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input type="checkbox" id="planner-deployed-only" onchange="plannerFilterUnits()"
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
||||||
|
Deployed only
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input type="checkbox" id="planner-benched-only" onchange="plannerFilterUnits()"
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
||||||
|
Benched only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="text" id="planner-search" placeholder="Search by unit ID..."
|
||||||
|
oninput="plannerFilterUnits()"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||||
|
|
||||||
|
<div id="planner-units-list" class="flex flex-col gap-1 overflow-y-auto flex-1" style="max-height: 55vh;">
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8" id="planner-units-placeholder">
|
||||||
|
Set start and end dates to see available units
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div><!-- end right panel -->
|
||||||
|
|
||||||
|
</div><!-- end flex row -->
|
||||||
|
</div><!-- end view-planner -->
|
||||||
|
|
||||||
|
<!-- Unit Detail Modal (planner) -->
|
||||||
|
<div id="unit-detail-modal" class="fixed inset-0 z-50 hidden">
|
||||||
|
<div class="fixed inset-0 bg-black/50" onclick="closeUnitDetailModal()"></div>
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col" onclick="event.stopPropagation()">
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white" id="unit-detail-modal-title">Unit Detail</h3>
|
||||||
|
<button onclick="closeUnitDetailModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<iframe id="unit-detail-iframe" src="" class="flex-1 rounded-b-xl" style="min-height: 70vh; border: none;"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Day Detail Slide Panel -->
|
<!-- Day Detail Slide Panel -->
|
||||||
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
|
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
|
||||||
<div id="day-panel" class="slide-panel">
|
<div id="day-panel" class="slide-panel">
|
||||||
@@ -397,13 +632,14 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">New Reservation</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="modal-title">New Reservation</h2>
|
||||||
<button onclick="closeReservationModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
<button onclick="closeReservationModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" id="editing-reservation-id" value="">
|
||||||
|
|
||||||
<form id="reservation-form" onsubmit="submitReservation(event)">
|
<form id="reservation-form" onsubmit="submitReservation(event)">
|
||||||
<!-- Name -->
|
<!-- Name -->
|
||||||
@@ -522,7 +758,7 @@
|
|||||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit"
|
<button type="submit" id="modal-submit-btn"
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
Create Reservation
|
Create Reservation
|
||||||
</button>
|
</button>
|
||||||
@@ -600,27 +836,115 @@ function closeDayPanel() {
|
|||||||
let reservationModeActive = false;
|
let reservationModeActive = false;
|
||||||
|
|
||||||
function openReservationModal() {
|
function openReservationModal() {
|
||||||
|
// Reset to "create" mode
|
||||||
|
document.getElementById('modal-title').textContent = 'New Reservation';
|
||||||
|
document.getElementById('modal-submit-btn').textContent = 'Create Reservation';
|
||||||
|
document.getElementById('editing-reservation-id').value = '';
|
||||||
|
document.getElementById('reservation-form').reset();
|
||||||
|
|
||||||
document.getElementById('reservation-modal').classList.remove('hidden');
|
document.getElementById('reservation-modal').classList.remove('hidden');
|
||||||
reservationModeActive = true;
|
reservationModeActive = true;
|
||||||
// Show reservation legend, hide main legend
|
|
||||||
document.getElementById('main-legend').classList.add('md:hidden');
|
document.getElementById('main-legend').classList.add('md:hidden');
|
||||||
document.getElementById('main-legend').classList.remove('md:flex');
|
document.getElementById('main-legend').classList.remove('md:flex');
|
||||||
document.getElementById('reservation-legend').classList.remove('md:hidden');
|
document.getElementById('reservation-legend').classList.remove('md:hidden');
|
||||||
document.getElementById('reservation-legend').classList.add('md:flex');
|
document.getElementById('reservation-legend').classList.add('md:flex');
|
||||||
// Trigger availability update
|
|
||||||
updateCalendarAvailability();
|
updateCalendarAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleResCard(id) {
|
||||||
|
const detail = document.getElementById('res-detail-' + id);
|
||||||
|
const chevron = document.getElementById('chevron-' + id);
|
||||||
|
if (!detail) return;
|
||||||
|
const isHidden = detail.classList.contains('hidden');
|
||||||
|
if (isHidden) {
|
||||||
|
detail.classList.remove('hidden');
|
||||||
|
detail.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
detail.classList.add('hidden');
|
||||||
|
detail.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (chevron) chevron.style.transform = isHidden ? 'rotate(180deg)' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function deleteReservation(id, name) {
|
||||||
|
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, { method: 'DELETE' });
|
||||||
|
if (response.ok) {
|
||||||
|
htmx.trigger('#planner-reservations-list', 'load');
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert('Error: ' + (data.detail || 'Failed to delete'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error deleting reservation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editReservation(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/fleet-calendar/reservations/${id}`);
|
||||||
|
if (!response.ok) { alert('Failed to load reservation'); return; }
|
||||||
|
const res = await response.json();
|
||||||
|
|
||||||
|
// Switch modal to "edit" mode
|
||||||
|
document.getElementById('modal-title').textContent = 'Edit Reservation';
|
||||||
|
document.getElementById('modal-submit-btn').textContent = 'Save Changes';
|
||||||
|
document.getElementById('editing-reservation-id').value = id;
|
||||||
|
|
||||||
|
// Populate fields
|
||||||
|
const form = document.getElementById('reservation-form');
|
||||||
|
form.querySelector('input[name="name"]').value = res.name;
|
||||||
|
form.querySelector('select[name="project_id"]').value = res.project_id || '';
|
||||||
|
form.querySelector('input[name="start_date"]').value = res.start_date;
|
||||||
|
form.querySelector('textarea[name="notes"]').value = res.notes || '';
|
||||||
|
|
||||||
|
// Color radio
|
||||||
|
const colorRadio = form.querySelector(`input[name="color"][value="${res.color}"]`);
|
||||||
|
if (colorRadio) colorRadio.checked = true;
|
||||||
|
|
||||||
|
// Assignment type
|
||||||
|
const atRadio = form.querySelector(`input[name="assignment_type"][value="${res.assignment_type}"]`);
|
||||||
|
if (atRadio) { atRadio.checked = true; toggleAssignmentType(res.assignment_type); }
|
||||||
|
if (res.assignment_type === 'quantity') {
|
||||||
|
form.querySelector('input[name="quantity_needed"]').value = res.quantity_needed || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// End date / TBD
|
||||||
|
const tbdCheckbox = document.getElementById('end_date_tbd');
|
||||||
|
if (res.end_date_tbd) {
|
||||||
|
tbdCheckbox.checked = true;
|
||||||
|
form.querySelector('input[name="estimated_end_date"]').value = res.estimated_end_date || '';
|
||||||
|
} else {
|
||||||
|
tbdCheckbox.checked = false;
|
||||||
|
document.getElementById('end_date_input').value = res.end_date || '';
|
||||||
|
}
|
||||||
|
toggleEndDateTBD();
|
||||||
|
|
||||||
|
document.getElementById('reservation-modal').classList.remove('hidden');
|
||||||
|
reservationModeActive = true;
|
||||||
|
document.getElementById('main-legend').classList.add('md:hidden');
|
||||||
|
document.getElementById('main-legend').classList.remove('md:flex');
|
||||||
|
document.getElementById('reservation-legend').classList.remove('md:hidden');
|
||||||
|
document.getElementById('reservation-legend').classList.add('md:flex');
|
||||||
|
updateCalendarAvailability();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading reservation:', error);
|
||||||
|
alert('Error loading reservation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeReservationModal() {
|
function closeReservationModal() {
|
||||||
document.getElementById('reservation-modal').classList.add('hidden');
|
document.getElementById('reservation-modal').classList.add('hidden');
|
||||||
document.getElementById('reservation-form').reset();
|
document.getElementById('reservation-form').reset();
|
||||||
|
document.getElementById('editing-reservation-id').value = '';
|
||||||
reservationModeActive = false;
|
reservationModeActive = false;
|
||||||
// Restore main legend
|
|
||||||
document.getElementById('main-legend').classList.remove('md:hidden');
|
document.getElementById('main-legend').classList.remove('md:hidden');
|
||||||
document.getElementById('main-legend').classList.add('md:flex');
|
document.getElementById('main-legend').classList.add('md:flex');
|
||||||
document.getElementById('reservation-legend').classList.add('md:hidden');
|
document.getElementById('reservation-legend').classList.add('md:hidden');
|
||||||
document.getElementById('reservation-legend').classList.remove('md:flex');
|
document.getElementById('reservation-legend').classList.remove('md:flex');
|
||||||
// Reset calendar colors
|
|
||||||
resetCalendarColors();
|
resetCalendarColors();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,6 +1076,7 @@ async function submitReservation(event) {
|
|||||||
const form = event.target;
|
const form = event.target;
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const endDateTbd = formData.get('end_date_tbd') === 'on';
|
const endDateTbd = formData.get('end_date_tbd') === 'on';
|
||||||
|
const editingId = document.getElementById('editing-reservation-id').value;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: formData.get('name'),
|
name: formData.get('name'),
|
||||||
@@ -766,7 +1091,6 @@ async function submitReservation(event) {
|
|||||||
notes: formData.get('notes') || null
|
notes: formData.get('notes') || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate: need either end_date or TBD checked
|
|
||||||
if (!data.end_date && !data.end_date_tbd) {
|
if (!data.end_date && !data.end_date_tbd) {
|
||||||
alert('Please enter an end date or check "TBD / Ongoing"');
|
alert('Please enter an end date or check "TBD / Ongoing"');
|
||||||
return;
|
return;
|
||||||
@@ -778,9 +1102,15 @@ async function submitReservation(event) {
|
|||||||
data.unit_ids = formData.getAll('unit_ids');
|
data.unit_ids = formData.getAll('unit_ids');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEdit = editingId !== '';
|
||||||
|
const url = isEdit
|
||||||
|
? `/api/fleet-calendar/reservations/${editingId}`
|
||||||
|
: '/api/fleet-calendar/reservations';
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/fleet-calendar/reservations', {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
@@ -789,14 +1119,13 @@ async function submitReservation(event) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
closeReservationModal();
|
closeReservationModal();
|
||||||
// Reload the page to refresh calendar
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
alert('Error creating reservation: ' + (result.detail || 'Unknown error'));
|
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation: ` + (result.detail || 'Unknown error'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
alert('Error creating reservation');
|
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,5 +1136,446 @@ document.addEventListener('keydown', function(e) {
|
|||||||
closeReservationModal();
|
closeReservationModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tab + sub-tab switching
|
||||||
|
// ============================================================
|
||||||
|
function switchPlannerTab(tab) {
|
||||||
|
const isAssign = tab === 'assign';
|
||||||
|
document.getElementById('ptab-list').classList.toggle('hidden', isAssign);
|
||||||
|
document.getElementById('ptab-assign').classList.toggle('hidden', !isAssign);
|
||||||
|
|
||||||
|
['list', 'assign'].forEach(t => {
|
||||||
|
const btn = document.getElementById(`ptab-btn-${t}`);
|
||||||
|
if (t === tab) {
|
||||||
|
btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
|
||||||
|
btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
|
||||||
|
btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.getElementById('view-calendar').classList.toggle('hidden', tab !== 'calendar');
|
||||||
|
document.getElementById('view-planner').classList.toggle('hidden', tab !== 'planner');
|
||||||
|
if (tab === 'calendar') htmx.trigger(document.body, 'calendar-reservations-refresh');
|
||||||
|
|
||||||
|
['calendar', 'planner'].forEach(t => {
|
||||||
|
const btn = document.getElementById(`tab-btn-${t}`);
|
||||||
|
if (t === tab) {
|
||||||
|
btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
|
||||||
|
btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
|
||||||
|
btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Reservation Planner
|
||||||
|
// ============================================================
|
||||||
|
let plannerState = {
|
||||||
|
reservation_id: null, // null = creating new
|
||||||
|
slots: [], // array of {unit_id: string|null, power_type: string|null, notes: string|null}
|
||||||
|
allUnits: [] // full list from server
|
||||||
|
};
|
||||||
|
let dragSrcIdx = null;
|
||||||
|
|
||||||
|
function plannerDatesChanged() {
|
||||||
|
plannerLoadUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function plannerLoadUnits() {
|
||||||
|
const start = document.getElementById('planner-start').value;
|
||||||
|
const end = document.getElementById('planner-end').value;
|
||||||
|
const excludeId = plannerState.reservation_id || '';
|
||||||
|
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
|
||||||
|
|
||||||
|
let url = `/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`;
|
||||||
|
if (start && end && end >= start) {
|
||||||
|
url += `&start_date=${start}&end_date=${end}`;
|
||||||
|
}
|
||||||
|
if (excludeId) url += `&exclude_reservation_id=${excludeId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const data = await resp.json();
|
||||||
|
plannerState.allUnits = data.units || [];
|
||||||
|
const hasDates = start && end;
|
||||||
|
document.getElementById('planner-avail-count').textContent =
|
||||||
|
hasDates ? `(${plannerState.allUnits.length} available for period)` : `(${plannerState.allUnits.length} total)`;
|
||||||
|
plannerRenderUnits();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Planner load error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerFilterUnits() {
|
||||||
|
// Mutually exclusive checkboxes
|
||||||
|
const deployedOnly = document.getElementById('planner-deployed-only');
|
||||||
|
const benchedOnly = document.getElementById('planner-benched-only');
|
||||||
|
if (event && event.target === deployedOnly && deployedOnly.checked) benchedOnly.checked = false;
|
||||||
|
if (event && event.target === benchedOnly && benchedOnly.checked) deployedOnly.checked = false;
|
||||||
|
plannerRenderUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerRenderUnits() {
|
||||||
|
const search = document.getElementById('planner-search').value.toLowerCase();
|
||||||
|
const deployedOnly = document.getElementById('planner-deployed-only').checked;
|
||||||
|
const benchedOnly = document.getElementById('planner-benched-only').checked;
|
||||||
|
const slottedIds = new Set(plannerState.slots.map(s => s.unit_id).filter(Boolean));
|
||||||
|
const start = document.getElementById('planner-start').value;
|
||||||
|
const end = document.getElementById('planner-end').value;
|
||||||
|
|
||||||
|
let units = plannerState.allUnits.filter(u => {
|
||||||
|
if (deployedOnly && !u.deployed) return false;
|
||||||
|
if (benchedOnly && u.deployed) return false;
|
||||||
|
if (search && !u.id.toLowerCase().includes(search)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeholder = document.getElementById('planner-units-placeholder');
|
||||||
|
const list = document.getElementById('planner-units-list');
|
||||||
|
|
||||||
|
if (plannerState.allUnits.length === 0) {
|
||||||
|
placeholder.classList.remove('hidden');
|
||||||
|
placeholder.textContent = 'Loading units...';
|
||||||
|
list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
placeholder.classList.add('hidden');
|
||||||
|
list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
|
||||||
|
|
||||||
|
if (units.length === 0) {
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'planner-unit-row text-sm text-gray-400 dark:text-gray-500 text-center py-8';
|
||||||
|
empty.textContent = 'No units match your filter';
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const unit of units) {
|
||||||
|
const isSlotted = slottedIds.has(unit.id);
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = `planner-unit-row flex items-center justify-between px-3 py-2 rounded-lg border transition-colors ${
|
||||||
|
isSlotted
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 opacity-60 cursor-default'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
|
||||||
|
}`;
|
||||||
|
row.dataset.unitId = unit.id;
|
||||||
|
if (!isSlotted) row.onclick = () => plannerAssignUnit(unit.id);
|
||||||
|
|
||||||
|
const calDate = unit.last_calibrated
|
||||||
|
? new Date(unit.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
|
||||||
|
: 'No cal date';
|
||||||
|
|
||||||
|
// Calibration expiry warning during deployment
|
||||||
|
let expiryWarning = '';
|
||||||
|
if (start && end && unit.expiry_date) {
|
||||||
|
const expiry = new Date(unit.expiry_date + 'T00:00:00');
|
||||||
|
const jobStart = new Date(start + 'T00:00:00');
|
||||||
|
const jobEnd = new Date(end + 'T00:00:00');
|
||||||
|
if (expiry >= jobStart && expiry <= jobEnd) {
|
||||||
|
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
|
||||||
|
expiryWarning = `<span class="text-xs px-1.5 py-0.5 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 rounded border border-amber-200 dark:border-amber-800" title="Will need swap during job">cal expires ${expiryStr}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployedBadge = unit.deployed
|
||||||
|
? '<span class="text-xs px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
|
||||||
|
: '<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<button onclick="event.stopPropagation(); openUnitDetailModal('${unit.id}')"
|
||||||
|
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${unit.id}</button>
|
||||||
|
${deployedBadge}
|
||||||
|
${expiryWarning}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0 ml-2">
|
||||||
|
${isSlotted
|
||||||
|
? '<span class="text-xs text-blue-600 dark:text-blue-400 font-medium">Assigned</span>'
|
||||||
|
: '<button class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 whitespace-nowrap">Assign →</button>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUnitDetailModal(unitId) {
|
||||||
|
document.getElementById('unit-detail-modal-title').textContent = unitId;
|
||||||
|
document.getElementById('unit-detail-iframe').src = `/unit/${unitId}?embed=1`;
|
||||||
|
document.getElementById('unit-detail-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUnitDetailModal() {
|
||||||
|
document.getElementById('unit-detail-modal').classList.add('hidden');
|
||||||
|
document.getElementById('unit-detail-iframe').src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerAddSlot() {
|
||||||
|
plannerState.slots.push({ unit_id: null, power_type: null, notes: null });
|
||||||
|
plannerRenderSlots();
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerAssignUnit(unitId) {
|
||||||
|
const emptyIdx = plannerState.slots.findIndex(s => !s.unit_id);
|
||||||
|
if (emptyIdx >= 0) {
|
||||||
|
plannerState.slots[emptyIdx].unit_id = unitId;
|
||||||
|
} else {
|
||||||
|
plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null });
|
||||||
|
}
|
||||||
|
plannerRenderSlots();
|
||||||
|
plannerRenderUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerRemoveSlot(idx) {
|
||||||
|
plannerState.slots.splice(idx, 1);
|
||||||
|
plannerRenderSlots();
|
||||||
|
plannerRenderUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerSetPowerType(idx, value) {
|
||||||
|
plannerState.slots[idx].power_type = value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerSetSlotNotes(idx, value) {
|
||||||
|
plannerState.slots[idx].notes = value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerRenderSlots() {
|
||||||
|
const container = document.getElementById('planner-slots');
|
||||||
|
const emptyMsg = document.getElementById('planner-slots-empty');
|
||||||
|
container.querySelectorAll('.planner-slot-row').forEach(el => el.remove());
|
||||||
|
|
||||||
|
if (plannerState.slots.length === 0) {
|
||||||
|
emptyMsg.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyMsg.classList.add('hidden');
|
||||||
|
|
||||||
|
plannerState.slots.forEach((slot, idx) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'planner-slot-row flex flex-col gap-1.5 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700/50';
|
||||||
|
row.dataset.idx = idx;
|
||||||
|
row.draggable = !!slot.unit_id;
|
||||||
|
|
||||||
|
// Drag events
|
||||||
|
if (slot.unit_id) {
|
||||||
|
row.addEventListener('dragstart', e => {
|
||||||
|
dragSrcIdx = idx;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
row.classList.add('opacity-50');
|
||||||
|
});
|
||||||
|
row.addEventListener('dragend', () => row.classList.remove('opacity-50'));
|
||||||
|
}
|
||||||
|
row.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
container.querySelectorAll('.planner-slot-row').forEach(r => r.classList.remove('ring-2', 'ring-blue-400'));
|
||||||
|
row.classList.add('ring-2', 'ring-blue-400');
|
||||||
|
});
|
||||||
|
row.addEventListener('dragleave', () => row.classList.remove('ring-2', 'ring-blue-400'));
|
||||||
|
row.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
row.classList.remove('ring-2', 'ring-blue-400');
|
||||||
|
if (dragSrcIdx === null || dragSrcIdx === idx) return;
|
||||||
|
// Swap unit_id and power_type only (keep location notes in place)
|
||||||
|
const srcSlot = plannerState.slots[dragSrcIdx];
|
||||||
|
const dstSlot = plannerState.slots[idx];
|
||||||
|
[srcSlot.unit_id, dstSlot.unit_id] = [dstSlot.unit_id, srcSlot.unit_id];
|
||||||
|
[srcSlot.power_type, dstSlot.power_type] = [dstSlot.power_type, srcSlot.power_type];
|
||||||
|
dragSrcIdx = null;
|
||||||
|
plannerRenderSlots();
|
||||||
|
plannerRenderUnits();
|
||||||
|
});
|
||||||
|
|
||||||
|
const powerSelect = `
|
||||||
|
<select onchange="plannerSetPowerType(${idx}, this.value)"
|
||||||
|
class="text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300 focus:ring-1 focus:ring-blue-500">
|
||||||
|
<option value="" ${!slot.power_type ? 'selected' : ''}>— power —</option>
|
||||||
|
<option value="ac" ${slot.power_type === 'ac' ? 'selected' : ''}>A/C Power</option>
|
||||||
|
<option value="solar" ${slot.power_type === 'solar' ? 'selected' : ''}>Solar</option>
|
||||||
|
</select>`;
|
||||||
|
|
||||||
|
const dragHandle = slot.unit_id
|
||||||
|
? `<span class="text-gray-300 dark:text-gray-600 cursor-grab active:cursor-grabbing select-none" title="Drag to reorder">⠿</span>`
|
||||||
|
: `<span class="w-4"></span>`;
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${dragHandle}
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400 w-16 flex-shrink-0">Loc. ${idx + 1}</span>
|
||||||
|
${slot.unit_id
|
||||||
|
? `<span class="flex-1 font-medium text-gray-900 dark:text-white">${slot.unit_id}</span>
|
||||||
|
${powerSelect}
|
||||||
|
<button onclick="plannerClearSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove unit">✕</button>`
|
||||||
|
: `<span class="flex-1 text-sm text-gray-400 dark:text-gray-500 italic">Empty — click a unit</span>
|
||||||
|
${powerSelect}
|
||||||
|
<button onclick="plannerRemoveSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove location">✕</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="pl-8">
|
||||||
|
<input type="text" value="${slot.notes ? slot.notes.replace(/"/g, '"') : ''}"
|
||||||
|
oninput="plannerSetSlotNotes(${idx}, this.value)"
|
||||||
|
placeholder="Location notes (optional)"
|
||||||
|
class="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-600 dark:text-gray-400 placeholder-gray-300 dark:placeholder-gray-600 focus:ring-1 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerClearSlot(idx) {
|
||||||
|
plannerState.slots[idx].unit_id = null;
|
||||||
|
plannerState.slots[idx].power_type = null;
|
||||||
|
plannerRenderSlots();
|
||||||
|
plannerRenderUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerReset() {
|
||||||
|
plannerState = { reservation_id: null, slots: [], allUnits: [] };
|
||||||
|
document.getElementById('planner-name').value = '';
|
||||||
|
document.getElementById('planner-project').value = '';
|
||||||
|
document.getElementById('planner-start').value = '';
|
||||||
|
document.getElementById('planner-end').value = '';
|
||||||
|
document.getElementById('planner-notes').value = '';
|
||||||
|
document.getElementById('planner-est-units').value = '';
|
||||||
|
document.getElementById('planner-search').value = '';
|
||||||
|
const defaultDt = document.querySelector('input[name="planner_device_type"][value="seismograph"]');
|
||||||
|
if (defaultDt) defaultDt.checked = true;
|
||||||
|
document.getElementById('planner-deployed-only').checked = false;
|
||||||
|
document.getElementById('planner-avail-count').textContent = '';
|
||||||
|
document.querySelector('input[name="planner_color"][value="#3B82F6"]').checked = true;
|
||||||
|
const titleEl = document.getElementById('planner-form-title');
|
||||||
|
if (titleEl) titleEl.textContent = 'New Reservation';
|
||||||
|
document.getElementById('planner-save-btn').textContent = 'Save Reservation';
|
||||||
|
document.getElementById('planner-meta-fields').style.display = '';
|
||||||
|
plannerRenderSlots();
|
||||||
|
plannerRenderUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function plannerSave() {
|
||||||
|
const name = document.getElementById('planner-name').value.trim();
|
||||||
|
const start = document.getElementById('planner-start').value;
|
||||||
|
const end = document.getElementById('planner-end').value;
|
||||||
|
const projectId = document.getElementById('planner-project').value;
|
||||||
|
const notes = document.getElementById('planner-notes').value.trim();
|
||||||
|
const color = document.querySelector('input[name="planner_color"]:checked')?.value || '#3B82F6';
|
||||||
|
const estUnits = parseInt(document.getElementById('planner-est-units').value) || null;
|
||||||
|
const filledSlots = plannerState.slots.filter(s => s.unit_id);
|
||||||
|
|
||||||
|
if (!name) { alert('Please enter a reservation name.'); return; }
|
||||||
|
if (!start || !end) { alert('Please set start and end dates.'); return; }
|
||||||
|
if (end < start) { alert('End date must be after start date.'); return; }
|
||||||
|
|
||||||
|
const btn = document.getElementById('planner-save-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isEdit = !!plannerState.reservation_id;
|
||||||
|
const url = isEdit
|
||||||
|
? `/api/fleet-calendar/reservations/${plannerState.reservation_id}`
|
||||||
|
: '/api/fleet-calendar/reservations';
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
|
||||||
|
const payload = {
|
||||||
|
name, start_date: start, end_date: end,
|
||||||
|
project_id: projectId || null,
|
||||||
|
assignment_type: 'specific',
|
||||||
|
device_type: plannerDeviceType,
|
||||||
|
color, notes: notes || null,
|
||||||
|
quantity_needed: estUnits
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (!result.success) throw new Error(result.detail || 'Save failed');
|
||||||
|
|
||||||
|
const reservationId = isEdit ? plannerState.reservation_id : result.reservation_id;
|
||||||
|
|
||||||
|
// Always call assign-units (even with empty list) — endpoint does a full replace
|
||||||
|
const unitIds = filledSlots.map(s => s.unit_id);
|
||||||
|
const powerTypes = {};
|
||||||
|
const locationNotes = {};
|
||||||
|
filledSlots.forEach(s => {
|
||||||
|
if (s.power_type) powerTypes[s.unit_id] = s.power_type;
|
||||||
|
if (s.notes) locationNotes[s.unit_id] = s.notes;
|
||||||
|
});
|
||||||
|
const assignResp = await fetch(
|
||||||
|
`/api/fleet-calendar/reservations/${reservationId}/assign-units`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ unit_ids: unitIds, power_types: powerTypes, location_notes: locationNotes })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const assignResult = await assignResp.json();
|
||||||
|
if (assignResult.conflicts && assignResult.conflicts.length > 0) {
|
||||||
|
const conflictIds = assignResult.conflicts.map(c => c.unit_id).join(', ');
|
||||||
|
alert(`Saved! Note: ${assignResult.conflicts.length} unit(s) had conflicts and were not assigned: ${conflictIds}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
plannerReset();
|
||||||
|
switchPlannerTab('list');
|
||||||
|
// Reload the reservations list partial
|
||||||
|
htmx.trigger('#planner-reservations-list', 'load');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Planner save error', e);
|
||||||
|
alert('Error saving reservation: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = plannerState.reservation_id ? 'Save Changes' : 'Save Reservation';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPlanner(reservationId) {
|
||||||
|
plannerReset();
|
||||||
|
if (reservationId) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/fleet-calendar/reservations/${reservationId}`);
|
||||||
|
const res = await resp.json();
|
||||||
|
plannerState.reservation_id = reservationId;
|
||||||
|
document.getElementById('planner-name').value = res.name;
|
||||||
|
document.getElementById('planner-project').value = res.project_id || '';
|
||||||
|
document.getElementById('planner-start').value = res.start_date;
|
||||||
|
document.getElementById('planner-end').value = res.end_date || '';
|
||||||
|
document.getElementById('planner-notes').value = res.notes || '';
|
||||||
|
document.getElementById('planner-est-units').value = res.quantity_needed || '';
|
||||||
|
const colorRadio = document.querySelector(`input[name="planner_color"][value="${res.color}"]`);
|
||||||
|
if (colorRadio) colorRadio.checked = true;
|
||||||
|
const dtRadio = document.querySelector(`input[name="planner_device_type"][value="${res.device_type || 'seismograph'}"]`);
|
||||||
|
if (dtRadio) dtRadio.checked = true;
|
||||||
|
// Pre-fill slots from existing assigned units
|
||||||
|
for (const u of (res.assigned_units || [])) {
|
||||||
|
plannerState.slots.push({ unit_id: u.id, power_type: u.power_type || null, notes: u.notes || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleEl = document.getElementById('planner-form-title');
|
||||||
|
if (titleEl) titleEl.textContent = res.name;
|
||||||
|
document.getElementById('planner-save-btn').textContent = 'Save Changes';
|
||||||
|
document.getElementById('planner-meta-fields').style.display = 'none';
|
||||||
|
plannerRenderSlots();
|
||||||
|
if (res.start_date && res.end_date) plannerLoadUnits();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading reservation for planner', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switchTab('planner');
|
||||||
|
switchPlannerTab('assign');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% 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">
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<button onclick="switchTab('sessions')"
|
<button onclick="switchTab('sessions')"
|
||||||
data-tab="sessions"
|
data-tab="sessions"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
Recording Sessions
|
Monitoring Sessions
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('data')"
|
<button onclick="switchTab('data')"
|
||||||
data-tab="data"
|
data-tab="data"
|
||||||
@@ -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">
|
||||||
@@ -302,11 +333,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recording Sessions Tab -->
|
<!-- Monitoring Sessions Tab -->
|
||||||
<div id="sessions-tab" class="tab-panel hidden">
|
<div id="sessions-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">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit %}
|
||||||
<button onclick="openScheduleModal()"
|
<button onclick="openScheduleModal()"
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
@@ -329,8 +360,51 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="flex items-center gap-3">
|
||||||
<span class="font-medium">{{ file_count }}</span> files
|
<span class="text-sm text-gray-500"><span class="font-medium">{{ file_count }}</span> files</span>
|
||||||
|
<button onclick="toggleUploadPanel()"
|
||||||
|
class="px-3 py-1.5 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Panel -->
|
||||||
|
<div id="upload-panel" class="hidden mb-6 p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload SD Card Data</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Select a ZIP file, or select all files from inside an <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">Auto_####</code> folder. File types (.rnd, .rnh) are auto-detected.
|
||||||
|
</p>
|
||||||
|
<input type="file" id="upload-input" multiple
|
||||||
|
accept=".zip,.rnd,.rnh,.RND,.RNH"
|
||||||
|
class="block w-full 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" />
|
||||||
|
<div class="flex items-center gap-3 mt-3">
|
||||||
|
<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">
|
||||||
|
Import Files
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span id="upload-status" class="text-sm hidden"></span>
|
||||||
|
</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>
|
||||||
|
|
||||||
@@ -559,5 +633,112 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
|||||||
closeAssignModal();
|
closeAssignModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Upload Data ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleUploadPanel() {
|
||||||
|
const panel = document.getElementById('upload-panel');
|
||||||
|
const status = document.getElementById('upload-status');
|
||||||
|
panel.classList.toggle('hidden');
|
||||||
|
// Reset state when reopening
|
||||||
|
if (!panel.classList.contains('hidden')) {
|
||||||
|
status.textContent = '';
|
||||||
|
status.className = 'text-sm hidden';
|
||||||
|
document.getElementById('upload-input').value = '';
|
||||||
|
document.getElementById('upload-progress-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('upload-progress-bar').style.width = '0%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitUpload() {
|
||||||
|
const input = document.getElementById('upload-input');
|
||||||
|
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) {
|
||||||
|
alert('Please select files to upload.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = input.files.length;
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of input.files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable controls and show progress bar
|
||||||
|
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 {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
||||||
|
if (data.leq_files || data.lp_files) {
|
||||||
|
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
||||||
|
}
|
||||||
|
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
||||||
|
status.textContent = parts.join(' ');
|
||||||
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
input.value = '';
|
||||||
|
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
||||||
|
} 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', () => {
|
||||||
|
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 %}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-id="{{ unit.id }}"
|
data-id="{{ unit.id }}"
|
||||||
data-type="{{ unit.device_type }}"
|
data-type="{{ unit.device_type }}"
|
||||||
@@ -60,7 +60,9 @@
|
|||||||
data-note="{{ unit.note if unit.note else '' }}">
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
{% if not unit.deployed %}
|
{% if unit.out_for_calibration %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||||
|
{% elif not unit.deployed %}
|
||||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
@@ -72,6 +74,8 @@
|
|||||||
|
|
||||||
{% if unit.deployed %}
|
{% if unit.deployed %}
|
||||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-purple-400" title="Out for Calibration"></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -203,14 +207,16 @@
|
|||||||
<div class="unit-card device-card"
|
<div class="unit-card device-card"
|
||||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-unit-id="{{ unit.id }}"
|
data-unit-id="{{ unit.id }}"
|
||||||
data-age="{{ unit.age }}">
|
data-age="{{ unit.age }}">
|
||||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if not unit.deployed %}
|
{% if unit.out_for_calibration %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||||
|
{% elif not unit.deployed %}
|
||||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
@@ -224,12 +230,13 @@
|
|||||||
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
|
||||||
|
{% elif unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
{% if unit.out_for_calibration %}Out for Cal{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,6 +286,10 @@
|
|||||||
<span class="text-xs text-blue-600 dark:text-blue-400">
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
⚡ Deployed
|
⚡ Deployed
|
||||||
</span>
|
</span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="text-xs text-purple-600 dark:text-purple-400">
|
||||||
|
🔧 Out for Calibration
|
||||||
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
📦 Benched
|
📦 Benched
|
||||||
|
|||||||
@@ -1,103 +1,164 @@
|
|||||||
<!-- Reservations List -->
|
<!-- Reservations List -->
|
||||||
{% if reservations %}
|
{% if reservations %}
|
||||||
<div class="space-y-3">
|
<div class="space-y-2">
|
||||||
{% for item in reservations %}
|
{% for item in reservations %}
|
||||||
{% set res = item.reservation %}
|
{% set res = item.reservation %}
|
||||||
<div class="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
{% set card_id = "res-card-" ~ res.id %}
|
||||||
|
{% set detail_id = "res-detail-" ~ res.id %}
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
style="border-left: 4px solid {{ res.color }};">
|
style="border-left: 4px solid {{ res.color }};">
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- Header row (always visible, clickable) -->
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
<div class="res-card-header flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors select-none"
|
||||||
|
data-res-id="{{ res.id }}"
|
||||||
|
onclick="toggleResCard('{{ res.id }}')">
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
||||||
|
{% if res.device_type == 'slm' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">SLM</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded">Seismograph</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.has_conflicts %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded"
|
||||||
|
title="{{ item.conflict_count }} unit(s) will need a calibration swap during this job">
|
||||||
|
{{ item.conflict_count }} cal swap{{ 's' if item.conflict_count != 1 else '' }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{{ res.start_date.strftime('%b %d, %Y') }} –
|
||||||
|
{% if res.end_date %}
|
||||||
|
{{ res.end_date.strftime('%b %d, %Y') }}
|
||||||
|
{% elif res.end_date_tbd %}
|
||||||
|
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
||||||
|
{% if res.estimated_end_date %}
|
||||||
|
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit count -->
|
||||||
|
<div class="text-right mx-4 flex-shrink-0">
|
||||||
|
<p class="text-base font-bold text-gray-900 dark:text-white">
|
||||||
|
{% if res.quantity_needed %}
|
||||||
|
{{ item.assigned_count }}/{{ res.quantity_needed }}
|
||||||
|
{% else %}
|
||||||
|
{{ item.assigned_count }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ 'assigned' if item.assigned_count != 1 else 'assigned' }}
|
||||||
|
{% if res.quantity_needed %} needed{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons (stop propagation so clicks don't toggle card) -->
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button onclick="event.stopPropagation(); openPlanner('{{ res.id }}')"
|
||||||
|
class="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="Plan units">
|
||||||
|
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="event.stopPropagation(); editReservation('{{ res.id }}')"
|
||||||
|
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="Edit">
|
||||||
|
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="event.stopPropagation(); deleteReservation('{{ res.id }}', '{{ res.name }}')"
|
||||||
|
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="Delete">
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Chevron (not in stopPropagation zone so clicking it still toggles the card) -->
|
||||||
|
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1 pointer-events-none" 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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expandable detail panel -->
|
||||||
|
<div id="{{ detail_id }}" class="hidden border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-slate-800/60 px-4 py-3">
|
||||||
|
|
||||||
|
{% if res.notes %}
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3 italic">{{ res.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-sm mb-3">
|
||||||
|
{% if res.quantity_needed %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Est. units needed</div>
|
||||||
|
<div class="font-medium text-gray-800 dark:text-gray-200">{{ res.quantity_needed }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Assigned</div>
|
||||||
|
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} unit{{ 's' if item.assigned_count != 1 else '' }}</div>
|
||||||
|
{% if res.quantity_needed and item.assigned_count < res.quantity_needed %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
|
||||||
|
<div class="font-medium text-amber-600 dark:text-amber-400">{{ res.quantity_needed - item.assigned_count }} more</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.has_conflicts %}
|
{% if item.has_conflicts %}
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full"
|
<div class="text-gray-500 dark:text-gray-400">Cal swaps</div>
|
||||||
title="{{ item.conflict_count }} unit(s) have calibration expiring during this job">
|
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.conflict_count }} unit{{ 's' if item.conflict_count != 1 else '' }} will need swapping during job</div>
|
||||||
{{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{{ res.start_date.strftime('%b %d, %Y') }} -
|
{% if item.assigned_units %}
|
||||||
{% if res.end_date %}
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">Monitoring Locations</p>
|
||||||
{{ res.end_date.strftime('%b %d, %Y') }}
|
<div class="flex flex-col gap-1">
|
||||||
{% elif res.end_date_tbd %}
|
{% for u in item.assigned_units %}
|
||||||
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
<div class="rounded bg-white dark:bg-slate-700 border border-gray-100 dark:border-gray-600 text-sm">
|
||||||
{% if res.estimated_end_date %}
|
<div class="flex items-center gap-3 px-3 py-1.5">
|
||||||
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
<span class="text-gray-400 dark:text-gray-500 text-xs w-12 flex-shrink-0">Loc. {{ loop.index }}</span>
|
||||||
|
<button onclick="openUnitDetailModal('{{ u.id }}')"
|
||||||
|
class="font-medium text-blue-600 dark:text-blue-400 hover:underline">{{ u.id }}</button>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
{% if u.power_type == 'ac' %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded">A/C</span>
|
||||||
|
{% elif u.power_type == 'solar' %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 rounded">Solar</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.deployed %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded">Deployed</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 rounded">Benched</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.last_calibrated %}
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: {{ u.last_calibrated.strftime('%b %d, %Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if u.notes %}
|
||||||
|
<p class="px-3 pb-1.5 text-xs text-gray-400 dark:text-gray-500 italic">{{ u.notes }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
</div>
|
||||||
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
{% endfor %}
|
||||||
{% endif %}
|
</div>
|
||||||
</p>
|
{% else %}
|
||||||
{% if res.notes %}
|
<p class="text-sm text-gray-400 dark:text-gray-500 italic">No units assigned yet. Click the clipboard icon to plan.</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">{{ res.notes }}</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right ml-4">
|
|
||||||
<p class="text-lg font-bold text-gray-900 dark:text-white">
|
|
||||||
{% if res.assignment_type == 'quantity' %}
|
|
||||||
{{ item.assigned_count }}/{{ res.quantity_needed or '?' }}
|
|
||||||
{% else %}
|
|
||||||
{{ item.assigned_count }}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4 flex items-center gap-2">
|
|
||||||
<button onclick="editReservation('{{ res.id }}')"
|
|
||||||
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
title="Edit reservation">
|
|
||||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}')"
|
|
||||||
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
title="Delete reservation">
|
|
||||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- toggleResCard, deleteReservation, editReservation, openUnitDetailModal defined in fleet_calendar.html -->
|
||||||
async function deleteReservation(id, name) {
|
|
||||||
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
alert('Error: ' + (data.detail || 'Failed to delete'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error deleting reservation');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editReservation(id) {
|
|
||||||
// For now, just show alert - can implement edit modal later
|
|
||||||
alert('Edit functionality coming soon. For now, delete and recreate the reservation.');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
<path 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"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400">No reservations for {{ year }}</p>
|
<p class="text-gray-500 dark:text-gray-400">No reservations found</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -151,9 +151,9 @@
|
|||||||
<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-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
Files will appear here once they are downloaded from the sound level meter
|
Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
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 %}
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if project.status == 'active' %}
|
{% if project.status == 'active' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||||
|
{% elif project.status == 'on_hold' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||||
{% elif project.status == 'completed' %}
|
{% elif project.status == 'completed' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
||||||
{% elif project.status == 'archived' %}
|
{% elif project.status == 'archived' %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
|
{% elif item.project.status == 'on_hold' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
|
||||||
|
On Hold
|
||||||
|
</span>
|
||||||
{% elif item.project.status == 'completed' %}
|
{% elif item.project.status == 'completed' %}
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
|
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
|
||||||
Completed
|
Completed
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
{% if item.project.status == 'active' %}
|
{% if item.project.status == 'active' %}
|
||||||
<span class="shrink-0 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">Active</span>
|
<span class="shrink-0 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">Active</span>
|
||||||
|
{% elif item.project.status == 'on_hold' %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||||
{% elif item.project.status == 'completed' %}
|
{% elif item.project.status == 'completed' %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
||||||
{% elif item.project.status == 'archived' %}
|
{% elif item.project.status == 'archived' %}
|
||||||
|
|||||||
@@ -27,6 +27,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">On Hold</p>
|
||||||
|
<p class="text-3xl font-bold text-amber-600 dark:text-amber-400">{{ on_hold_projects }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 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="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% if schedules %}
|
{% if schedules %}
|
||||||
{% for item in schedules %}
|
{% for item in schedules %}
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
|
||||||
{% if not item.schedule.enabled %}opacity-60{% endif %}">
|
{% if project_status == 'on_hold' or not item.schedule.enabled %}opacity-60{% endif %}">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
@@ -29,7 +29,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Status badge -->
|
<!-- Status badge -->
|
||||||
{% if item.schedule.enabled %}
|
{% if project_status == 'on_hold' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
On Hold
|
||||||
|
</span>
|
||||||
|
{% elif project_status == 'archived' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
Archived
|
||||||
|
</span>
|
||||||
|
{% elif item.schedule.enabled %}
|
||||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
@@ -98,7 +106,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions (hidden when project is on hold or archived) -->
|
||||||
|
{% if project_status not in ('on_hold', 'archived') %}
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
{% if item.schedule.enabled %}
|
{% if item.schedule.enabled %}
|
||||||
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
|
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
|
||||||
@@ -131,6 +140,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
<!-- Actions for this date -->
|
<!-- Actions for this date -->
|
||||||
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
|
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
|
||||||
{% for item in date_group.actions %}
|
{% for item in date_group.actions %}
|
||||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow">
|
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow
|
||||||
|
{% if project_status == 'on_hold' and item.schedule.execution_status == 'pending' %}opacity-60{% endif %}">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
@@ -54,6 +55,11 @@
|
|||||||
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
||||||
Pending
|
Pending
|
||||||
</span>
|
</span>
|
||||||
|
{% if project_status == 'on_hold' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
|
||||||
|
On Hold
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
{% elif item.schedule.execution_status == 'completed' %}
|
{% elif item.schedule.execution_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">
|
<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
|
Completed
|
||||||
@@ -157,7 +163,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions (hidden when project is on hold or archived) -->
|
||||||
|
{% if project_status not in ('on_hold', 'archived') %}
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
{% if item.schedule.execution_status == 'pending' %}
|
{% if item.schedule.execution_status == 'pending' %}
|
||||||
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
||||||
@@ -177,6 +184,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Schedule a single recording session with a specific start and end time.
|
Schedule a single monitoring session with a specific start and end time.
|
||||||
Duration can be between 15 minutes and 24 hours.
|
Duration can be between 15 minutes and 24 hours.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,79 +1,149 @@
|
|||||||
<!-- Recording 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 recording 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 recording 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>
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
||||||
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
||||||
|
{% if out_for_calibration > 0 %}
|
||||||
|
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ out_for_calibration }} out for calibration</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
|||||||
@@ -106,6 +106,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Deployed
|
Deployed
|
||||||
</span>
|
</span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Out for Cal
|
||||||
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
|||||||
@@ -163,6 +163,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Deploy / Bench Toggle -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">Deployment Status</span>
|
||||||
|
<p id="slm-settings-deploy-desc" class="text-xs text-gray-500 dark:text-gray-400">Unit is currently deployed in the field</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="slm-settings-deploy-btn"
|
||||||
|
onclick="toggleSLMDeployed()"
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg font-medium transition-colors">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- FTP Enable Toggle -->
|
<!-- FTP Enable Toggle -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
<label class="flex items-center justify-between cursor-pointer">
|
<label class="flex items-center justify-between cursor-pointer">
|
||||||
@@ -264,6 +283,9 @@ async function openSLMSettingsModal(unitId) {
|
|||||||
// FTP enabled from SLMM
|
// FTP enabled from SLMM
|
||||||
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
|
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
|
||||||
|
|
||||||
|
// Deploy/bench status from Terra-View
|
||||||
|
updateDeployButton(unitData.deployed !== false);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load SLM settings:', error);
|
console.error('Failed to load SLM settings:', error);
|
||||||
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
||||||
@@ -525,6 +547,77 @@ async function saveFTPSettings(event) {
|
|||||||
return saveSLMSettings(event);
|
return saveSLMSettings(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the deploy/bench button appearance based on current deployed state
|
||||||
|
function updateDeployButton(isDeployed) {
|
||||||
|
const btn = document.getElementById('slm-settings-deploy-btn');
|
||||||
|
const desc = document.getElementById('slm-settings-deploy-desc');
|
||||||
|
const icon = btn.closest('.border').querySelector('svg');
|
||||||
|
|
||||||
|
if (isDeployed) {
|
||||||
|
btn.textContent = 'Bench Unit';
|
||||||
|
btn.className = 'px-3 py-1.5 text-sm rounded-lg font-medium transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600';
|
||||||
|
desc.textContent = 'Unit is currently deployed in the field';
|
||||||
|
icon.classList.remove('text-gray-400');
|
||||||
|
icon.classList.add('text-green-500');
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Deploy Unit';
|
||||||
|
btn.className = 'px-3 py-1.5 text-sm rounded-lg font-medium transition-colors bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50';
|
||||||
|
desc.textContent = 'Unit is currently benched (not in field)';
|
||||||
|
icon.classList.remove('text-green-500');
|
||||||
|
icon.classList.add('text-gray-400');
|
||||||
|
}
|
||||||
|
// Store current state on button for toggle reference
|
||||||
|
btn.dataset.deployed = isDeployed ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle deploy/bench status
|
||||||
|
async function toggleSLMDeployed() {
|
||||||
|
const unitId = document.getElementById('slm-settings-unit-id').value;
|
||||||
|
const btn = document.getElementById('slm-settings-deploy-btn');
|
||||||
|
const errorDiv = document.getElementById('slm-settings-error');
|
||||||
|
const successDiv = document.getElementById('slm-settings-success');
|
||||||
|
|
||||||
|
const currentlyDeployed = btn.dataset.deployed === '1';
|
||||||
|
const newDeployed = !currentlyDeployed;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = newDeployed ? 'Deploying...' : 'Benching...';
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
successDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('deployed', newDeployed ? 'true' : 'false');
|
||||||
|
|
||||||
|
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.detail || 'Failed to update deployment status');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDeployButton(newDeployed);
|
||||||
|
successDiv.textContent = newDeployed ? 'Unit marked as deployed.' : 'Unit marked as benched.';
|
||||||
|
successDiv.classList.remove('hidden');
|
||||||
|
setTimeout(() => successDiv.classList.add('hidden'), 3000);
|
||||||
|
|
||||||
|
// Refresh any SLM list on the page
|
||||||
|
if (typeof htmx !== 'undefined') {
|
||||||
|
htmx.trigger('#slm-list', 'load');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
btn.disabled = false;
|
||||||
|
updateDeployButton(currentlyDeployed); // restore button state
|
||||||
|
errorDiv.textContent = 'Error: ' + error.message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close modal on background click
|
// Close modal on background click
|
||||||
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
|
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
|
||||||
if (e.target === this) {
|
if (e.target === this) {
|
||||||
|
|||||||
@@ -40,22 +40,22 @@
|
|||||||
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
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('sessions')"
|
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
||||||
data-tab="sessions"
|
data-tab="sessions"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
Recording Sessions
|
Monitoring Sessions
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('data')"
|
<button id="data-tab-btn" onclick="switchTab('data')"
|
||||||
data-tab="data"
|
data-tab="data"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
Data Files
|
Data Files
|
||||||
@@ -185,11 +185,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recording Sessions Tab -->
|
<!-- Monitoring Sessions Tab -->
|
||||||
<div id="sessions-tab" class="tab-panel hidden">
|
<div id="sessions-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">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<select id="sessions-filter" onchange="filterSessions()"
|
<select id="sessions-filter" onchange="filterSessions()"
|
||||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
@@ -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"
|
||||||
@@ -279,12 +329,37 @@
|
|||||||
<select name="status" id="settings-status"
|
<select name="status" id="settings-status"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
|
<option value="on_hold">On Hold</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="archived">Archived</option>
|
<option value="archived">Archived</option>
|
||||||
</select>
|
</select>
|
||||||
</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"
|
||||||
@@ -329,14 +404,39 @@
|
|||||||
<!-- Danger Zone -->
|
<!-- Danger Zone -->
|
||||||
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
<div class="space-y-3">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
<!-- On Hold -->
|
||||||
Archive this project to remove it from active listings. All data will be preserved.
|
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 flex items-center justify-between gap-4">
|
||||||
</p>
|
<div>
|
||||||
<button onclick="archiveProject()"
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Put Project On Hold</p>
|
||||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
<p class="text-sm text-gray-600 dark:text-gray-400">Pause this project without archiving. Assignments and schedules remain in place.</p>
|
||||||
Archive Project
|
</div>
|
||||||
</button>
|
<div id="hold-btn-container" class="shrink-0">
|
||||||
|
<!-- Rendered by updateDangerZone() based on current status -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Archive -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 rounded-lg p-4 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Archive Project</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Remove from active listings. All data is preserved and can be restored.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="archiveProject()"
|
||||||
|
class="shrink-0 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm">
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Delete -->
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Delete Project</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Permanently removes all project data after a 60-day grace period. This action is difficult to undo.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="openDeleteModal()"
|
||||||
|
class="shrink-0 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,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>
|
||||||
@@ -389,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"
|
||||||
@@ -495,7 +618,7 @@
|
|||||||
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Single recording session with a specific start and end date/time (15 min - 24 hrs).
|
Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -596,6 +719,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Project Confirmation Modal -->
|
||||||
|
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="p-2 bg-red-100 dark:bg-red-900/40 rounded-lg">
|
||||||
|
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Delete Project</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
This project will be soft-deleted and <strong class="text-gray-900 dark:text-white">permanently removed after 60 days</strong>. All associated locations, assignments, and sessions will be lost.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
|
||||||
|
Type <span class="font-mono font-bold text-red-600 dark:text-red-400">delete</span> to confirm:
|
||||||
|
</p>
|
||||||
|
<input type="text" id="delete-confirm-input"
|
||||||
|
placeholder="type delete"
|
||||||
|
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 mb-4 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
autocomplete="off">
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<button onclick="closeDeleteModal()"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="confirm-delete-btn" disabled onclick="executeDeleteProject()"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
Delete Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const projectId = "{{ project_id }}";
|
const projectId = "{{ project_id }}";
|
||||||
let editingLocationId = null;
|
let editingLocationId = null;
|
||||||
@@ -654,14 +811,32 @@ 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 tab labels based on project type
|
// Update data collection mode radio
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
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
|
||||||
|
const isSoundProject = projectTypeId === 'sound_monitoring';
|
||||||
|
if (isSoundProject) {
|
||||||
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
||||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||||
}
|
}
|
||||||
|
// 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('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();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project details:', err);
|
console.error('Failed to load project details:', err);
|
||||||
}
|
}
|
||||||
@@ -674,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();
|
||||||
@@ -686,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 {
|
||||||
@@ -729,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';
|
||||||
@@ -737,17 +958,25 @@ 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') {
|
||||||
locationTypeSelect.value = 'sound';
|
locationTypeSelect.value = 'sound';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else if (projectTypeId === 'vibration_monitoring') {
|
||||||
|
locationTypeSelect.value = 'vibration';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
locationTypeSelect.disabled = false;
|
locationTypeSelect.disabled = false;
|
||||||
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');
|
||||||
}
|
}
|
||||||
@@ -761,17 +990,27 @@ 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') {
|
||||||
locationTypeSelect.value = 'sound';
|
locationTypeSelect.value = 'sound';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else if (projectTypeId === 'vibration_monitoring') {
|
||||||
|
locationTypeSelect.value = 'vibration';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
locationTypeSelect.disabled = false;
|
locationTypeSelect.disabled = false;
|
||||||
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');
|
||||||
}
|
}
|
||||||
@@ -790,8 +1029,12 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
let locationType = document.getElementById('location-type').value;
|
let locationType = document.getElementById('location-type').value;
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
locationType = 'sound';
|
locationType = 'sound';
|
||||||
|
} else if (projectTypeId === 'vibration_monitoring') {
|
||||||
|
locationType = 'vibration';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connectionMode = document.querySelector('input[name="connection_mode"]:checked')?.value || 'connected';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingLocationId) {
|
if (editingLocationId) {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -799,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',
|
||||||
@@ -817,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
|
||||||
@@ -1027,6 +1272,78 @@ function archiveProject() {
|
|||||||
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function holdProject() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/hold`, { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error('Failed to put project on hold');
|
||||||
|
await loadProjectDetails();
|
||||||
|
updateDangerZone();
|
||||||
|
htmx.trigger('#project-header', 'load');
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to put project on hold: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unholdProject() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/unhold`, { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error('Failed to resume project');
|
||||||
|
await loadProjectDetails();
|
||||||
|
updateDangerZone();
|
||||||
|
htmx.trigger('#project-header', 'load');
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to resume project: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDangerZone() {
|
||||||
|
const status = document.getElementById('settings-status').value;
|
||||||
|
const container = document.getElementById('hold-btn-container');
|
||||||
|
if (!container) return;
|
||||||
|
if (status === 'on_hold') {
|
||||||
|
container.innerHTML = `<button onclick="unholdProject()"
|
||||||
|
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors text-sm">
|
||||||
|
Resume Project
|
||||||
|
</button>`;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<button onclick="holdProject()"
|
||||||
|
class="px-4 py-2 border border-amber-500 text-amber-600 dark:text-amber-400 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors text-sm">
|
||||||
|
Put On Hold
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal() {
|
||||||
|
document.getElementById('delete-confirm-input').value = '';
|
||||||
|
document.getElementById('confirm-delete-btn').disabled = true;
|
||||||
|
document.getElementById('delete-project-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('delete-confirm-input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('delete-project-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDeleteProject() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error('Failed to delete project');
|
||||||
|
closeDeleteModal();
|
||||||
|
window.location.href = '/projects';
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete project: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const input = document.getElementById('delete-confirm-input');
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
document.getElementById('confirm-delete-btn').disabled = this.value !== 'delete';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Schedule Modal Functions
|
// Schedule Modal Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1326,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();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8"
|
||||||
hx-get="/api/projects/stats"
|
hx-get="/api/projects/stats"
|
||||||
hx-trigger="load, every 30s"
|
hx-trigger="load, every 30s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
@@ -43,6 +44,11 @@
|
|||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
Active
|
Active
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="switchTab('on_hold')"
|
||||||
|
id="tab-on_hold"
|
||||||
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
|
On Hold
|
||||||
|
</button>
|
||||||
<button onclick="switchTab('completed')"
|
<button onclick="switchTab('completed')"
|
||||||
id="tab-completed"
|
id="tab-completed"
|
||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
@@ -72,9 +78,16 @@
|
|||||||
<!-- Create Project Modal -->
|
<!-- Create Project Modal -->
|
||||||
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
<div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="hideCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 ml-4">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6" id="createProjectContent">
|
<div class="p-6" id="createProjectContent">
|
||||||
@@ -91,6 +104,12 @@
|
|||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button type="button" onclick="hideCreateProjectModal()"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Project Details Form (hidden initially) -->
|
<!-- Step 2: Project Details Form (hidden initially) -->
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1504,7 +1504,7 @@
|
|||||||
`• Unit roster entry\n` +
|
`• Unit roster entry\n` +
|
||||||
`• All history records\n` +
|
`• All history records\n` +
|
||||||
`• Project assignments\n` +
|
`• Project assignments\n` +
|
||||||
`• Recording sessions\n` +
|
`• Monitoring sessions\n` +
|
||||||
`• Modem references\n\n` +
|
`• Modem references\n\n` +
|
||||||
`This action cannot be undone.`
|
`This action cannot be undone.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
<option value="deployed">Deployed</option>
|
<option value="deployed">Deployed</option>
|
||||||
<option value="benched">Benched</option>
|
<option value="benched">Benched</option>
|
||||||
|
<option value="out_for_calibration">Out for Calibration</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Modem Filter -->
|
<!-- Modem Filter -->
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Retired</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">Unit Status</span>
|
||||||
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,18 +497,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Checkboxes -->
|
<!-- Status Checkboxes -->
|
||||||
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<div class="flex items-center gap-6">
|
||||||
<input type="checkbox" name="deployed" id="deployed" value="true"
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
<input type="checkbox" name="deployed" id="deployed" value="true"
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
</label>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
</label>
|
||||||
<input type="checkbox" name="retired" id="retired" value="true"
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
<input type="checkbox" name="out_for_calibration" id="outForCalibration" value="true"
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
|
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
|
||||||
</label>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<!-- Hidden field for retired — controlled by the Retire button below -->
|
||||||
|
<input type="hidden" name="retired" id="retired" value="">
|
||||||
|
<div id="retireButtonSection">
|
||||||
|
<button type="button" id="retireBtn"
|
||||||
|
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
|
||||||
|
onclick="toggleRetired()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
@@ -817,7 +827,16 @@ function populateViewMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
|
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
|
||||||
document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
|
if (currentUnit.retired) {
|
||||||
|
document.getElementById('retiredStatus').textContent = 'Retired';
|
||||||
|
document.getElementById('retiredStatus').className = 'font-medium text-red-600 dark:text-red-400';
|
||||||
|
} else if (currentUnit.out_for_calibration) {
|
||||||
|
document.getElementById('retiredStatus').textContent = 'Out for Calibration';
|
||||||
|
document.getElementById('retiredStatus').className = 'font-medium text-purple-600 dark:text-purple-400';
|
||||||
|
} else {
|
||||||
|
document.getElementById('retiredStatus').textContent = 'Active';
|
||||||
|
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
|
||||||
|
}
|
||||||
|
|
||||||
// Basic info
|
// Basic info
|
||||||
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
||||||
@@ -1009,7 +1028,9 @@ function populateEditForm() {
|
|||||||
document.getElementById('address').value = currentUnit.address || '';
|
document.getElementById('address').value = currentUnit.address || '';
|
||||||
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||||
document.getElementById('retired').checked = currentUnit.retired;
|
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
||||||
|
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
||||||
|
updateRetireButton(currentUnit.retired);
|
||||||
document.getElementById('note').value = currentUnit.note || '';
|
document.getElementById('note').value = currentUnit.note || '';
|
||||||
|
|
||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
@@ -1153,6 +1174,25 @@ function cancelEdit() {
|
|||||||
populateEditForm();
|
populateEditForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateRetireButton(isRetired) {
|
||||||
|
const btn = document.getElementById('retireBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
if (isRetired) {
|
||||||
|
btn.textContent = 'Un-Retire Unit';
|
||||||
|
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600';
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Retire Unit';
|
||||||
|
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRetired() {
|
||||||
|
const hiddenInput = document.getElementById('retired');
|
||||||
|
const isCurrentlyRetired = hiddenInput.value === 'true';
|
||||||
|
hiddenInput.value = isCurrentlyRetired ? '' : 'true';
|
||||||
|
updateRetireButton(!isCurrentlyRetired);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
415
templates/vibration_location_detail.html
Normal file
415
templates/vibration_location_detail.html
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ location.name }} - Monitoring Location{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb Navigation -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
|
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
Projects
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/projects/{{ project_id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ project.name }}
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">{{ location.name }}</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ location.name }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Monitoring Location • {{ project.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if assigned_unit %}
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
Unit Assigned
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
No Unit Assigned
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex space-x-6">
|
||||||
|
<button onclick="switchTab('overview')"
|
||||||
|
data-tab="overview"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('settings')"
|
||||||
|
data-tab="settings"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div id="tab-content">
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div id="overview-tab" class="tab-panel">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Location Details Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location Details</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Name</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ location.name }}</div>
|
||||||
|
</div>
|
||||||
|
{% if location.description %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Description</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ location.description }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if location.address %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Address</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ location.address }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if location.coordinates %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Coordinates</div>
|
||||||
|
<div class="text-gray-900 dark:text-white font-mono text-sm">{{ location.coordinates }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ location.created_at|local_datetime if location.created_at else 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
||||||
|
{% if assigned_unit %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ assigned_unit.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if assigned_unit.device_type %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if assignment %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at|local_datetime if assignment.assigned_at else 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
{% if assignment.notes %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||||
|
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="pt-2">
|
||||||
|
<button onclick="unassignUnit('{{ assignment.id }}')"
|
||||||
|
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
||||||
|
Unassign Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
||||||
|
<button onclick="openAssignModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
Assign a Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div id="settings-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Location Settings</h2>
|
||||||
|
|
||||||
|
<form id="location-settings-form" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
|
||||||
|
<input type="text" id="settings-name" value="{{ location.name }}"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||||
|
<textarea id="settings-description" rows="3"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">{{ location.description or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
|
<input type="text" id="settings-address" value="{{ location.address or '' }}"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||||
|
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
|
||||||
|
placeholder="40.7128,-74.0060"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assign Unit Modal -->
|
||||||
|
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="assign-form" class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
||||||
|
<select id="assign-unit-id" name="unit_id"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||||
|
<option value="">Loading units...</option>
|
||||||
|
</select>
|
||||||
|
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
|
<textarea id="assign-notes" name="notes" rows="2"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="closeAssignModal()"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Assign Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const projectId = "{{ project_id }}";
|
||||||
|
const locationId = "{{ location_id }}";
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
function switchTab(tabName) {
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tab-button').forEach(button => {
|
||||||
|
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||||
|
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
});
|
||||||
|
const panel = document.getElementById(`${tabName}-tab`);
|
||||||
|
if (panel) panel.classList.remove('hidden');
|
||||||
|
const button = document.querySelector(`[data-tab="${tabName}"]`);
|
||||||
|
if (button) {
|
||||||
|
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location settings form submission
|
||||||
|
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('settings-name').value.trim(),
|
||||||
|
description: document.getElementById('settings-description').value.trim() || null,
|
||||||
|
address: document.getElementById('settings-address').value.trim() || null,
|
||||||
|
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to update location');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('settings-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to update location.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign modal
|
||||||
|
function openAssignModal() {
|
||||||
|
document.getElementById('assign-modal').classList.remove('hidden');
|
||||||
|
loadAvailableUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssignModal() {
|
||||||
|
document.getElementById('assign-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableUnits() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load available units');
|
||||||
|
const data = await response.json();
|
||||||
|
const select = document.getElementById('assign-unit-id');
|
||||||
|
select.innerHTML = '<option value="">Select a unit</option>';
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
document.getElementById('assign-empty').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(unit => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = unit.id;
|
||||||
|
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('assign-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to load units.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const unitId = document.getElementById('assign-unit-id').value;
|
||||||
|
const notes = document.getElementById('assign-notes').value.trim();
|
||||||
|
|
||||||
|
if (!unitId) {
|
||||||
|
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
||||||
|
document.getElementById('assign-error').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('unit_id', unitId);
|
||||||
|
formData.append('notes', notes);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to assign unit');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('assign-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to assign unit.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function unassignUnit(assignmentId) {
|
||||||
|
if (!confirm('Unassign this unit from the location?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to unassign unit');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to unassign unit.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') closeAssignModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeAssignModal();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user