14 Commits

Author SHA1 Message Date
184f0ddd13 doc: update to 0.9.3 2026-03-28 01:53:13 +00:00
e7bd09418b fix: update session calendar layout and improve session labels for clarity 2026-03-28 01:44:59 +00:00
27eeb0fae6 fix: adds timeline bars to SLM calendar view, more conscise and legible. 2026-03-27 22:44:53 +00:00
192e15f238 Merge pull request 'cleanup/project-locations-active-assignment' (#42) from cleanup/project-locations-active-assignment into dev
Reviewed-on: #42
2026-03-27 18:20:07 -04:00
49bc625c1a feat: add report_date to monitoring sessions and update related functionality
fix: chart properly renders centered
2026-03-27 22:18:50 +00:00
95fedca8c9 feat: monitoring session improvements — UTC fix, period hours, calendar, session detail
- Fix UTC display bug: upload_nrl_data now wraps RNH datetimes with
  local_to_utc() before storing, matching patch_session behavior.
  Period type and label are derived from local time before conversion.

- Add period_start_hour / period_end_hour to MonitoringSession model
  (nullable integers 0–23). Migration: migrate_add_session_period_hours.py

- Update patch_session to accept and store period_start_hour / period_end_hour.
  Response now includes both fields.

- Update get_project_sessions to compute "Effective: M/D H:MM AM → M/D H:MM AM"
  string from period hours and pass it to session_list.html.

- Rework period edit UI in session_list.html: clicking the period badge now
  opens an inline editor with period type selector + start/end hour inputs.
  Selecting a period type pre-fills default hours (Day: 7–19, Night: 19–7).

- Wire period hours into _build_location_data_from_sessions: uses
  period_start/end_hour when set, falls back to hardcoded defaults.

- RND viewer: inject SESSION_PERIOD_START/END_HOUR from template context.
  renderTable() dims rows outside the period window (opacity-40) with a
  tooltip; shows "(N in period window)" in the row count.

- New session detail page at /api/projects/{id}/sessions/{id}/detail:
  shows breadcrumb, files list with View/Download/Report actions,
  editable session info form (label, period type, hours, times).

- Add local_datetime_input Jinja filter for datetime-local input values.

- Monthly calendar view: new get_sessions_calendar endpoint returns
  sessions_calendar.html partial; added below sessions list in detail.html.
  Color-coded per NRL with legend, HTMX prev/next navigation, session dots
  link to detail page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:52:52 +00:00
e8e155556a refactor: unify active assignment checks and add project-type guards
- Replace all UnitAssignment "active" checks from `status == "active"` to
  `assigned_until == None` in both project_locations.py and projects.py.
  This aligns with the canonical definition: active = no end date set.
  (status field is still set in sync, but is no longer the query criterion)

- Add `_require_sound_project()` helper to both routers and call it at the
  top of every sound-monitoring-specific endpoint (FTP browser, FTP downloads,
  RND file viewer, all Excel report endpoints, combined report wizard,
  upload-all, NRL live status, NRL data upload). Vibration projects hitting
  these endpoints now receive a clear 400 instead of silently failing or
  returning empty results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:12:38 +00:00
33e962e73d feat: add edit session times functionality with modal for monitoring sessions 2026-03-27 20:54:04 +00:00
ac48fb2977 feat: add swap functionality for unit and modem assignments in vibration monitoring locations 2026-03-27 20:33:13 +00:00
3c4b81cf78 docs: updates to 0.9.2 2026-03-27 17:01:43 +00:00
d135727ebd feat: add in-line quick editing for seismograph details (cal date, notes, deployment status) 2026-03-26 06:10:03 +00:00
64d4423308 feat: add allocated status and project allocation to unit management
- Updated dashboard to display allocated units alongside deployed and benched units.
- Introduced a quick-info modal for units, showing detailed information including calibration status, project allocation, and upcoming jobs.
- Enhanced fleet calendar with a new quick-info modal for units, allowing users to view unit details without navigating away.
- Modified devices table to include allocated status and visual indicators for allocated units.
- Added allocated filter option in the roster view for better unit management.
- Implemented backend migration to add 'allocated' and 'allocated_to_project_id' columns to the roster table.
- Updated unit detail view to reflect allocated status and allow for project allocation input.
2026-03-26 05:05:34 +00:00
4f71d528ce feat: shows cal date in reservation planner after unit selected for location. 2026-03-25 19:10:13 +00:00
4f56dea4f3 feat: adds deployment records for seismographs. 2026-03-25 17:36:51 +00:00
32 changed files with 3189 additions and 336 deletions

View File

@@ -5,11 +5,58 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.1] - 2026-03-23
## [0.9.3] - 2026-03-28
### Added
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.
### Fixed
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.
### Changed
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
```
---
## [0.9.2] - 2026-03-27
### Added
- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations.
- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators.
- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster.
- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page.
- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing.
- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_allocated.py
docker compose exec terra-view python3 backend/migrate_add_deployment_records.py
```
---
## [0.9.1] - 2026-03-20
### Fixed
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility.
### Migration Notes
Run on each database before deploying:

View File

@@ -1,4 +1,4 @@
# Terra-View v0.9.1
# Terra-View v0.9.3
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

View File

@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.9.1"
VERSION = "0.9.3"
if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0":
@@ -126,6 +126,10 @@ app.include_router(recurring_schedules.router)
from backend.routers import fleet_calendar
app.include_router(fleet_calendar.router)
# Deployment Records router
from backend.routers import deployments
app.include_router(deployments.router)
# Start scheduler service and device status monitor on application startup
from backend.services.scheduler import start_scheduler, stop_scheduler
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
@@ -351,8 +355,11 @@ async def nrl_detail_page(
).first()
assigned_unit = None
assigned_modem = None
if assignment:
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if assigned_unit and assigned_unit.deployed_with_modem_id:
assigned_modem = db.query(RosterUnit).filter_by(id=assigned_unit.deployed_with_modem_id).first()
# Get session count
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
@@ -389,6 +396,7 @@ async def nrl_detail_page(
"location": location,
"assignment": assignment,
"assigned_unit": assigned_unit,
"assigned_modem": assigned_modem,
"session_count": session_count,
"file_count": file_count,
"active_session": active_session,
@@ -703,6 +711,33 @@ async def devices_all_partial(request: Request):
"hardware_model": unit_data.get("hardware_model"),
})
# Add allocated units
for unit_id, unit_data in snapshot.get("allocated", {}).items():
units_list.append({
"id": unit_id,
"status": "Allocated",
"age": "N/A",
"last_seen": "N/A",
"deployed": False,
"retired": False,
"out_for_calibration": False,
"allocated": True,
"allocated_to_project_id": unit_data.get("allocated_to_project_id", ""),
"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({
@@ -780,17 +815,19 @@ async def devices_all_partial(request: Request):
# Sort by status category, then by ID
def sort_key(unit):
# Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored
# Priority: deployed (active) -> allocated -> benched -> out_for_calibration -> retired -> ignored
if unit["deployed"]:
return (0, unit["id"])
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
elif unit.get("allocated"):
return (1, unit["id"])
elif unit["out_for_calibration"]:
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
return (2, unit["id"])
elif unit["retired"]:
elif unit["out_for_calibration"]:
return (3, unit["id"])
else:
elif unit["retired"]:
return (4, unit["id"])
else:
return (5, unit["id"])
units_list.sort(key=sort_key)

View File

@@ -0,0 +1,35 @@
"""
Migration: Add allocated and allocated_to_project_id columns to roster table.
Run once: python backend/migrate_add_allocated.py
"""
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'seismo_fleet.db')
def run():
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
# Check existing columns
cur.execute("PRAGMA table_info(roster)")
cols = {row[1] for row in cur.fetchall()}
if 'allocated' not in cols:
cur.execute("ALTER TABLE roster ADD COLUMN allocated BOOLEAN DEFAULT 0 NOT NULL")
print("Added column: allocated")
else:
print("Column already exists: allocated")
if 'allocated_to_project_id' not in cols:
cur.execute("ALTER TABLE roster ADD COLUMN allocated_to_project_id VARCHAR")
print("Added column: allocated_to_project_id")
else:
print("Column already exists: allocated_to_project_id")
conn.commit()
conn.close()
print("Migration complete.")
if __name__ == '__main__':
run()

View File

@@ -0,0 +1,79 @@
"""
Migration: Add deployment_records table.
Tracks each time a unit is sent to the field and returned.
The active deployment is the row with actual_removal_date IS NULL.
Run once per database:
python backend/migrate_add_deployment_records.py
"""
import sqlite3
import os
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
# Check if table already exists
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='deployment_records'
""")
if cursor.fetchone():
print("✓ deployment_records table already exists, skipping")
return
print("Creating deployment_records table...")
cursor.execute("""
CREATE TABLE deployment_records (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL,
deployed_date DATE,
estimated_removal_date DATE,
actual_removal_date DATE,
project_ref TEXT,
project_id TEXT,
location_name TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE INDEX idx_deployment_records_unit_id
ON deployment_records(unit_id)
""")
cursor.execute("""
CREATE INDEX idx_deployment_records_project_id
ON deployment_records(project_id)
""")
# Index for finding active deployments quickly
cursor.execute("""
CREATE INDEX idx_deployment_records_active
ON deployment_records(unit_id, actual_removal_date)
""")
conn.commit()
print("✓ deployment_records table created successfully")
print("✓ Indexes created")
except Exception as e:
conn.rollback()
print(f"✗ Migration failed: {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
migrate_database()

View File

@@ -0,0 +1,42 @@
"""
Migration: add period_start_hour and period_end_hour to monitoring_sessions.
Run once:
python backend/migrate_add_session_period_hours.py
Or inside the container:
docker exec terra-view python3 backend/migrate_add_session_period_hours.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import engine
from sqlalchemy import text
def run():
with engine.connect() as conn:
# Check which columns already exist
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
existing = {row[1] for row in result}
added = []
for col, definition in [
("period_start_hour", "INTEGER"),
("period_end_hour", "INTEGER"),
]:
if col not in existing:
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
added.append(col)
else:
print(f" Column '{col}' already exists — skipping.")
conn.commit()
if added:
print(f" Added columns: {', '.join(added)}")
print("Migration complete.")
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,41 @@
"""
Migration: add report_date to monitoring_sessions.
Run once:
python backend/migrate_add_session_report_date.py
Or inside the container:
docker exec terra-view-terra-view-1 python3 backend/migrate_add_session_report_date.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import engine
from sqlalchemy import text
def run():
with engine.connect() as conn:
# Check which columns already exist
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
existing = {row[1] for row in result}
added = []
for col, definition in [
("report_date", "DATE"),
]:
if col not in existing:
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
added.append(col)
else:
print(f" Column '{col}' already exists — skipping.")
conn.commit()
if added:
print(f" Added columns: {', '.join(added)}")
print("Migration complete.")
if __name__ == "__main__":
run()

View File

@@ -33,6 +33,8 @@ class RosterUnit(Base):
deployed = Column(Boolean, default=True)
retired = Column(Boolean, default=False)
out_for_calibration = Column(Boolean, default=False)
allocated = Column(Boolean, default=False) # Staged for an upcoming job, not yet deployed
allocated_to_project_id = Column(String, nullable=True) # Which project it's allocated to
note = Column(String, nullable=True)
project_id = Column(String, nullable=True)
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
@@ -301,6 +303,17 @@ class MonitoringSession(Base):
# weekday_day | weekday_night | weekend_day | weekend_night
period_type = Column(String, nullable=True)
# Effective monitoring window (hours 023). Night sessions cross midnight
# (period_end_hour < period_start_hour). NULL = no filtering applied.
# e.g. Day: start=7, end=19 Night: start=19, end=7
period_start_hour = Column(Integer, nullable=True)
period_end_hour = Column(Integer, nullable=True)
# For day sessions: the specific calendar date to use for report filtering.
# Overrides the automatic "last date with daytime rows" heuristic.
# Null = use heuristic.
report_date = Column(Date, nullable=True)
# Snapshot of device configuration at recording time
session_metadata = Column(Text, nullable=True) # JSON
@@ -448,6 +461,41 @@ class Alert(Base):
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
# ============================================================================
# Deployment Records
# ============================================================================
class DeploymentRecord(Base):
"""
Deployment records: tracks each time a unit is sent to the field and returned.
Each row represents one deployment. The active deployment is the record
with actual_removal_date IS NULL. The fleet calendar uses this to show
units as "In Field" and surface their expected return date.
project_ref is a freeform string for legacy/vibration jobs like "Fay I-80".
project_id will be populated once those jobs are migrated to proper Project records.
"""
__tablename__ = "deployment_records"
id = Column(String, primary_key=True, index=True) # UUID
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
deployed_date = Column(Date, nullable=True) # When unit left the yard
estimated_removal_date = Column(Date, nullable=True) # Expected return date
actual_removal_date = Column(Date, nullable=True) # Filled in when returned; NULL = still out
# Project linkage: freeform for legacy jobs, FK for proper project records
project_ref = Column(String, nullable=True) # e.g. "Fay I-80" (vibration jobs)
project_id = Column(String, nullable=True, index=True) # FK to Project.id (when available)
location_name = Column(String, nullable=True) # e.g. "North Gate", "VP-001"
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ============================================================================
# Fleet Calendar & Job Reservations
# ============================================================================

View File

@@ -0,0 +1,154 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, date
from typing import Optional
import uuid
from backend.database import get_db
from backend.models import DeploymentRecord, RosterUnit
router = APIRouter(prefix="/api", tags=["deployments"])
def _serialize(record: DeploymentRecord) -> dict:
return {
"id": record.id,
"unit_id": record.unit_id,
"deployed_date": record.deployed_date.isoformat() if record.deployed_date else None,
"estimated_removal_date": record.estimated_removal_date.isoformat() if record.estimated_removal_date else None,
"actual_removal_date": record.actual_removal_date.isoformat() if record.actual_removal_date else None,
"project_ref": record.project_ref,
"project_id": record.project_id,
"location_name": record.location_name,
"notes": record.notes,
"created_at": record.created_at.isoformat() if record.created_at else None,
"updated_at": record.updated_at.isoformat() if record.updated_at else None,
"is_active": record.actual_removal_date is None,
}
@router.get("/deployments/{unit_id}")
def get_deployments(unit_id: str, db: Session = Depends(get_db)):
"""Get all deployment records for a unit, newest first."""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
records = (
db.query(DeploymentRecord)
.filter_by(unit_id=unit_id)
.order_by(DeploymentRecord.deployed_date.desc(), DeploymentRecord.created_at.desc())
.all()
)
return {"deployments": [_serialize(r) for r in records]}
@router.get("/deployments/{unit_id}/active")
def get_active_deployment(unit_id: str, db: Session = Depends(get_db)):
"""Get the current active deployment (actual_removal_date is NULL), or null."""
record = (
db.query(DeploymentRecord)
.filter(
DeploymentRecord.unit_id == unit_id,
DeploymentRecord.actual_removal_date == None
)
.order_by(DeploymentRecord.created_at.desc())
.first()
)
return {"deployment": _serialize(record) if record else None}
@router.post("/deployments/{unit_id}")
def create_deployment(unit_id: str, payload: dict, db: Session = Depends(get_db)):
"""
Create a new deployment record for a unit.
Body fields (all optional):
deployed_date (YYYY-MM-DD)
estimated_removal_date (YYYY-MM-DD)
project_ref (freeform string)
project_id (UUID if linked to Project)
location_name
notes
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
def parse_date(val) -> Optional[date]:
if not val:
return None
if isinstance(val, date):
return val
return date.fromisoformat(str(val))
record = DeploymentRecord(
id=str(uuid.uuid4()),
unit_id=unit_id,
deployed_date=parse_date(payload.get("deployed_date")),
estimated_removal_date=parse_date(payload.get("estimated_removal_date")),
actual_removal_date=None,
project_ref=payload.get("project_ref"),
project_id=payload.get("project_id"),
location_name=payload.get("location_name"),
notes=payload.get("notes"),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(record)
db.commit()
db.refresh(record)
return _serialize(record)
@router.put("/deployments/{unit_id}/{deployment_id}")
def update_deployment(unit_id: str, deployment_id: str, payload: dict, db: Session = Depends(get_db)):
"""
Update a deployment record. Used for:
- Setting/changing estimated_removal_date
- Closing a deployment (set actual_removal_date to mark unit returned)
- Editing project_ref, location_name, notes
"""
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
if not record:
raise HTTPException(status_code=404, detail="Deployment record not found")
def parse_date(val) -> Optional[date]:
if val is None:
return None
if val == "":
return None
if isinstance(val, date):
return val
return date.fromisoformat(str(val))
if "deployed_date" in payload:
record.deployed_date = parse_date(payload["deployed_date"])
if "estimated_removal_date" in payload:
record.estimated_removal_date = parse_date(payload["estimated_removal_date"])
if "actual_removal_date" in payload:
record.actual_removal_date = parse_date(payload["actual_removal_date"])
if "project_ref" in payload:
record.project_ref = payload["project_ref"]
if "project_id" in payload:
record.project_id = payload["project_id"]
if "location_name" in payload:
record.location_name = payload["location_name"]
if "notes" in payload:
record.notes = payload["notes"]
record.updated_at = datetime.utcnow()
db.commit()
db.refresh(record)
return _serialize(record)
@router.delete("/deployments/{unit_id}/{deployment_id}")
def delete_deployment(unit_id: str, deployment_id: str, db: Session = Depends(get_db)):
"""Delete a deployment record."""
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
if not record:
raise HTTPException(status_code=404, detail="Deployment record not found")
db.delete(record)
db.commit()
return {"ok": True}

View File

@@ -701,6 +701,8 @@ async def get_planner_availability(
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
"deployed": u.deployed,
"out_for_calibration": u.out_for_calibration or False,
"allocated": getattr(u, 'allocated', False) or False,
"allocated_to_project_id": getattr(u, 'allocated_to_project_id', None) or "",
"note": u.note or "",
"reservations": unit_reservations.get(u.id, [])
})
@@ -716,6 +718,56 @@ async def get_planner_availability(
}
@router.get("/api/fleet-calendar/unit-quick-info/{unit_id}", response_class=JSONResponse)
async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
"""Return at-a-glance info for the planner quick-view modal."""
from backend.models import Emitter
u = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not u:
raise HTTPException(status_code=404, detail="Unit not found")
today = date.today()
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
# Active/upcoming reservations
assignments = db.query(JobReservationUnit).filter(JobReservationUnit.unit_id == unit_id).all()
reservations = []
for a in assignments:
res = db.query(JobReservation).filter(
JobReservation.id == a.reservation_id,
JobReservation.end_date >= today
).first()
if res:
reservations.append({
"name": res.name,
"start_date": res.start_date.isoformat() if res.start_date else None,
"end_date": res.end_date.isoformat() if res.end_date else None,
"end_date_tbd": res.end_date_tbd,
"color": res.color or "#3B82F6",
"location_name": a.location_name,
})
# Last seen from emitter
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
return {
"id": u.id,
"unit_type": u.unit_type,
"deployed": u.deployed,
"out_for_calibration": u.out_for_calibration or False,
"note": u.note or "",
"project_id": u.project_id or "",
"address": u.address or u.location or "",
"coordinates": u.coordinates or "",
"deployed_with_modem_id": u.deployed_with_modem_id or "",
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
"cal_expired": not u.last_calibrated or (expiry and expiry < today),
"last_seen": emitter.last_seen.isoformat() if emitter and emitter.last_seen else None,
"reservations": reservations,
}
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
async def get_available_units_partial(
request: Request,

View File

@@ -31,10 +31,24 @@ from backend.models import (
DataFile,
)
from backend.templates_config import templates
from backend.utils.timezone import local_to_utc
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
# ============================================================================
# Shared helpers
# ============================================================================
def _require_sound_project(project) -> None:
"""Raise 400 if the project is not a sound_monitoring project."""
if not project or project.project_type_id != "sound_monitoring":
raise HTTPException(
status_code=400,
detail="This feature is only available for Sound Monitoring projects.",
)
# ============================================================================
# Session period helpers
# ============================================================================
@@ -98,11 +112,11 @@ async def get_project_locations(
# Enrich with assignment info
locations_data = []
for location in locations:
# Get active assignment
# Get active assignment (active = assigned_until IS NULL)
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location.id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).first()
@@ -258,11 +272,11 @@ async def delete_location(
if not location:
raise HTTPException(status_code=404, detail="Location not found")
# Check if location has active assignments
# Check if location has active assignments (active = assigned_until IS NULL)
active_assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).count()
@@ -353,18 +367,18 @@ async def assign_unit_to_location(
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
)
# Check if location already has an active assignment
# Check if location already has an active assignment (active = assigned_until IS NULL)
existing_assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).first()
if existing_assignment:
raise HTTPException(
status_code=400,
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Unassign first.",
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
)
# Create new assignment
@@ -433,10 +447,120 @@ async def unassign_unit(
return {"success": True, "message": "Unit unassigned successfully"}
@router.post("/locations/{location_id}/swap")
async def swap_unit_on_location(
project_id: str,
location_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Swap the unit assigned to a vibration monitoring location.
Ends the current active assignment (if any), creates a new one,
and optionally updates modem pairing on the seismograph.
Works for first-time assignments too (no current assignment = just create).
"""
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")
form_data = await request.form()
unit_id = form_data.get("unit_id")
modem_id = form_data.get("modem_id") or None
notes = form_data.get("notes") or None
if not unit_id:
raise HTTPException(status_code=400, detail="unit_id is required")
# Validate new unit
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
if unit.device_type != expected_device_type:
raise HTTPException(
status_code=400,
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
)
# End current active assignment if one exists (active = assigned_until IS NULL)
current = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.assigned_until == None,
)
).first()
if current:
current.assigned_until = datetime.utcnow()
current.status = "completed"
# Create new assignment
new_assignment = UnitAssignment(
id=str(uuid.uuid4()),
unit_id=unit_id,
location_id=location_id,
project_id=project_id,
device_type=unit.device_type,
assigned_until=None,
status="active",
notes=notes,
)
db.add(new_assignment)
# Update modem pairing on the seismograph if modem provided
if modem_id:
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
unit.deployed_with_modem_id = modem_id
modem.deployed_with_unit_id = unit_id
else:
# Clear modem pairing if not provided
unit.deployed_with_modem_id = None
db.commit()
return JSONResponse({
"success": True,
"assignment_id": new_assignment.id,
"message": f"Unit '{unit_id}' assigned to '{location.name}'" + (f" with modem '{modem_id}'" if modem_id else ""),
})
# ============================================================================
# Available Units for Assignment
# ============================================================================
@router.get("/available-modems", response_class=JSONResponse)
async def get_available_modems(
project_id: str,
db: Session = Depends(get_db),
):
"""
Get all deployed, non-retired modems for the modem assignment dropdown.
"""
modems = db.query(RosterUnit).filter(
and_(
RosterUnit.device_type == "modem",
RosterUnit.deployed == True,
RosterUnit.retired == False,
)
).order_by(RosterUnit.id).all()
return [
{
"id": m.id,
"hardware_model": m.hardware_model,
"ip_address": m.ip_address,
}
for m in modems
]
@router.get("/available-units", response_class=JSONResponse)
async def get_available_units(
project_id: str,
@@ -459,9 +583,9 @@ async def get_available_units(
)
).all()
# Filter out units that already have active assignments
# Filter out units that already have active assignments (active = assigned_until IS NULL)
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
UnitAssignment.status == "active"
UnitAssignment.assigned_until == None
).distinct().all()
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
@@ -637,6 +761,9 @@ async def upload_nrl_data(
from datetime import datetime
# Verify project and location exist
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(
id=location_id, project_id=project_id
).first()
@@ -698,8 +825,15 @@ async def upload_nrl_data(
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"))
# RNH files store local time (no UTC offset). Use local values for period
# classification / label generation, then convert to UTC for DB storage so
# the local_datetime Jinja filter displays the correct time.
started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
started_at = local_to_utc(started_at_local)
stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
duration_seconds = None
if started_at and stopped_at:
duration_seconds = int((stopped_at - started_at).total_seconds())
@@ -709,8 +843,9 @@ async def upload_nrl_data(
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
# Use local times for period/label so classification reflects the clock at the site.
period_type = _derive_period_type(started_at_local) if started_at_local else None
session_label = _build_session_label(started_at_local, location.name, period_type) if started_at_local else None
session_id = str(uuid.uuid4())
monitoring_session = MonitoringSession(
@@ -815,15 +950,18 @@ async def get_nrl_live_status(
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.
Sound Monitoring projects only.
"""
import os
import httpx
# Find the assigned unit
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
# Find the assigned unit (active = assigned_until IS NULL)
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).first()

View File

@@ -20,7 +20,7 @@ import json
import logging
import io
from backend.utils.timezone import utc_to_local, format_local_datetime
from backend.utils.timezone import utc_to_local, format_local_datetime, local_to_utc
from backend.database import get_db
from fastapi import UploadFile, File
@@ -45,6 +45,21 @@ router = APIRouter(prefix="/api/projects", tags=["projects"])
logger = logging.getLogger(__name__)
# ============================================================================
# Shared helpers
# ============================================================================
def _require_sound_project(project: Project) -> None:
"""Raise 400 if the project is not a sound_monitoring project.
Call this at the top of any endpoint that only makes sense for sound projects
(report generation, FTP browser, RND file viewer, etc.)."""
if not project or project.project_type_id != "sound_monitoring":
raise HTTPException(
status_code=400,
detail="This feature is only available for Sound Monitoring projects.",
)
# ============================================================================
# RND file normalization — maps AU2 (older Rion) column names to the NL-43
# equivalents so report generation and the web viewer work for both formats.
@@ -398,11 +413,11 @@ async def get_projects_list(
project_id=project.id
).scalar()
# Count assigned units
# Count assigned units (active = assigned_until IS NULL)
unit_count = db.query(func.count(UnitAssignment.id)).filter(
and_(
UnitAssignment.project_id == project.id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).scalar()
@@ -806,11 +821,11 @@ async def get_project_dashboard(
# Get locations
locations = db.query(MonitoringLocation).filter_by(project_id=project_id).all()
# Get assigned units with details
# Get assigned units with details (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.project_id == project_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).all()
@@ -899,11 +914,11 @@ async def get_project_units(
"""
from backend.models import DataFile
# Get all assignments for this project
# Get all assignments for this project (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.project_id == project_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).all()
@@ -1128,7 +1143,7 @@ async def get_project_sessions(
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
# Enrich with unit and location details
# Enrich with unit, location, and effective time window details
sessions_data = []
for session in sessions:
unit = None
@@ -1139,10 +1154,34 @@ async def get_project_sessions(
if session.location_id:
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
# Compute "Effective: date time → date time" string when period hours are set
effective_range = None
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
from datetime import date as _date
local_start = utc_to_local(session.started_at)
start_day = session.report_date if session.report_date else local_start.date()
sh = session.period_start_hour
eh = session.period_end_hour
def _fmt_h(h):
ampm = "AM" if h < 12 else "PM"
h12 = h % 12 or 12
return f"{h12}:00 {ampm}"
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
if eh > sh: # same calendar day
end_day = start_day
else: # crosses midnight
from datetime import timedelta as _td
end_day = start_day + _td(days=1)
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
effective_range = f"{start_str}{end_str}"
sessions_data.append({
"session": session,
"unit": unit,
"location": location,
"effective_range": effective_range,
})
return templates.TemplateResponse("partials/projects/session_list.html", {
@@ -1152,6 +1191,173 @@ async def get_project_sessions(
})
@router.get("/{project_id}/sessions-calendar", response_class=HTMLResponse)
async def get_sessions_calendar(
project_id: str,
request: Request,
db: Session = Depends(get_db),
month: Optional[int] = Query(None),
year: Optional[int] = Query(None),
):
"""
Monthly calendar view of monitoring sessions.
Color-coded by NRL location. Returns HTML partial.
"""
from calendar import monthrange
from datetime import date as _date, timedelta as _td
# Default to current month
now_local = utc_to_local(datetime.utcnow())
if not year:
year = now_local.year
if not month:
month = now_local.month
# Clamp month to valid range
month = max(1, min(12, month))
# Load all sessions for this project
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
# Build location -> color map (deterministic)
PALETTE = [
"#f97316", "#3b82f6", "#10b981", "#8b5cf6",
"#ec4899", "#14b8a6", "#f59e0b", "#6366f1",
"#ef4444", "#84cc16",
]
loc_ids = sorted({s.location_id for s in sessions if s.location_id})
loc_color = {lid: PALETTE[i % len(PALETTE)] for i, lid in enumerate(loc_ids)}
# Load location names
loc_names = {}
for lid in loc_ids:
loc = db.query(MonitoringLocation).filter_by(id=lid).first()
if loc:
loc_names[lid] = loc.name
# Build calendar grid bounds first (needed for session spanning logic)
first_day = _date(year, month, 1)
last_day = _date(year, month, monthrange(year, month)[1])
days_before = (first_day.isoweekday() % 7)
grid_start = first_day - _td(days=days_before)
days_after = 6 - (last_day.isoweekday() % 7)
grid_end = last_day + _td(days=days_after)
def _period_hours(s):
"""Return (start_hour, end_hour) for a session, falling back to period_type defaults."""
psh, peh = s.period_start_hour, s.period_end_hour
if psh is None or peh is None:
if s.period_type and "night" in s.period_type:
return 19, 7
if s.period_type and "day" in s.period_type:
return 7, 19
return psh, peh
# Build day -> list of gantt segments
day_sessions: dict = {}
for s in sessions:
if not s.started_at:
continue
local_start = utc_to_local(s.started_at)
local_end = utc_to_local(s.stopped_at) if s.stopped_at else now_local
span_start = local_start.date()
span_end = local_end.date()
psh, peh = _period_hours(s)
cur_d = span_start
while cur_d <= span_end:
if grid_start <= cur_d <= grid_end:
# Device bar bounds (hours 024 within this day)
dev_sh = (local_start.hour + local_start.minute / 60.0) if cur_d == span_start else 0.0
dev_eh = (local_end.hour + local_end.minute / 60.0) if cur_d == span_end else 24.0
# Effective window within this day
eff_sh = eff_eh = None
if psh is not None and peh is not None:
if psh < peh:
# Day window e.g. 7→19
eff_sh, eff_eh = float(psh), float(peh)
else:
# Night window crossing midnight e.g. 19→7
if cur_d == span_start:
eff_sh, eff_eh = float(psh), 24.0
else:
eff_sh, eff_eh = 0.0, float(peh)
# Format tooltip labels
def _fmt_h(h):
hh = int(h) % 24
mm = int((h % 1) * 60)
suffix = "AM" if hh < 12 else "PM"
return f"{hh % 12 or 12}:{mm:02d} {suffix}"
if cur_d not in day_sessions:
day_sessions[cur_d] = []
day_sessions[cur_d].append({
"session_id": s.id,
"label": s.session_label or f"Session {s.id[:8]}",
"location_id": s.location_id,
"location_name": loc_names.get(s.location_id, "Unknown"),
"color": loc_color.get(s.location_id, "#9ca3af"),
"status": s.status,
"period_type": s.period_type,
# Gantt bar percentages (0100 scale across 24 hours)
"dev_start_pct": round(dev_sh / 24 * 100, 1),
"dev_width_pct": max(1.5, round((dev_eh - dev_sh) / 24 * 100, 1)),
"eff_start_pct": round(eff_sh / 24 * 100, 1) if eff_sh is not None else None,
"eff_width_pct": max(1.0, round((eff_eh - eff_sh) / 24 * 100, 1)) if eff_sh is not None else None,
"dev_start_label": _fmt_h(dev_sh),
"dev_end_label": _fmt_h(dev_eh),
"eff_start_label": f"{int(psh):02d}:00" if eff_sh is not None else None,
"eff_end_label": f"{int(peh):02d}:00" if eff_sh is not None else None,
})
cur_d += _td(days=1)
weeks = []
cur = grid_start
while cur <= grid_end:
week = []
for _ in range(7):
week.append({
"date": cur,
"in_month": cur.month == month,
"is_today": cur == now_local.date(),
"sessions": day_sessions.get(cur, []),
})
cur += _td(days=1)
weeks.append(week)
# Prev/next month navigation
prev_month = month - 1 if month > 1 else 12
prev_year = year if month > 1 else year - 1
next_month = month + 1 if month < 12 else 1
next_year = year if month < 12 else year + 1
import calendar as _cal
month_name = _cal.month_name[month]
# Legend: only locations that have sessions this month
used_lids = {s["location_id"] for day in day_sessions.values() for s in day}
legend = [
{"location_id": lid, "name": loc_names.get(lid, lid[:8]), "color": loc_color[lid]}
for lid in loc_ids if lid in used_lids
]
return templates.TemplateResponse("partials/projects/sessions_calendar.html", {
"request": request,
"project_id": project_id,
"weeks": weeks,
"month": month,
"year": year,
"month_name": month_name,
"prev_month": prev_month,
"prev_year": prev_year,
"next_month": next_month,
"next_year": next_year,
"legend": legend,
})
@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
async def get_ftp_browser(
project_id: str,
@@ -1160,15 +1366,18 @@ async def get_ftp_browser(
):
"""
Get FTP browser interface for downloading files from assigned SLMs.
Returns HTML partial with FTP browser.
Returns HTML partial with FTP browser. Sound Monitoring projects only.
"""
from backend.models import DataFile
# Get all assignments for this project
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
# Get all assignments for this project (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.project_id == project_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).all()
@@ -1202,6 +1411,7 @@ async def ftp_download_to_server(
"""
Download a file from an SLM to the server via FTP.
Creates a DataFile record and stores the file in data/Projects/{project_id}/
Sound Monitoring projects only.
"""
import httpx
import os
@@ -1209,6 +1419,8 @@ async def ftp_download_to_server(
from pathlib import Path
from backend.models import DataFile
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
data = await request.json()
unit_id = data.get("unit_id")
remote_path = data.get("remote_path")
@@ -1367,12 +1579,15 @@ async def ftp_download_folder_to_server(
Download an entire folder from an SLM to the server via FTP.
Extracts all files from the ZIP and preserves folder structure.
Creates individual DataFile records for each file.
Sound Monitoring projects only.
"""
import httpx
import os
import hashlib
import zipfile
import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
from pathlib import Path
from backend.models import DataFile
@@ -1800,6 +2015,23 @@ async def delete_session(
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"}
def _derive_period_type(dt: datetime) -> str:
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:
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)
@router.patch("/{project_id}/sessions/{session_id}")
async def patch_session(
project_id: str,
@@ -1807,13 +2039,53 @@ async def patch_session(
data: dict,
db: Session = Depends(get_db),
):
"""Update session_label and/or period_type on a monitoring session."""
"""Update session fields: started_at, stopped_at, session_label, period_type."""
session = db.query(MonitoringSession).filter_by(id=session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id:
raise HTTPException(status_code=403, detail="Session does not belong to this project")
times_changed = False
if "started_at" in data and data["started_at"]:
try:
local_dt = datetime.fromisoformat(data["started_at"])
session.started_at = local_to_utc(local_dt)
times_changed = True
except ValueError:
raise HTTPException(status_code=400, detail="Invalid started_at datetime format")
if "stopped_at" in data:
if data["stopped_at"]:
try:
local_dt = datetime.fromisoformat(data["stopped_at"])
session.stopped_at = local_to_utc(local_dt)
times_changed = True
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stopped_at datetime format")
else:
session.stopped_at = None
times_changed = True
if times_changed and session.started_at and session.stopped_at:
delta = session.stopped_at - session.started_at
session.duration_seconds = max(0, int(delta.total_seconds()))
elif times_changed and not session.stopped_at:
session.duration_seconds = None
# Re-derive period_type and session_label from new started_at unless explicitly provided
if times_changed and session.started_at and "period_type" not in data:
local_start = utc_to_local(session.started_at)
session.period_type = _derive_period_type(local_start)
if times_changed and session.started_at and "session_label" not in data:
from backend.models import MonitoringLocation
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
location_name = location.name if location else ""
local_start = utc_to_local(session.started_at)
session.session_label = _build_session_label(local_start, location_name, session.period_type or "")
if "session_label" in data:
session.session_label = str(data["session_label"]).strip() or None
if "period_type" in data:
@@ -1822,8 +2094,111 @@ async def patch_session(
raise HTTPException(status_code=400, detail=f"Invalid period_type. Must be one of: {', '.join(sorted(VALID_PERIOD_TYPES))}")
session.period_type = pt or None
# Configurable period window (023 integers; null = no filter)
for field in ("period_start_hour", "period_end_hour"):
if field in data:
val = data[field]
if val is None or val == "":
setattr(session, field, None)
else:
try:
h = int(val)
if not (0 <= h <= 23):
raise ValueError
setattr(session, field, h)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail=f"{field} must be an integer 023 or null")
if "report_date" in data:
val = data["report_date"]
if val is None or val == "":
session.report_date = None
else:
try:
from datetime import date as _date
session.report_date = _date.fromisoformat(str(val))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid report_date format. Use YYYY-MM-DD.")
db.commit()
return JSONResponse({"status": "success", "session_label": session.session_label, "period_type": session.period_type})
return JSONResponse({
"status": "success",
"session_label": session.session_label,
"period_type": session.period_type,
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
"report_date": session.report_date.isoformat() if session.report_date else None,
})
@router.get("/{project_id}/sessions/{session_id}/detail", response_class=HTMLResponse)
async def view_session_detail(
request: Request,
project_id: str,
session_id: str,
db: Session = Depends(get_db),
):
"""
Session detail page: shows files, editable session info, data preview, and report actions.
"""
from backend.models import DataFile
from pathlib import Path
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.unit_id else None
# Load all data files for this session
files = db.query(DataFile).filter_by(session_id=session_id).order_by(DataFile.created_at).all()
# Compute effective time range string for display
effective_range = None
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
local_start = utc_to_local(session.started_at)
start_day = session.report_date if session.report_date else local_start.date()
sh = session.period_start_hour
eh = session.period_end_hour
def _fmt_h(h):
ampm = "AM" if h < 12 else "PM"
h12 = h % 12 or 12
return f"{h12}:00 {ampm}"
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
if eh > sh:
end_day = start_day
else:
from datetime import timedelta as _td
end_day = start_day + _td(days=1)
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
effective_range = f"{start_str}{end_str}"
# Parse session_metadata if present
session_meta = {}
if session.session_metadata:
try:
session_meta = json.loads(session.session_metadata)
except Exception:
pass
return templates.TemplateResponse("session_detail.html", {
"request": request,
"project": project,
"project_id": project_id,
"session": session,
"location": location,
"unit": unit,
"files": files,
"effective_range": effective_range,
"session_meta": session_meta,
"report_date": session.report_date.isoformat() if session.report_date else "",
})
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
@@ -1858,6 +2233,7 @@ async def view_rnd_file(
# Get project info
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
# Get location info if available
location = None
@@ -1889,6 +2265,8 @@ async def view_rnd_file(
"metadata": metadata,
"filename": file_path.name,
"is_leq": _is_leq_file(str(file_record.file_path), _peek_rnd_headers(file_path)),
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
})
@@ -1901,12 +2279,15 @@ async def get_rnd_data(
"""
Get parsed RND file data as JSON.
Returns the measurement data for charts and tables.
Sound Monitoring projects only.
"""
from backend.models import DataFile
from pathlib import Path
import csv
import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
# Get the file record
file_record = db.query(DataFile).filter_by(id=file_id).first()
if not file_record:
@@ -1995,6 +2376,8 @@ async def get_rnd_data(
"summary": summary,
"headers": summary["headers"],
"data": rows,
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
}
except Exception as e:
@@ -2063,6 +2446,7 @@ async def generate_excel_report(
# Get related data for report context
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Build full file path
@@ -2349,7 +2733,7 @@ async def generate_excel_report(
_plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4")
ws.add_chart(chart, "I4")
# --- Stats table: note at I28-I29, headers at I31, data rows 32-34 ---
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
@@ -2493,6 +2877,7 @@ async def preview_report_data(
# Get related data for report context
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Build full file path
@@ -2704,6 +3089,7 @@ async def generate_report_from_preview(
raise HTTPException(status_code=403, detail="File does not belong to this project")
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Extract data from request
@@ -2835,7 +3221,7 @@ async def generate_report_from_preview(
_plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4")
ws.add_chart(chart, "I4")
# --- Stats block starting at I28 ---
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
@@ -2984,6 +3370,7 @@ async def generate_combined_excel_report(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
# Get all sessions with measurement files
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
@@ -3182,7 +3569,7 @@ async def generate_combined_excel_report(
_plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4")
ws.add_chart(chart, "I4")
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
@@ -3329,6 +3716,7 @@ async def combined_report_wizard(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()
@@ -3421,7 +3809,10 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
"loc_name": loc_name,
"session_label": session.session_label or "",
"period_type": session.period_type or "",
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
"started_at": session.started_at,
"report_date": session.report_date,
"rows": [],
}
@@ -3462,25 +3853,39 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
pass
parsed.append((dt, row))
# Determine which rows to keep based on period_type
is_day_session = period_type in ('weekday_day', 'weekend_day')
# Determine effective hour window.
# Prefer per-session period_start/end_hour; fall back to hardcoded defaults.
sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night
eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night
if sh is None or eh is None:
# Legacy defaults based on period_type
is_day_session = period_type in ('weekday_day', 'weekend_day')
sh = 7 if is_day_session else 19
eh = 19 if is_day_session else 7
else:
is_day_session = eh > sh # crosses midnight when end < start
target_date = None
if is_day_session:
# Day: 07:0018:59 only, restricted to the LAST calendar date that has daytime rows
daytime_dates = sorted({
dt.date() for dt, row in parsed
if dt and 7 <= dt.hour < 19
})
target_date = daytime_dates[-1] if daytime_dates else None
# Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
in_window = lambda h: sh <= h < eh
if entry.get("report_date"):
target_date = entry["report_date"]
else:
daytime_dates = sorted({
dt.date() for dt, row in parsed if dt and in_window(dt.hour)
})
target_date = daytime_dates[-1] if daytime_dates else None
filtered = [
(dt, row) for dt, row in parsed
if dt and dt.date() == target_date and 7 <= dt.hour < 19
if dt and dt.date() == target_date and in_window(dt.hour)
]
else:
# Night: 19:0006:59, spanning both calendar days — no date restriction
# Night-style: hour >= start_h OR hour < end_h (crosses midnight)
in_window = lambda h: h >= sh or h < eh
filtered = [
(dt, row) for dt, row in parsed
if dt and (dt.hour >= 19 or dt.hour < 7)
if dt and in_window(dt.hour)
]
# Fall back to all rows if filtering removed everything
@@ -3598,6 +4003,7 @@ async def generate_combined_from_preview(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
report_title = data.get("report_title", "Background Noise Study")
project_name = data.get("project_name", project.name)
@@ -3760,7 +4166,7 @@ async def generate_combined_from_preview(
_plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4")
ws.add_chart(chart, "I4")
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
@@ -4073,6 +4479,7 @@ async def upload_all_project_data(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
# Load all sound monitoring locations for this project
locations = db.query(MonitoringLocation).filter_by(

View File

@@ -9,7 +9,8 @@ import httpx
import os
from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
import uuid
from backend.services.slmm_sync import sync_slm_to_slmm
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
@@ -27,6 +28,38 @@ def get_calibration_interval(db: Session) -> int:
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
def sync_deployment_record(db: Session, unit: RosterUnit, new_deployed: bool):
"""
Keep DeploymentRecord in sync with the deployed flag.
deployed True → open a new DeploymentRecord if none is already open.
deployed False → close the active DeploymentRecord by setting actual_removal_date = today.
"""
if new_deployed:
existing = db.query(DeploymentRecord).filter(
DeploymentRecord.unit_id == unit.id,
DeploymentRecord.actual_removal_date == None
).first()
if not existing:
record = DeploymentRecord(
id=str(uuid.uuid4()),
unit_id=unit.id,
project_ref=unit.project_id or None,
deployed_date=date.today(),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(record)
else:
active = db.query(DeploymentRecord).filter(
DeploymentRecord.unit_id == unit.id,
DeploymentRecord.actual_removal_date == None
).first()
if active:
active.actual_removal_date = date.today()
active.updated_at = datetime.utcnow()
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
"""Helper function to record a change in unit history"""
@@ -467,6 +500,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"deployed": unit.deployed,
"retired": unit.retired,
"out_for_calibration": unit.out_for_calibration or False,
"allocated": getattr(unit, 'allocated', False) or False,
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
"note": unit.note or "",
"project_id": unit.project_id or "",
"location": unit.location or "",
@@ -499,6 +534,8 @@ async def edit_roster_unit(
deployed: str = Form(None),
retired: str = Form(None),
out_for_calibration: str = Form(None),
allocated: str = Form(None),
allocated_to_project_id: str = Form(None),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
@@ -541,6 +578,7 @@ async def edit_roster_unit(
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed 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
allocated_bool = allocated in ['true', 'True', '1', 'yes'] if allocated else False
# Convert port strings to integers
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -578,6 +616,8 @@ async def edit_roster_unit(
unit.deployed = deployed_bool
unit.retired = retired_bool
unit.out_for_calibration = out_for_calibration_bool
unit.allocated = allocated_bool
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
unit.note = note
unit.project_id = project_id
unit.location = location
@@ -679,6 +719,7 @@ async def edit_roster_unit(
status_text = "deployed" if deployed else "benched"
old_status_text = "deployed" if old_deployed else "benched"
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
sync_deployment_record(db, unit, deployed_bool)
if old_retired != retired:
status_text = "retired" if retired else "active"
@@ -795,6 +836,7 @@ async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = D
new_value=status_text,
source="manual"
)
sync_deployment_record(db, unit, deployed)
db.commit()

View File

@@ -3,13 +3,13 @@ Seismograph Dashboard API Router
Provides endpoints for the seismograph-specific dashboard
"""
from datetime import date
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Request, Depends, Query
from fastapi import APIRouter, Request, Depends, Query, Form, HTTPException
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import RosterUnit
from backend.models import RosterUnit, UnitHistory, UserPreferences
from backend.templates_config import templates
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
@@ -120,3 +120,109 @@ async def get_seismo_units(
"today": date.today()
}
)
def _get_calibration_interval(db: Session) -> int:
prefs = db.query(UserPreferences).first()
if prefs and prefs.calibration_interval_days:
return prefs.calibration_interval_days
return 365
def _row_context(request: Request, unit: RosterUnit) -> dict:
return {"request": request, "unit": unit, "today": date.today()}
@router.get("/unit/{unit_id}/view-row", response_class=HTMLResponse)
async def get_seismo_view_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
@router.get("/unit/{unit_id}/edit-row", response_class=HTMLResponse)
async def get_seismo_edit_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
return templates.TemplateResponse("partials/seismo_row_edit.html", _row_context(request, unit))
@router.post("/unit/{unit_id}/quick-update", response_class=HTMLResponse)
async def quick_update_seismo_unit(
unit_id: str,
request: Request,
db: Session = Depends(get_db),
status: str = Form(...),
last_calibrated: str = Form(""),
note: str = Form(""),
):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
# --- Status ---
old_deployed = unit.deployed
old_out_for_cal = unit.out_for_calibration
if status == "deployed":
unit.deployed = True
unit.out_for_calibration = False
elif status == "out_for_calibration":
unit.deployed = False
unit.out_for_calibration = True
else:
unit.deployed = False
unit.out_for_calibration = False
if unit.deployed != old_deployed or unit.out_for_calibration != old_out_for_cal:
old_status = "deployed" if old_deployed else ("out_for_calibration" if old_out_for_cal else "benched")
db.add(UnitHistory(
unit_id=unit_id,
change_type="deployed_change",
field_name="status",
old_value=old_status,
new_value=status,
source="manual",
))
# --- Last calibrated ---
old_cal = unit.last_calibrated
if last_calibrated:
try:
new_cal = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
unit.last_calibrated = new_cal
unit.next_calibration_due = new_cal + timedelta(days=_get_calibration_interval(db))
else:
unit.last_calibrated = None
unit.next_calibration_due = None
if unit.last_calibrated != old_cal:
db.add(UnitHistory(
unit_id=unit_id,
change_type="calibration_status_change",
field_name="last_calibrated",
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
new_value=last_calibrated or None,
source="manual",
))
# --- Note ---
old_note = unit.note
unit.note = note or None
if unit.note != old_note:
db.add(UnitHistory(
unit_id=unit_id,
change_type="note_change",
field_name="note",
old_value=old_note,
new_value=unit.note,
source="manual",
))
db.commit()
db.refresh(unit)
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))

View File

@@ -15,7 +15,7 @@ from sqlalchemy import and_, or_
from backend.models import (
RosterUnit, JobReservation, JobReservationUnit,
UserPreferences, Project
UserPreferences, Project, DeploymentRecord
)
@@ -70,6 +70,19 @@ def get_unit_reservations_on_date(
return reservations
def get_active_deployment(db: Session, unit_id: str) -> Optional[DeploymentRecord]:
"""Return the active (unreturned) deployment record for a unit, or None."""
return (
db.query(DeploymentRecord)
.filter(
DeploymentRecord.unit_id == unit_id,
DeploymentRecord.actual_removal_date == None
)
.order_by(DeploymentRecord.created_at.desc())
.first()
)
def is_unit_available_on_date(
db: Session,
unit: RosterUnit,
@@ -82,8 +95,8 @@ def is_unit_available_on_date(
Returns:
(is_available, status, reservation_name)
- is_available: True if unit can be assigned to new work
- status: "available", "reserved", "expired", "retired", "needs_calibration"
- reservation_name: Name of blocking reservation (if any)
- status: "available", "reserved", "expired", "retired", "needs_calibration", "in_field"
- reservation_name: Name of blocking reservation or project ref (if any)
"""
# Check if retired
if unit.retired:
@@ -96,6 +109,12 @@ def is_unit_available_on_date(
if cal_status == "needs_calibration":
return False, "needs_calibration", None
# Check for an active deployment record (unit is physically in the field)
active_deployment = get_active_deployment(db, unit.id)
if active_deployment:
label = active_deployment.project_ref or "Field deployment"
return False, "in_field", label
# Check if already reserved
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
if reservations:
@@ -136,6 +155,7 @@ def get_day_summary(
expired_units = []
expiring_soon_units = []
needs_calibration_units = []
in_field_units = []
cal_expiring_today = [] # Units whose calibration expires ON this day
for unit in units:
@@ -167,6 +187,9 @@ def get_day_summary(
available_units.append(unit_info)
if cal_status == "expiring_soon":
expiring_soon_units.append(unit_info)
elif status == "in_field":
unit_info["project_ref"] = reservation_name
in_field_units.append(unit_info)
elif status == "reserved":
unit_info["reservation_name"] = reservation_name
reserved_units.append(unit_info)
@@ -207,6 +230,7 @@ def get_day_summary(
"date": check_date.isoformat(),
"device_type": device_type,
"available_units": available_units,
"in_field_units": in_field_units,
"reserved_units": reserved_units,
"expired_units": expired_units,
"expiring_soon_units": expiring_soon_units,
@@ -215,6 +239,7 @@ def get_day_summary(
"reservations": reservation_list,
"counts": {
"available": len(available_units),
"in_field": len(in_field_units),
"reserved": len(reserved_units),
"expired": len(expired_units),
"expiring_soon": len(expiring_soon_units),
@@ -285,6 +310,14 @@ def get_calendar_year_data(
unit_reservations[unit_id] = []
unit_reservations[unit_id].append((start_d, end_d, res.name))
# Build set of unit IDs that have an active deployment record (still in the field)
unit_ids = [u.id for u in units]
active_deployments = db.query(DeploymentRecord.unit_id).filter(
DeploymentRecord.unit_id.in_(unit_ids),
DeploymentRecord.actual_removal_date == None
).all()
unit_in_field = {row.unit_id for row in active_deployments}
# Generate data for each month
months_data = {}
@@ -301,6 +334,7 @@ def get_calendar_year_data(
while current_day <= last_day:
available = 0
in_field = 0
reserved = 0
expired = 0
expiring_soon = 0
@@ -328,6 +362,11 @@ def get_calendar_year_data(
needs_cal += 1
continue
# Check active deployment record (in field)
if unit.id in unit_in_field:
in_field += 1
continue
# Check if reserved
is_reserved = False
if unit.id in unit_reservations:
@@ -346,6 +385,7 @@ def get_calendar_year_data(
days_data[current_day.day] = {
"available": available,
"in_field": in_field,
"reserved": reserved,
"expired": expired,
"expiring_soon": expiring_soon,
@@ -462,6 +502,14 @@ def get_rolling_calendar_data(
unit_reservations[unit_id] = []
unit_reservations[unit_id].append((start_d, end_d, res.name))
# Build set of unit IDs that have an active deployment record (still in the field)
unit_ids = [u.id for u in units]
active_deployments = db.query(DeploymentRecord.unit_id).filter(
DeploymentRecord.unit_id.in_(unit_ids),
DeploymentRecord.actual_removal_date == None
).all()
unit_in_field = {row.unit_id for row in active_deployments}
# Generate data for each of the 12 months
months_data = []
current_year = start_year
@@ -640,11 +688,22 @@ def get_available_units_for_period(
for a in assigned:
reserved_unit_ids.add(a.unit_id)
# Get units with active deployment records (still in the field)
unit_ids = [u.id for u in units]
active_deps = db.query(DeploymentRecord.unit_id).filter(
DeploymentRecord.unit_id.in_(unit_ids),
DeploymentRecord.actual_removal_date == None
).all()
in_field_unit_ids = {row.unit_id for row in active_deps}
available_units = []
for unit in units:
# Check if already reserved
if unit.id in reserved_unit_ids:
continue
# Check if currently in the field
if unit.id in in_field_unit_ids:
continue
if unit.last_calibrated:
expiry_date = unit.last_calibrated + timedelta(days=365)

View File

@@ -86,6 +86,12 @@ def emit_status_snapshot():
age = "N/A"
last_seen = None
fname = ""
elif getattr(r, 'allocated', False) and not r.deployed:
# Allocated: staged for an upcoming job, not yet physically deployed
status = "Allocated"
age = "N/A"
last_seen = None
fname = ""
else:
if e:
last_seen = ensure_utc(e.last_seen)
@@ -110,6 +116,8 @@ def emit_status_snapshot():
"note": r.note or "",
"retired": r.retired,
"out_for_calibration": r.out_for_calibration or False,
"allocated": getattr(r, 'allocated', False) or False,
"allocated_to_project_id": getattr(r, 'allocated_to_project_id', None) or "",
# Device type and type-specific fields
"device_type": r.device_type or "seismograph",
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
@@ -141,6 +149,8 @@ def emit_status_snapshot():
"note": "",
"retired": False,
"out_for_calibration": False,
"allocated": False,
"allocated_to_project_id": "",
# Device type and type-specific fields (defaults for unknown units)
"device_type": "seismograph", # default
"last_calibrated": None,
@@ -192,7 +202,12 @@ def emit_status_snapshot():
benched_units = {
uid: u for uid, u in units.items()
if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored
if not u["retired"] and not u["out_for_calibration"] and not u["allocated"] and not u["deployed"] and uid not in ignored
}
allocated_units = {
uid: u for uid, u in units.items()
if not u["retired"] and not u["out_for_calibration"] and u["allocated"] and not u["deployed"] and uid not in ignored
}
retired_units = {
@@ -216,13 +231,15 @@ def emit_status_snapshot():
"units": units,
"active": active_units,
"benched": benched_units,
"allocated": allocated_units,
"retired": retired_units,
"out_for_calibration": out_for_calibration_units,
"unknown": unknown_units,
"summary": {
"total": len(active_units) + len(benched_units),
"total": len(active_units) + len(benched_units) + len(allocated_units),
"active": len(active_units),
"benched": len(benched_units),
"allocated": len(allocated_units),
"retired": len(retired_units),
"out_for_calibration": len(out_for_calibration_units),
"unknown": len(unknown_units),

View File

@@ -73,10 +73,16 @@ def jinja_log_tail_display(s):
return str(s)
def jinja_local_datetime_input(dt):
"""Jinja filter: format UTC datetime as local YYYY-MM-DDTHH:MM for <input type='datetime-local'>."""
return format_local_datetime(dt, "%Y-%m-%dT%H:%M")
# Register Jinja filters and globals
templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time
templates.env.filters["local_date"] = jinja_local_date
templates.env.filters["local_datetime_input"] = jinja_local_datetime_input
templates.env.filters["fromjson"] = jinja_fromjson
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone

View File

@@ -57,6 +57,10 @@
<span class="text-gray-600 dark:text-gray-400">Benched</span>
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
<div class="flex justify-between items-center mb-1">
@@ -703,6 +707,7 @@ function updateDashboard(event) {
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;

View File

@@ -650,7 +650,7 @@
<!-- Fleet Summary (shown on jobs list) -->
<div id="right-fleet-summary" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
<div id="fleet-summary-stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
<div id="fleet-summary-stats" class="flex flex-col gap-0">
<!-- Populated by JS -->
</div>
<input type="text" id="summary-search" placeholder="Search by unit ID..."
@@ -713,6 +713,70 @@
</div>
</div>
<!-- Unit Quick-Info Modal -->
<div id="unit-quick-modal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeUnitQuickModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4 pointer-events-none">
<div id="unit-quick-modal-inner" class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md pointer-events-auto" onclick="event.stopPropagation()">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<h3 id="uqm-title" class="text-lg font-bold text-gray-900 dark:text-white"></h3>
<span id="uqm-deployed-badge"></span>
<span id="uqm-outforcal-badge"></span>
</div>
<button onclick="closeUnitQuickModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Body -->
<div class="px-5 py-4 flex flex-col gap-4">
<!-- Cal row -->
<div class="flex gap-6">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Last Calibration</p>
<p id="uqm-cal-date" class="text-sm font-medium text-gray-900 dark:text-white"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Cal Due</p>
<p id="uqm-cal-due" class="text-sm font-medium"></p>
</div>
</div>
<!-- Location / address -->
<div id="uqm-address-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Address / Location</p>
<p id="uqm-address" class="text-sm text-gray-800 dark:text-gray-200"></p>
</div>
<!-- Project -->
<div id="uqm-project-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Project</p>
<p id="uqm-project" class="text-sm text-gray-800 dark:text-gray-200"></p>
</div>
<!-- Modem -->
<div id="uqm-modem-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Deployed With Modem</p>
<p id="uqm-modem" class="text-sm text-gray-800 dark:text-gray-200"></p>
</div>
<!-- Last seen -->
<div id="uqm-lastseen-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Last Seen</p>
<p id="uqm-lastseen" class="text-sm text-gray-800 dark:text-gray-200"></p>
</div>
<!-- Note -->
<div id="uqm-note-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Note</p>
<p id="uqm-note" class="text-sm text-gray-800 dark:text-gray-200 italic"></p>
</div>
<!-- Reservations -->
<div id="uqm-reservations-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Upcoming Jobs</p>
<div id="uqm-reservations" class="flex flex-col gap-1"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Day Detail Slide Panel -->
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
<div id="day-panel" class="slide-panel">
@@ -1434,6 +1498,8 @@ function toggleJobLayer(layer) {
// ============================================================
// Reservation Planner
// ============================================================
let plannerSelectedSlotIdx = null;
let plannerState = {
reservation_id: null, // null = creating new
slots: [], // array of {unit_id: string|null, power_type: string|null, notes: string|null, location_name: string|null}
@@ -1607,6 +1673,22 @@ function plannerRenderUnits() {
const placeholder = document.getElementById('planner-units-placeholder');
const list = document.getElementById('planner-units-list');
// Show/hide slot-selection hint banner
let slotHint = document.getElementById('planner-slot-hint');
if (!slotHint) {
slotHint = document.createElement('div');
slotHint.id = 'planner-slot-hint';
list.parentNode.insertBefore(slotHint, list);
}
if (plannerSelectedSlotIdx !== null) {
const slotNum = plannerSelectedSlotIdx + 1;
slotHint.className = 'mb-2 px-3 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700 text-sm text-blue-700 dark:text-blue-300';
slotHint.textContent = `Assigning to Loc. ${slotNum} — click a unit below`;
} else {
slotHint.className = 'hidden';
slotHint.textContent = '';
}
if (plannerState.allUnits.length === 0) {
placeholder.classList.remove('hidden');
const start = document.getElementById('planner-start').value;
@@ -1660,7 +1742,7 @@ function plannerRenderUnits() {
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}')"
<button onclick="event.stopPropagation(); openUnitQuickModal('${unit.id}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${unit.id}</button>
${deployedBadge}
${expiryWarning}
@@ -1689,6 +1771,108 @@ function closeUnitDetailModal() {
document.getElementById('unit-detail-iframe').src = '';
}
async function openUnitQuickModal(unitId) {
document.getElementById('unit-quick-modal').classList.remove('hidden');
// Reset while loading
document.getElementById('uqm-title').textContent = unitId;
document.getElementById('uqm-deployed-badge').innerHTML = '';
document.getElementById('uqm-outforcal-badge').innerHTML = '';
document.getElementById('uqm-cal-date').textContent = '…';
document.getElementById('uqm-cal-due').textContent = '…';
['uqm-address-row','uqm-project-row','uqm-modem-row','uqm-lastseen-row','uqm-note-row','uqm-reservations-row']
.forEach(id => document.getElementById(id).classList.add('hidden'));
try {
const resp = await fetch(`/api/fleet-calendar/unit-quick-info/${unitId}`);
if (!resp.ok) throw new Error('Not found');
const u = await resp.json();
const today = new Date(); today.setHours(0,0,0,0);
// Deployed badge
document.getElementById('uqm-deployed-badge').innerHTML = u.deployed
? '<span class="text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full">Deployed</span>'
: '<span class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full">Benched</span>';
// Out for cal badge
if (u.out_for_calibration) {
document.getElementById('uqm-outforcal-badge').innerHTML =
'<span class="text-xs px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded-full">Out for Cal</span>';
}
// Cal date
const calDateEl = document.getElementById('uqm-cal-date');
calDateEl.textContent = u.last_calibrated
? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
: 'No record';
calDateEl.className = `text-sm font-medium ${!u.last_calibrated ? 'text-red-500 dark:text-red-400' : 'text-gray-900 dark:text-white'}`;
// Cal due
const calDueEl = document.getElementById('uqm-cal-due');
if (u.next_calibration_due) {
const due = new Date(u.next_calibration_due + 'T00:00:00');
const expired = due < today;
calDueEl.textContent = due.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) + (expired ? ' (expired)' : '');
calDueEl.className = `text-sm font-medium ${expired ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`;
} else {
calDueEl.textContent = '—';
calDueEl.className = 'text-sm font-medium text-red-500 dark:text-red-400';
}
// Address
if (u.address) {
document.getElementById('uqm-address-row').classList.remove('hidden');
document.getElementById('uqm-address').textContent = u.address;
}
// Project
if (u.project_id) {
document.getElementById('uqm-project-row').classList.remove('hidden');
document.getElementById('uqm-project').textContent = u.project_id;
}
// Modem
if (u.deployed_with_modem_id) {
document.getElementById('uqm-modem-row').classList.remove('hidden');
document.getElementById('uqm-modem').textContent = u.deployed_with_modem_id;
}
// Last seen
if (u.last_seen) {
document.getElementById('uqm-lastseen-row').classList.remove('hidden');
document.getElementById('uqm-lastseen').textContent =
new Date(u.last_seen).toLocaleString('en-US', {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
}
// Note
if (u.note) {
document.getElementById('uqm-note-row').classList.remove('hidden');
document.getElementById('uqm-note').textContent = u.note;
}
// Reservations
if (u.reservations && u.reservations.length > 0) {
document.getElementById('uqm-reservations-row').classList.remove('hidden');
document.getElementById('uqm-reservations').innerHTML = u.reservations.map(r => {
const s = r.start_date ? new Date(r.start_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : '';
const e = r.end_date_tbd ? 'TBD' : (r.end_date ? new Date(r.end_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : 'TBD');
const loc = r.location_name ? ` · ${r.location_name}` : '';
return `<div class="flex items-center gap-2 text-sm">
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:${r.color}"></span>
<span class="font-medium text-gray-800 dark:text-gray-200">${r.name}</span>
<span class="text-gray-400 dark:text-gray-500">${s}${e}${loc}</span>
</div>`;
}).join('');
}
} catch(e) {
document.getElementById('uqm-cal-date').textContent = 'Error loading';
}
}
function closeUnitQuickModal() {
document.getElementById('unit-quick-modal').classList.add('hidden');
}
function plannerAddSlot() {
plannerState.slots.push({ unit_id: null, power_type: null, notes: null, location_name: null });
plannerRenderSlots();
@@ -1698,7 +1882,7 @@ function plannerAddSlot() {
// Fleet Summary (right panel on jobs list)
// ============================================================
let summaryAllUnits = [];
let summaryActiveFilter = null; // null | 'deployed' | 'benched' | 'cal_expired'
let summaryActiveFilters = new Set(); // multi-select: 'deployed' | 'benched' | 'cal_expired' | 'cal_good' | 'out_for_cal' | 'reserved'
async function loadFleetSummary() {
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
@@ -1706,7 +1890,7 @@ async function loadFleetSummary() {
const resp = await fetch(`/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`);
const data = await resp.json();
summaryAllUnits = data.units || [];
summaryActiveFilter = null;
summaryActiveFilters = new Set();
renderFleetSummary();
} catch(e) { console.error('Fleet summary load error', e); }
}
@@ -1715,88 +1899,158 @@ function summaryFilterUnits() {
renderFleetSummary();
}
// Stat cards: set exactly this one filter (or clear all if already the only active one)
function summarySetFilter(f) {
summaryActiveFilter = summaryActiveFilter === f ? null : f;
if (f === null) {
summaryActiveFilters = new Set();
} else if (summaryActiveFilters.size === 1 && summaryActiveFilters.has(f)) {
summaryActiveFilters = new Set();
} else {
summaryActiveFilters = new Set([f]);
}
renderFleetSummary();
}
// Pills: toggle independently (multi-select)
function summaryToggleFilter(f) {
if (summaryActiveFilters.has(f)) summaryActiveFilters.delete(f);
else summaryActiveFilters.add(f);
renderFleetSummary();
}
function renderFleetSummary() {
const search = document.getElementById('summary-search')?.value.toLowerCase() || '';
const today = new Date(); today.setHours(0,0,0,0);
// Stats (always against full list)
const total = summaryAllUnits.length;
const deployed = summaryAllUnits.filter(u => u.deployed).length;
const benched = summaryAllUnits.filter(u => !u.deployed).length;
const calExpired = summaryAllUnits.filter(u => u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date()).length;
// Computed flags for each unit
const withFlags = summaryAllUnits.map(u => {
const expiry = u.expiry_date ? new Date(u.expiry_date + 'T00:00:00') : null;
return {
...u,
_calExpired: !u.last_calibrated || (expiry && expiry < today),
_calGood: u.last_calibrated && expiry && expiry >= today,
_outForCal: !!u.out_for_calibration,
_allocated: !!u.allocated,
_reserved: (u.reservations || []).length > 0,
};
});
// Counts always against full list
const counts = {
total: withFlags.length,
deployed: withFlags.filter(u => u.deployed).length,
benched: withFlags.filter(u => !u.deployed).length,
cal_expired: withFlags.filter(u => u._calExpired).length,
cal_good: withFlags.filter(u => u._calGood).length,
out_for_cal: withFlags.filter(u => u._outForCal).length,
allocated: withFlags.filter(u => u._allocated).length,
reserved: withFlags.filter(u => u._reserved).length,
};
const af = summaryActiveFilters;
// Stat cards — single-shortcut behavior, highlighted when they're the sole active filter
const cardActive = (f) => af.size === 1 && af.has(f);
const card = (f, label, count, colorClass, ringColor) => {
const isActive = f === null ? af.size === 0 : cardActive(f);
return `<button onclick="summarySetFilter(${f === null ? 'null' : `'${f}'`})"
class="rounded-lg p-3 text-left w-full cursor-pointer transition-all ring-2 ${isActive ? ringColor : 'ring-transparent'} ${colorClass}">
<p class="text-2xl font-bold">${count}</p>
<p class="text-xs opacity-80">${label}</p>
</button>`;
};
const cardBase = 'rounded-lg p-3 text-left w-full cursor-pointer transition-all ring-2';
const active = summaryActiveFilter;
document.getElementById('fleet-summary-stats').innerHTML = `
<button onclick="summarySetFilter(null)"
class="${cardBase} ${!active ? 'ring-gray-400 dark:ring-gray-300' : 'ring-transparent'} bg-gray-50 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-slate-600">
<p class="text-2xl font-bold text-gray-900 dark:text-white">${total}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Total</p>
</button>
<button onclick="summarySetFilter('deployed')"
class="${cardBase} ${active === 'deployed' ? 'ring-green-500' : 'ring-transparent'} bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40">
<p class="text-2xl font-bold text-green-700 dark:text-green-400">${deployed}</p>
<p class="text-xs text-green-600 dark:text-green-500">Deployed</p>
</button>
<button onclick="summarySetFilter('benched')"
class="${cardBase} ${active === 'benched' ? 'ring-blue-500' : 'ring-transparent'} bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40">
<p class="text-2xl font-bold text-blue-700 dark:text-blue-400">${benched}</p>
<p class="text-xs text-blue-600 dark:text-blue-500">Benched</p>
</button>
<button onclick="summarySetFilter('cal_expired')"
class="${cardBase} ${active === 'cal_expired' ? 'ring-red-500' : 'ring-transparent'} bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
<p class="text-2xl font-bold text-red-700 dark:text-red-400">${calExpired}</p>
<p class="text-xs text-red-600 dark:text-red-500">Cal Expired</p>
</button>
`;
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
${card(null, 'Total', counts.total, 'bg-gray-50 dark:bg-slate-700 text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-slate-600', 'ring-gray-400 dark:ring-gray-300')}
${card('deployed', 'Deployed', counts.deployed, 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/40', 'ring-green-500')}
${card('benched', 'Benched', counts.benched, 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/40', 'ring-blue-500')}
${card('cal_good', 'Cal Good', counts.cal_good, 'bg-teal-50 dark:bg-teal-900/20 text-teal-700 dark:text-teal-400 hover:bg-teal-100 dark:hover:bg-teal-900/40', 'ring-teal-500')}
</div>
<div class="flex flex-wrap gap-1.5 mt-2">
${summaryPill('cal_expired', 'Cal Expired', counts.cal_expired, af)}
${summaryPill('out_for_cal', 'Out for Cal', counts.out_for_cal, af)}
${summaryPill('allocated', 'Allocated', counts.allocated, af)}
${summaryPill('reserved', 'Reserved', counts.reserved, af)}
</div>`;
// Apply filter + search to the list
let units = summaryAllUnits;
if (active === 'deployed') units = units.filter(u => u.deployed);
else if (active === 'benched') units = units.filter(u => !u.deployed);
else if (active === 'cal_expired') units = units.filter(u => u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date());
// Apply all active filters (AND logic) + search
const filterFns = {
deployed: u => u.deployed,
benched: u => !u.deployed,
cal_expired: u => u._calExpired,
cal_good: u => u._calGood,
out_for_cal: u => u._outForCal,
allocated: u => u._allocated,
reserved: u => u._reserved,
};
let units = af.size === 0 ? withFlags : withFlags.filter(u => [...af].some(f => filterFns[f](u)));
if (search) units = units.filter(u => u.id.toLowerCase().includes(search));
// Unit list
const list = document.getElementById('fleet-summary-list');
if (units.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No units found</p>';
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No units match</p>';
return;
}
list.innerHTML = units.map(u => {
const calDate = u.last_calibrated
? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
: 'No cal date';
const expired = u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date();
: null;
const expiryDate = u.expiry_date
? new Date(u.expiry_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
: null;
const deployedBadge = u.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>';
const calBadge = expired
? `<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired</span>`
: `<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate}</span>`;
const outForCalBadge = u._outForCal
? '<span class="text-xs px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">Out for Cal</span>'
: '';
const allocatedBadge = u._allocated
? `<span class="text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded" title="${u.allocated_to_project_id ? 'For: ' + u.allocated_to_project_id : ''}">Allocated${u.allocated_to_project_id ? ': ' + u.allocated_to_project_id : ''}</span>`
: '';
let calBadge;
if (!calDate) {
calBadge = '<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired</span>';
} else if (u._calExpired) {
calBadge = `<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired ${expiryDate}</span>`;
} else {
calBadge = `<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate} · exp. ${expiryDate}</span>`;
}
const resBadges = (u.reservations || []).map(r => {
const s = r.start_date ? new Date(r.start_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : '';
const e = r.end_date ? new Date(r.end_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : 'TBD';
return `<span class="text-xs px-1.5 py-0.5 rounded font-medium" style="background-color:${r.color}22; color:${r.color}; border:1px solid ${r.color}66;"><span class="opacity-60">Reserved:</span> ${r.reservation_name} ${s}${e}</span>`;
return `<span class="text-xs px-1.5 py-0.5 rounded font-medium" style="background-color:${r.color}22; color:${r.color}; border:1px solid ${r.color}66;">${r.reservation_name} ${s}${e}</span>`;
}).join('');
return `
<div class="flex flex-col gap-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center gap-2 flex-wrap">
<button onclick="openUnitDetailModal('${u.id}')"
<button onclick="openUnitQuickModal('${u.id}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${u.id}</button>
${deployedBadge}
${calBadge}
${deployedBadge}${outForCalBadge}${allocatedBadge}${calBadge}
</div>
${resBadges ? `<div class="flex flex-wrap gap-1">${resBadges}</div>` : ''}
</div>`;
}).join('');
}
function summaryPill(f, label, count, activeSet) {
const isActive = activeSet.has(f);
const pillColors = {
cal_expired: isActive ? 'bg-red-600 text-white border-red-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-red-500 hover:text-red-600 dark:hover:text-red-400',
out_for_cal: isActive ? 'bg-purple-600 text-white border-purple-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-500 hover:text-purple-600 dark:hover:text-purple-400',
allocated: isActive ? 'bg-orange-500 text-white border-orange-500' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-orange-500 hover:text-orange-600 dark:hover:text-orange-400',
reserved: isActive ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400',
};
return `<button onclick="summaryToggleFilter('${f}')"
class="text-xs px-2.5 py-1 rounded-full font-medium border transition-colors ${pillColors[f]}">
${label} <span class="${isActive ? 'opacity-80' : 'opacity-60'}">${count}</span>
</button>`;
}
function showRightPanel(panel) {
document.getElementById('right-fleet-summary').classList.toggle('hidden', panel !== 'summary');
document.getElementById('right-available-units').classList.toggle('hidden', panel !== 'available');
@@ -1838,12 +2092,23 @@ function plannerSyncSlotsToEstimate() {
plannerRenderSlots();
}
function plannerSelectSlot(idx) {
plannerSelectedSlotIdx = (plannerSelectedSlotIdx === idx) ? null : idx;
plannerRenderSlots();
plannerRenderUnits();
}
function plannerAssignUnit(unitId) {
const emptyIdx = plannerState.slots.findIndex(s => !s.unit_id);
if (emptyIdx >= 0) {
plannerState.slots[emptyIdx].unit_id = unitId;
if (plannerSelectedSlotIdx !== null && plannerSelectedSlotIdx < plannerState.slots.length && !plannerState.slots[plannerSelectedSlotIdx].unit_id) {
plannerState.slots[plannerSelectedSlotIdx].unit_id = unitId;
plannerSelectedSlotIdx = null;
} else {
plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null, location_name: null });
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, location_name: null });
}
}
plannerRenderSlots();
plannerRenderUnits();
@@ -1879,8 +2144,13 @@ function plannerRenderSlots() {
emptyMsg.classList.add('hidden');
plannerState.slots.forEach((slot, idx) => {
const isSelected = !slot.unit_id && plannerSelectedSlotIdx === 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.className = `planner-slot-row flex flex-col gap-1.5 px-3 py-2 rounded-lg border ${
isSelected
? 'border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/30 ring-2 ring-blue-400 dark:ring-blue-500'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700/50'
}`;
row.dataset.idx = idx;
row.draggable = !!slot.unit_id;
@@ -1904,11 +2174,10 @@ function plannerRenderSlots() {
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)
// Swap unit_id only — power_type stays with the location slot
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();
@@ -1926,15 +2195,45 @@ function plannerRenderSlots() {
? `<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>`;
// Build inline cal text for filled slots
let calInline = '';
if (slot.unit_id) {
const uData = plannerState.allUnits.find(u => u.id === slot.unit_id);
if (uData) {
const today = new Date(); today.setHours(0,0,0,0);
const expiry = uData.expiry_date ? new Date(uData.expiry_date + 'T00:00:00') : null;
const calExpired = !uData.last_calibrated || (expiry && expiry < today);
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
const jobStart = start ? new Date(start + 'T00:00:00') : null;
const jobEnd = end ? new Date(end + 'T00:00:00') : null;
const expiresInJob = expiry && jobStart && jobEnd && expiry >= jobStart && expiry <= jobEnd;
if (!uData.last_calibrated) {
calInline = `<span class="text-xs text-red-500 dark:text-red-400 font-medium">No cal</span>`;
} else if (calExpired) {
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
calInline = `<span class="text-xs text-red-500 dark:text-red-400 font-medium">Cal exp. ${expiryStr}</span>`;
} else if (expiresInJob) {
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
calInline = `<span class="text-xs text-amber-500 dark:text-amber-400 font-medium">Cal exp. ${expiryStr}</span>`;
} else {
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
calInline = `<span class="text-xs text-gray-400 dark:text-gray-500">Cal exp. ${expiryStr}</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>
? `<button onclick="openUnitQuickModal('${slot.unit_id}')" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">${slot.unit_id}</button>
${calInline ? `<span class="flex-1">${calInline}</span>` : '<span class="flex-1"></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>
: `<button onclick="plannerSelectSlot(${idx})" class="flex-1 text-left text-sm italic ${plannerSelectedSlotIdx === idx ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-400 dark:text-gray-500'}">${plannerSelectedSlotIdx === idx ? '← click a unit to assign here' : 'Empty — click to select'}</button>
${powerSelect}
<button onclick="plannerRemoveSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove location">✕</button>`
}
@@ -1956,12 +2255,13 @@ function plannerRenderSlots() {
function plannerClearSlot(idx) {
plannerState.slots[idx].unit_id = null;
plannerState.slots[idx].power_type = null;
plannerSelectedSlotIdx = null;
plannerRenderSlots();
plannerRenderUnits();
}
function plannerReset() {
plannerSelectedSlotIdx = null;
plannerState = { reservation_id: null, slots: [], allUnits: [] };
document.getElementById('planner-name').value = '';
document.getElementById('planner-project').value = '';

View File

@@ -51,7 +51,7 @@
{% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
data-device-type="{{ unit.device_type }}"
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-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-id="{{ unit.id }}"
data-type="{{ unit.device_type }}"
@@ -62,6 +62,8 @@
<div class="flex items-center space-x-2">
{% if unit.out_for_calibration %}
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
{% elif unit.allocated %}
<span class="w-3 h-3 rounded-full bg-orange-400" title="Allocated"></span>
{% elif not unit.deployed %}
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
@@ -76,6 +78,8 @@
<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>
{% elif unit.allocated %}
<span class="w-2 h-2 rounded-full bg-orange-400" title="Allocated"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
{% endif %}
@@ -207,7 +211,7 @@
<div class="unit-card device-card"
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
data-device-type="{{ unit.device_type }}"
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-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-unit-id="{{ unit.id }}"
data-age="{{ unit.age }}">
@@ -216,6 +220,8 @@
<div class="flex items-center gap-2">
{% if unit.out_for_calibration %}
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
{% elif unit.allocated %}
<span class="w-4 h-4 rounded-full bg-orange-400" title="Allocated"></span>
{% elif not unit.deployed %}
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
@@ -231,12 +237,13 @@
</div>
<span class="px-3 py-1 rounded-full text-xs font-medium
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
{% elif unit.allocated %}bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-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 == '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
{% endif %}">
{% if unit.out_for_calibration %}Out for Cal{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
{% if unit.out_for_calibration %}Out for Cal{% elif unit.allocated %}Allocated{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
</span>
</div>

View File

@@ -5,6 +5,7 @@
{% set s = item.session %}
{% set loc = item.location %}
{% set unit = item.unit %}
{% set effective_range = item.effective_range %}
{# Period display maps #}
{% set period_labels = {
@@ -49,25 +50,74 @@
<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 %}
<!-- Period type badge (click to change) -->
<!-- Period type badge (click to open hour editor) -->
<div class="relative" id="period-wrap-{{ s.id }}">
<button onclick="togglePeriodMenu('{{ s.id }}')"
<button onclick="openPeriodEditor('{{ 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">
title="Click to edit period type and hours">
<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 %}
<!-- Period editor panel -->
<div id="period-editor-{{ 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 w-64 p-3 space-y-3">
<!-- Period type selector -->
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Period Type</label>
<div class="grid grid-cols-2 gap-1">
{% for pt, pt_label in [('weekday_day','WD Day'),('weekday_night','WD Night'),('weekend_day','WE Day'),('weekend_night','WE Night')] %}
<button onclick="selectPeriodType('{{ s.id }}', '{{ pt }}')"
id="pt-btn-{{ s.id }}-{{ pt }}"
class="period-type-btn text-xs py-1 px-2 rounded border transition-colors
{% if s.period_type == pt %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
{{ pt_label }}
</button>
{% endfor %}
</div>
</div>
<!-- Hour inputs -->
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Hour (023)</label>
<input type="number" min="0" max="23"
id="period-start-hr-{{ s.id }}"
value="{{ s.period_start_hour if s.period_start_hour is not none else '' }}"
placeholder="e.g. 19"
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Hour (023)</label>
<input type="number" min="0" max="23"
id="period-end-hr-{{ s.id }}"
value="{{ s.period_end_hour if s.period_end_hour is not none else '' }}"
placeholder="e.g. 7"
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
</div>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">Day: 7→19 · Night: 19→7 · Customize as needed</p>
<!-- Actions -->
<div class="flex gap-2 pt-1">
<button onclick="savePeriodEditor('{{ s.id }}')"
class="flex-1 text-xs py-1 bg-seismo-orange text-white rounded hover:bg-orange-600 transition-colors">
Save
</button>
<button onclick="closePeriodEditor('{{ s.id }}')"
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
Cancel
</button>
<button onclick="clearPeriodEditor('{{ s.id }}')"
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-500 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors"
title="Clear period type and hours">
Clear
</button>
</div>
</div>
</div>
</div>
@@ -131,8 +181,17 @@
{% endif %}
</div>
{% if s.notes %}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
<!-- Effective window (when period hours are set) -->
{% if effective_range %}
<div class="flex items-center gap-1 mt-1.5 text-xs text-indigo-600 dark:text-indigo-400">
<svg class="w-3.5 h-3.5 shrink-0" 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 id="effective-range-{{ s.id }}">Effective: {{ effective_range }}</span>
</div>
{% else %}
<div class="hidden text-xs text-indigo-600 dark:text-indigo-400 mt-1.5"
id="effective-range-{{ s.id }}"></div>
{% endif %}
</div>
@@ -163,6 +222,8 @@
{% endif %}
<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',
@@ -175,46 +236,145 @@ const PERIOD_LABELS = {
weekend_day: 'Weekend Day',
weekend_night: 'Weekend Night',
};
// Default hours for each period type
const PERIOD_DEFAULT_HOURS = {
weekday_day: {start: 7, end: 19},
weekday_night: {start: 19, end: 7},
weekend_day: {start: 7, end: 19},
weekend_night: {start: 19, end: 7},
};
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');
// Track which period type is selected in the editor before saving
const _editorState = {};
// ---- Period editor ----
function openPeriodEditor(sessionId) {
// Close all other editors first
document.querySelectorAll('[id^="period-editor-"]').forEach(el => {
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
});
menu.classList.toggle('hidden');
document.getElementById('period-editor-' + sessionId).classList.toggle('hidden');
}
function closePeriodEditor(sessionId) {
document.getElementById('period-editor-' + sessionId).classList.add('hidden');
delete _editorState[sessionId];
}
function selectPeriodType(sessionId, pt) {
_editorState[sessionId] = pt;
// Highlight selected button
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
const isSelected = btn.id === `pt-btn-${sessionId}-${pt}`;
btn.classList.toggle('border-seismo-orange', isSelected);
btn.classList.toggle('bg-orange-50', isSelected);
btn.classList.toggle('text-seismo-orange', isSelected);
btn.classList.toggle('dark:bg-orange-900/20', isSelected);
btn.classList.toggle('border-gray-200', !isSelected);
btn.classList.toggle('dark:border-gray-600', !isSelected);
btn.classList.toggle('text-gray-600', !isSelected);
btn.classList.toggle('dark:text-gray-400', !isSelected);
});
// Fill default hours
const defaults = PERIOD_DEFAULT_HOURS[pt];
if (defaults) {
const sh = document.getElementById('period-start-hr-' + sessionId);
const eh = document.getElementById('period-end-hr-' + sessionId);
if (sh && !sh.value) sh.value = defaults.start;
if (eh && !eh.value) eh.value = defaults.end;
}
}
async function savePeriodEditor(sessionId) {
const pt = _editorState[sessionId] || document.getElementById('period-badge-' + sessionId)
?.dataset?.currentPeriod || null;
const shInput = document.getElementById('period-start-hr-' + sessionId);
const ehInput = document.getElementById('period-end-hr-' + sessionId);
const payload = {};
if (pt !== undefined) payload.period_type = pt || null;
payload.period_start_hour = shInput?.value !== '' ? parseInt(shInput.value, 10) : null;
payload.period_end_hour = ehInput?.value !== '' ? parseInt(ehInput.value, 10) : null;
try {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await resp.text());
const result = await resp.json();
// Update badge
const badge = document.getElementById('period-badge-' + sessionId);
const label = document.getElementById('period-label-' + sessionId);
const newPt = result.period_type;
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
if (newPt && PERIOD_COLORS[newPt]) {
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
if (label) label.textContent = PERIOD_LABELS[newPt];
} else {
badge.classList.add(...FALLBACK_COLORS);
if (label) label.textContent = 'Set period';
}
// Update effective range display
_updateEffectiveRange(sessionId, result.period_start_hour, result.period_end_hour);
closePeriodEditor(sessionId);
} catch (err) {
alert('Failed to save period: ' + err.message);
}
}
async function clearPeriodEditor(sessionId) {
const shInput = document.getElementById('period-start-hr-' + sessionId);
const ehInput = document.getElementById('period-end-hr-' + sessionId);
if (shInput) shInput.value = '';
if (ehInput) ehInput.value = '';
_editorState[sessionId] = null;
// Reset period type button highlights
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
btn.classList.remove('border-seismo-orange','bg-orange-50','text-seismo-orange','dark:bg-orange-900/20');
btn.classList.add('border-gray-200','dark:border-gray-600','text-gray-600','dark:text-gray-400');
});
}
// ---- Effective range helper ----
function _updateEffectiveRange(sessionId, startHour, endHour) {
const el = document.getElementById('effective-range-' + sessionId);
if (!el) return;
if (startHour == null || endHour == null) {
el.textContent = '';
el.classList.add('hidden');
return;
}
function _fmt(h) {
const ampm = h < 12 ? 'AM' : 'PM';
const h12 = h % 12 || 12;
return `${h12}:00 ${ampm}`;
}
// We don't have the session start date in JS so just show the hours pattern
el.textContent = `Effective window: ${_fmt(startHour)}${_fmt(endHour)}`;
el.classList.remove('hidden');
}
// ---- Close editors on outside click ----
document.addEventListener('click', function(e) {
if (!e.target.closest('[id^="period-wrap-"]')) {
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden'));
document.querySelectorAll('[id^="period-editor-"]').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;
}
}
// ---- Label editing ----
function startEditLabel(sessionId) {
document.getElementById('label-display-' + sessionId).classList.add('hidden');
@@ -234,7 +394,7 @@ async function saveLabel(sessionId) {
const input = document.getElementById('label-input-' + sessionId);
const newLabel = input.value.trim();
try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_label: newLabel}),
@@ -249,8 +409,10 @@ async function saveLabel(sessionId) {
}
}
// ---- Session details ----
function viewSession(sessionId) {
alert('Session details coming soon: ' + sessionId);
window.location.href = `/api/projects/${PROJECT_ID}/sessions/${sessionId}/detail`;
}
function stopRecording(sessionId) {

View File

@@ -0,0 +1,124 @@
<!-- Monthly Sessions Calendar — Gantt Style -->
<div class="sessions-cal-wrap bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Month navigation -->
<div class="px-5 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ prev_month }}&year={{ prev_year }}"
hx-target="#sessions-calendar"
hx-swap="innerHTML"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
title="Previous month">
<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 19l-7-7 7-7"></path>
</svg>
</button>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ month_name }} {{ year }}</h3>
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ next_month }}&year={{ next_year }}"
hx-target="#sessions-calendar"
hx-swap="innerHTML"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
title="Next month">
<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 5l7 7-7 7"></path>
</svg>
</button>
</div>
<!-- Legend + key -->
<div class="px-5 py-2 border-b border-gray-100 dark:border-gray-700 flex flex-wrap items-center gap-x-5 gap-y-1.5">
{% if legend %}
<div class="flex flex-wrap gap-x-4 gap-y-1">
{% for loc in legend %}
<div class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background-color: {{ loc.color }}"></span>
<span>{{ loc.name }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Bar key -->
<div class="flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500 ml-auto shrink-0">
<div class="flex items-center gap-1.5">
<div class="w-8 h-2 rounded-sm" style="background:rgba(100,100,100,0.25)"></div>
<span>Device on</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-8 h-2 rounded-sm bg-blue-500"></div>
<span>Effective window</span>
</div>
</div>
</div>
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
{% for day_name in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %}
<div class="py-2 text-center text-xs font-medium uppercase tracking-wide
{% if loop.index == 1 or loop.index == 7 %}text-amber-500 dark:text-amber-400{% else %}text-gray-400 dark:text-gray-500{% endif %}">
{{ day_name }}
</div>
{% endfor %}
</div>
<!-- Calendar grid -->
{% for week in weeks %}
<div class="grid grid-cols-7 {% if not loop.last %}border-b border-gray-100 dark:border-gray-700{% endif %}">
{% for day in week %}
<div class="min-h-[80px] p-1.5
{% if not loop.last %}border-r border-gray-100 dark:border-gray-700{% endif %}
{% if not day.in_month %}bg-gray-50 dark:bg-slate-800/50{% else %}bg-white dark:bg-slate-800{% endif %}
{% if day.is_today %}ring-1 ring-inset ring-seismo-orange{% endif %}">
<!-- Date number -->
<div class="text-right mb-1.5">
<span class="text-xs {% if day.is_today %}font-bold text-seismo-orange{% elif day.in_month %}text-gray-700 dark:text-gray-300{% else %}text-gray-300 dark:text-gray-600{% endif %}">
{{ day.date.day }}
</span>
</div>
<!-- Gantt bars -->
{% if day.sessions %}
<div class="space-y-2">
{% for s in day.sessions %}
<a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail" class="block">
<!-- 24-hour timeline bar -->
<div class="relative overflow-hidden -mx-1.5" style="height:11px; background:rgba(128,128,128,0.08);">
<div class="absolute top-0 bottom-0 w-px" style="left:25%; background:rgba(128,128,128,0.18)"></div>
<div class="absolute top-0 bottom-0 w-px" style="left:50%; background:rgba(128,128,128,0.28)"></div>
<div class="absolute top-0 bottom-0 w-px" style="left:75%; background:rgba(128,128,128,0.18)"></div>
<div class="absolute top-0 bottom-0"
style="left:{{ s.dev_start_pct }}%; width:{{ s.dev_width_pct }}%; background-color:{{ s.color }}; opacity:0.28;"></div>
{% if s.eff_start_pct is not none %}
<div class="absolute"
style="left:{{ s.eff_start_pct }}%; width:{{ s.eff_width_pct }}%; top:1.5px; bottom:1.5px; background-color:{{ s.color }};"></div>
{% endif %}
</div>
<!-- Label -->
<div class="truncate mt-0.5" style="color:{{ s.color }}; font-size:0.58rem; line-height:1.3;">
{{ s.location_name }} · {{ day.date.strftime('%-m/%-d') }} · {% if s.period_type %}{{ 'Night' if 'night' in s.period_type else 'Day' }}{% else %}—{% endif %}
</div>
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
<!-- Time scale footer hint -->
<div class="px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 flex justify-between text-gray-300 dark:text-gray-600" style="font-size:0.6rem;">
<span>12 AM</span>
<span>6 AM</span>
<span>12 PM</span>
<span>6 PM</span>
<span>12 AM</span>
</div>
</div>

View File

@@ -56,6 +56,15 @@
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ session.status or 'unknown' }}
</span>
<!-- Edit Session Times -->
<button onclick="event.stopPropagation(); openEditSessionModal('{{ session.id }}', '{{ session.started_at|local_datetime if session.started_at else '' }}', '{{ session.stopped_at|local_datetime if session.stopped_at else '' }}')"
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Edit session times">
<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"></path>
</svg>
Edit
</button>
<!-- Download All Files in Session -->
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
@@ -326,4 +335,74 @@ async function deleteSession(sessionId) {
alert(`Error deleting session: ${error.message}`);
}
}
function openEditSessionModal(sessionId, startedAt, stoppedAt) {
document.getElementById('editSessionId').value = sessionId;
// local_datetime filter returns "YYYY-MM-DD HH:MM" — convert to "YYYY-MM-DDTHH:MM" for datetime-local input
document.getElementById('editStartedAt').value = startedAt ? startedAt.replace(' ', 'T') : '';
document.getElementById('editStoppedAt').value = stoppedAt ? stoppedAt.replace(' ', 'T') : '';
document.getElementById('editSessionModal').classList.remove('hidden');
}
function closeEditSessionModal() {
document.getElementById('editSessionModal').classList.add('hidden');
}
async function saveSessionTimes() {
const sessionId = document.getElementById('editSessionId').value;
const startedAt = document.getElementById('editStartedAt').value;
const stoppedAt = document.getElementById('editStoppedAt').value;
try {
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
started_at: startedAt || null,
stopped_at: stoppedAt || null,
})
});
if (response.ok) {
closeEditSessionModal();
window.location.reload();
} else {
const data = await response.json();
alert(`Failed to update session: ${data.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error updating session: ${error.message}`);
}
}
</script>
<!-- Edit Session Times Modal -->
<div id="editSessionModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Session Times</h3>
<input type="hidden" id="editSessionId">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
<input type="datetime-local" id="editStartedAt"
class="w-full 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 focus:outline-none 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">Stop Time</label>
<input type="datetime-local" id="editStoppedAt"
class="w-full 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 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">Times are in your local timezone. The session label and period type will be updated automatically.</p>
<div class="flex justify-end gap-3 mt-5">
<button onclick="closeEditSessionModal()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Cancel
</button>
<button onclick="saveSessionTimes()"
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
Save
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<tr id="seismo-row-{{ unit.id }}" class="bg-blue-50 dark:bg-slate-600 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.id }}
</a>
</td>
<td class="px-4 py-2 whitespace-nowrap">
<select name="status"
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="deployed" {% if unit.deployed %}selected{% endif %}>Deployed</option>
<option value="out_for_calibration" {% if unit.out_for_calibration %}selected{% endif %}>Out for Cal</option>
<option value="benched" {% if not unit.deployed and not unit.out_for_calibration %}selected{% endif %}>Benched</option>
</select>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{% if unit.deployed_with_modem_id %}
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.deployed_with_modem_id }}
</a>
{% else %}
<span class="text-gray-400 dark:text-gray-600">None</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{% if unit.address %}
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
{% elif unit.coordinates %}
<span>{{ unit.coordinates }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-2 whitespace-nowrap">
<input type="date"
name="last_calibrated"
value="{{ unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '' }}"
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
</td>
<td class="px-4 py-2">
<input type="text"
name="note"
value="{{ unit.note or '' }}"
placeholder="Add a note..."
class="w-full text-sm rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
</td>
<td class="px-4 py-2 whitespace-nowrap text-right text-sm">
<div class="flex items-center justify-end gap-2">
<button hx-post="/api/seismo-dashboard/unit/{{ unit.id }}/quick-update"
hx-include="closest tr"
hx-target="#seismo-row-{{ unit.id }}"
hx-swap="outerHTML"
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors">
Save
</button>
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/view-row"
hx-target="#seismo-row-{{ unit.id }}"
hx-swap="outerHTML"
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Cancel
</button>
</div>
</td>
</tr>

View File

@@ -0,0 +1,93 @@
<tr id="seismo-row-{{ unit.id }}" class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.id }}
</a>
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if unit.deployed %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Deployed
</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 %}
<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">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
</svg>
Benched
</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.deployed_with_modem_id %}
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.deployed_with_modem_id }}
</a>
{% else %}
<span class="text-gray-400 dark:text-gray-600">None</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
{% if unit.address %}
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
{% elif unit.coordinates %}
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.last_calibrated %}
<span class="inline-flex items-center gap-1.5">
{% if unit.next_calibration_due and today %}
{% set days_until = (unit.next_calibration_due - today).days %}
{% if days_until < 0 %}
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
{% elif days_until <= 14 %}
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
{% endif %}
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
{% endif %}
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
{% if unit.note %}
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<div class="flex items-center justify-end gap-3">
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/edit-row"
hx-target="#seismo-row-{{ unit.id }}"
hx-swap="outerHTML"
class="text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
title="Edit row">
<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.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
</button>
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
View Details →
</a>
</div>
</td>
</tr>

View File

@@ -92,88 +92,7 @@
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.id }}
</a>
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if unit.deployed %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Deployed
</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 %}
<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">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
</svg>
Benched
</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.deployed_with_modem_id %}
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.deployed_with_modem_id }}
</a>
{% else %}
<span class="text-gray-400 dark:text-gray-600">None</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
{% if unit.address %}
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
{% elif unit.coordinates %}
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.last_calibrated %}
<span class="inline-flex items-center gap-1.5">
{% if unit.next_calibration_due and today %}
{% set days_until = (unit.next_calibration_due - today).days %}
{% if days_until < 0 %}
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
{% elif days_until <= 14 %}
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
{% endif %}
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
{% endif %}
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
{% if unit.note %}
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
View Details →
</a>
</td>
</tr>
{% include "partials/seismo_row_view.html" %}
{% endfor %}
</tbody>
</table>

View File

@@ -208,6 +208,19 @@
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
</div>
</div>
<!-- Monthly Calendar -->
<div class="mt-6 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">Calendar View</h3>
</div>
<div id="sessions-calendar"
hx-get="/api/projects/{{ project_id }}/sessions-calendar"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-6 text-gray-400 text-sm">Loading calendar…</div>
</div>
</div>
</div>
<!-- Data Files Tab -->
@@ -838,6 +851,7 @@ async function loadProjectDetails() {
// Update tab labels and visibility based on project type
const isSoundProject = projectTypeId === 'sound_monitoring';
const isVibrationProject = projectTypeId === 'vibration_monitoring';
if (isSoundProject) {
document.getElementById('locations-tab-label').textContent = 'NRLs';
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
@@ -848,9 +862,9 @@ async function loadProjectDetails() {
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);
// Schedules and Assigned Units: hidden for vibration; for sound, only show if remote
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
// FTP browser within Data Files tab
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
@@ -1828,5 +1842,6 @@ document.addEventListener('DOMContentLoaded', function() {
switchTab(hash);
}
});
</script>
{% endblock %}

View File

@@ -181,6 +181,27 @@ let chartInstance = null;
let allData = [];
let allHeaders = [];
// Session period window (null = no filtering)
const SESSION_PERIOD_START_HOUR = {{ period_start_hour if period_start_hour is not none else 'null' }};
const SESSION_PERIOD_END_HOUR = {{ period_end_hour if period_end_hour is not none else 'null' }};
/**
* Returns true if the given hour integer is within the session's period window.
* Always returns true when no period window is configured.
*/
function _isInPeriodWindow(hour) {
if (SESSION_PERIOD_START_HOUR === null || SESSION_PERIOD_END_HOUR === null) return true;
const sh = SESSION_PERIOD_START_HOUR;
const eh = SESSION_PERIOD_END_HOUR;
if (eh > sh) {
// Same-day window, e.g. 719
return hour >= sh && hour < eh;
} else {
// Crosses midnight, e.g. 197
return hour >= sh || hour < eh;
}
}
// Load data on page load
document.addEventListener('DOMContentLoaded', function() {
loadRndData();
@@ -387,19 +408,21 @@ function renderChart(data, fileType) {
});
}
function renderTable(headers, data) {
const headerRow = document.getElementById('table-header');
const tbody = document.getElementById('table-body');
function _rowHour(row) {
// Parse hour from "Start Time" field (format: "YYYY/MM/DD HH:MM:SS")
const t = row['Start Time'];
if (!t) return null;
const parts = t.split(' ');
if (parts.length < 2) return null;
return parseInt(parts[1].split(':')[0], 10);
}
// Render headers
headerRow.innerHTML = '<tr>' + headers.map(h =>
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
).join('') + '</tr>';
// Render rows (limit to first 500 for performance)
const displayData = data.slice(0, 500);
tbody.innerHTML = displayData.map(row =>
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
function _buildRow(headers, row) {
const hour = _rowHour(row);
const inWindow = hour === null || _isInPeriodWindow(hour);
const dimClass = inWindow ? '' : 'opacity-40';
const titleAttr = (!inWindow) ? ' title="Outside period window"' : '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 ${dimClass}"${titleAttr}>` +
headers.map(h => {
const val = row[h];
let displayVal = val;
@@ -410,12 +433,34 @@ function renderTable(headers, data) {
}
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
}).join('') +
'</tr>'
).join('');
'</tr>';
}
function renderTable(headers, data) {
const headerRow = document.getElementById('table-header');
const tbody = document.getElementById('table-body');
// Render headers — add period window indicator if configured
let periodNote = '';
if (SESSION_PERIOD_START_HOUR !== null && SESSION_PERIOD_END_HOUR !== null) {
function _fmtH(h) { const ampm = h < 12 ? 'AM' : 'PM'; return `${h%12||12}:00 ${ampm}`; }
periodNote = ` <span class="ml-2 text-indigo-500 dark:text-indigo-400 font-normal normal-case text-xs" title="Dimmed rows are outside this window">Period: ${_fmtH(SESSION_PERIOD_START_HOUR)}${_fmtH(SESSION_PERIOD_END_HOUR)}</span>`;
}
headerRow.innerHTML = '<tr>' + headers.map((h, i) =>
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}${i === 0 ? periodNote : ''}</th>`
).join('') + '</tr>';
// Render rows (limit to first 500 for performance)
const displayData = data.slice(0, 500);
tbody.innerHTML = displayData.map(row => _buildRow(headers, row)).join('');
// Update row count
const inWindowCount = data.filter(r => { const h = _rowHour(r); return h === null || _isInPeriodWindow(h); }).length;
const windowNote = (SESSION_PERIOD_START_HOUR !== null && inWindowCount < data.length)
? ` (${inWindowCount} in period window)`
: '';
document.getElementById('row-count').textContent =
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows` : `${data.length.toLocaleString()} rows`;
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows${windowNote}` : `${data.length.toLocaleString()} rows${windowNote}`;
// Search functionality
document.getElementById('table-search').addEventListener('input', function(e) {
@@ -427,20 +472,7 @@ function renderTable(headers, data) {
);
const displayFiltered = filtered.slice(0, 500);
tbody.innerHTML = displayFiltered.map(row =>
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
headers.map(h => {
const val = row[h];
let displayVal = val;
if (val === null || val === undefined) {
displayVal = '-';
} else if (typeof val === 'number') {
displayVal = val.toFixed(1);
}
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
}).join('') +
'</tr>'
).join('');
tbody.innerHTML = displayFiltered.map(row => _buildRow(headers, row)).join('');
document.getElementById('row-count').textContent =
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;

View File

@@ -66,6 +66,7 @@
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
<button class="filter-btn filter-status" data-value="benched">Benched</button>
<button class="filter-btn filter-status" data-value="allocated">Allocated</button>
<button class="filter-btn filter-status" data-value="retired">Retired</button>
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
</div>
@@ -1352,7 +1353,7 @@
// Toggle health filter visibility (hide for retired/ignored)
const healthGroup = document.getElementById('health-filter-group');
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored') {
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored' || this.dataset.value === 'allocated') {
healthGroup.style.display = 'none';
} else {
healthGroup.style.display = 'flex';

View File

@@ -0,0 +1,437 @@
{% extends "base.html" %}
{% block title %}{{ session.session_label or 'Session' }} — {{ project.name }}{% endblock %}
{% block content %}
<div class="max-w-5xl mx-auto">
<!-- Breadcrumb -->
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
<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 5l7 7-7 7"></path>
</svg>
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name }}</a>
<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 5l7 7-7 7"></path>
</svg>
<span class="text-gray-900 dark:text-white truncate max-w-xs">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
</nav>
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<svg class="w-7 h-7 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<span id="header-label">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
</h1>
{% if location %}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ location.name }}{% if unit %} · {{ unit.id }}{% endif %}</p>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
{% if session.status == 'completed' %}
<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">Completed</span>
{% elif session.status == 'recording' %}
<span class="px-3 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 gap-1">
<span class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span> Recording
</span>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- LEFT COLUMN: Info + Edit -->
<div class="lg:col-span-1 space-y-4">
<!-- Session Info Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Session Info</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Label</dt>
<dd class="font-medium text-gray-900 dark:text-white text-right max-w-[180px] truncate"
id="info-label">{{ session.session_label or '—' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ location.name if location else '—' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Period</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-period">
{% set PLABELS = {'weekday_day':'Weekday Day','weekday_night':'Weekday Night','weekend_day':'Weekend Day','weekend_night':'Weekend Night'} %}
{{ PLABELS.get(session.period_type, '—') }}
</dd>
</div>
{% if effective_range %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective">{{ effective_range }}</dd>
</div>
{% else %}
<div class="flex justify-between hidden" id="info-effective-row">
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective"></dd>
</div>
{% endif %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Report Date</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-report-date">
{{ report_date or '— (auto)' }}
</dd>
</div>
{% if session.started_at %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.started_at|local_datetime }}</dd>
</div>
{% endif %}
{% if session.stopped_at %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Ended</dt>
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.stopped_at|local_datetime }}</dd>
</div>
{% endif %}
{% if session.duration_seconds %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Duration</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session.duration_seconds // 3600 }}h {{ (session.duration_seconds % 3600) // 60 }}m</dd>
</div>
{% endif %}
{% if session.device_model %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Device Model</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session.device_model }}</dd>
</div>
{% endif %}
{% if session_meta.get('store_name') %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Store Name</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.store_name }}</dd>
</div>
{% endif %}
{% if session_meta.get('serial_number') %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Serial #</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.serial_number }}</dd>
</div>
{% endif %}
</dl>
</div>
<!-- Edit Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Edit Session</h2>
<form id="edit-form" onsubmit="saveSession(event)">
<!-- Label -->
<div class="mb-4">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Label</label>
<input type="text" id="edit-label" name="session_label"
value="{{ session.session_label or '' }}"
placeholder="e.g. NRL-1 — Mon 3/24 — Night"
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
<!-- Section: Required Recording Window -->
<div class="mb-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
<p class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 mb-0.5">Required Recording Window</p>
<p class="text-xs text-indigo-500 dark:text-indigo-400 mb-3">The hours that count for reports. Only data within this window is included.</p>
<div class="space-y-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Period Type</label>
<select id="edit-period-type" name="period_type"
onchange="fillPeriodDefaults()"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
<option value="">— Not Set —</option>
<option value="weekday_day" {% if session.period_type == 'weekday_day' %}selected{% endif %}>Weekday Day</option>
<option value="weekday_night" {% if session.period_type == 'weekday_night' %}selected{% endif %}>Weekday Night</option>
<option value="weekend_day" {% if session.period_type == 'weekend_day' %}selected{% endif %}>Weekend Day</option>
<option value="weekend_night" {% if session.period_type == 'weekend_night' %}selected{% endif %}>Weekend Night</option>
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From (hour)</label>
<div class="relative">
<input type="number" min="0" max="23" id="edit-start-hour" name="period_start_hour"
value="{{ session.period_start_hour if session.period_start_hour is not none else '' }}"
placeholder="e.g. 19"
oninput="updateWindowPreview()"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To (hour)</label>
<input type="number" min="0" max="23" id="edit-end-hour" name="period_end_hour"
value="{{ session.period_end_hour if session.period_end_hour is not none else '' }}"
placeholder="e.g. 7"
oninput="updateWindowPreview()"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
</div>
<!-- Live preview -->
<div id="window-preview" class="text-xs font-medium text-indigo-600 dark:text-indigo-300 min-h-[1rem]">
{% if session.period_start_hour is not none and session.period_end_hour is not none %}
{% set sh = session.period_start_hour %}
{% set eh = session.period_end_hour %}
Window: {{ (sh % 12) or 12 }}:00 {{ 'AM' if sh < 12 else 'PM' }} {{ (eh % 12) or 12 }}:00 {{ 'AM' if eh < 12 else 'PM' }}{% if eh <= sh %} (crosses midnight){% endif %}
{% endif %}
</div>
<div class="mt-2">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Target Date <span class="text-gray-400">(optional — day sessions only)</span>
</label>
<input type="date" id="edit-report-date" name="report_date"
value="{{ report_date or '' }}"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Leave blank to auto-select the last day with data in the window.</p>
</div>
</div>
</div>
<!-- Section: Device On/Off Times -->
<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg border border-gray-200 dark:border-gray-600">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-0.5">Device On/Off Times</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">When the meter was actually running. Usually set automatically from the data file.</p>
<div class="space-y-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered on</label>
<input type="datetime-local" id="edit-started-at" name="started_at"
value="{{ session.started_at|local_datetime_input if session.started_at else '' }}"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered off</label>
<input type="datetime-local" id="edit-stopped-at" name="stopped_at"
value="{{ session.stopped_at|local_datetime_input if session.stopped_at else '' }}"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
</div>
</div>
<div class="flex gap-2">
<button type="submit"
class="flex-1 text-sm py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
Save Changes
</button>
</div>
<div id="save-status" class="hidden text-xs text-center pt-2"></div>
</form>
</div>
</div>
<!-- RIGHT COLUMN: Files + Report Actions -->
<div class="lg:col-span-2 space-y-5">
<!-- Files List -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Data Files
<span class="ml-2 text-xs font-normal text-gray-400">({{ files|length }})</span>
</h2>
</div>
{% if files %}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{% for f in files %}
{% set fname = f.file_path.split('/')[-1] %}
{% set is_rnd = fname.lower().endswith('.rnd') %}
{% set is_leq = '_leq_' in fname.lower() or fname.lower().startswith('au2_') %}
<div class="flex items-center gap-3 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<!-- Icon -->
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center
{% if is_rnd %}bg-green-100 dark:bg-green-900/30{% else %}bg-gray-100 dark:bg-gray-700{% endif %}">
{% if is_rnd %}
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
{% else %}
<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="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>
{% endif %}
</div>
<!-- Name + meta -->
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ fname }}</div>
<div class="text-xs text-gray-400 flex items-center gap-2 mt-0.5">
<span>{{ f.file_type | upper }}</span>
{% if f.file_size_bytes %}
<span>{{ (f.file_size_bytes / 1024) | round(1) }} KB</span>
{% endif %}
{% if is_leq %}<span class="text-green-600 dark:text-green-400 font-medium">Leq</span>{% endif %}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
{% if is_rnd %}
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/view-rnd"
class="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors">
View
</a>
{% if is_leq %}
<button onclick="openSingleFileReport('{{ f.id }}', '{{ fname }}')"
class="px-2 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 transition-colors">
Report
</button>
{% endif %}
{% endif %}
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/download"
class="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
Download
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="px-5 py-10 text-center text-gray-400">
<p>No files found for this session.</p>
</div>
{% endif %}
</div>
<!-- Report Actions -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Report Actions</h2>
{% if session.status == 'completed' %}
{% set has_rnd = files | selectattr('file_type', 'equalto', 'rnd') | list | length > 0 %}
{% if has_rnd %}
<div class="p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg space-y-2">
<p class="text-xs text-gray-600 dark:text-gray-400">
Use the <strong>Combined Report Wizard</strong> to generate an Excel report for this session, or click <strong>View</strong> on a Leq file above to access per-file reporting.
{% if session.period_start_hour is not none %}
<br><span class="text-indigo-600 dark:text-indigo-400">Period window {{ session.period_start_hour }}:00{{ session.period_end_hour }}:00 will be applied.</span>
{% endif %}
</p>
<a href="/projects/{{ project_id }}?tab=data"
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
<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 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>
</svg>
Go to Combined Report Wizard
</a>
</div>
{% else %}
<p class="text-sm text-gray-400 dark:text-gray-500">No .rnd files found — upload data to generate a report.</p>
{% endif %}
{% else %}
<p class="text-sm text-gray-400 dark:text-gray-500">Reports are available after the session is completed.</p>
{% endif %}
</div>
</div>
</div>
</div>
<script>
const PROJECT_ID = '{{ project_id }}';
const SESSION_ID = '{{ session.id }}';
const PERIOD_DEFAULT_HOURS = {
weekday_day: {start: 7, end: 19},
weekday_night: {start: 19, end: 7},
weekend_day: {start: 7, end: 19},
weekend_night: {start: 19, end: 7},
};
function fillPeriodDefaults() {
const pt = document.getElementById('edit-period-type').value;
const defaults = PERIOD_DEFAULT_HOURS[pt];
if (defaults) {
document.getElementById('edit-start-hour').value = defaults.start;
document.getElementById('edit-end-hour').value = defaults.end;
}
updateWindowPreview();
}
function updateWindowPreview() {
const sh = parseInt(document.getElementById('edit-start-hour').value, 10);
const eh = parseInt(document.getElementById('edit-end-hour').value, 10);
const el = document.getElementById('window-preview');
if (!el) return;
if (isNaN(sh) || isNaN(eh)) { el.textContent = ''; return; }
function fmt(h) { return `${h % 12 || 12}:00 ${h < 12 ? 'AM' : 'PM'}`; }
const crosses = eh <= sh;
el.textContent = `Window: ${fmt(sh)}${fmt(eh)}${crosses ? ' (crosses midnight)' : ''}`;
}
// Run once on load to populate preview if values already set
document.addEventListener('DOMContentLoaded', updateWindowPreview);
async function saveSession(e) {
e.preventDefault();
const status = document.getElementById('save-status');
status.className = 'text-xs text-center pt-1 text-gray-400';
status.textContent = 'Saving…';
status.classList.remove('hidden');
const form = document.getElementById('edit-form');
const payload = {};
const label = form.session_label.value.trim();
payload.session_label = label || null;
const pt = form.period_type.value;
payload.period_type = pt || null;
const sh = form.period_start_hour.value;
const eh = form.period_end_hour.value;
payload.period_start_hour = sh !== '' ? parseInt(sh, 10) : null;
payload.period_end_hour = eh !== '' ? parseInt(eh, 10) : null;
const rd = form.report_date.value;
payload.report_date = rd || null;
const sa = form.started_at.value;
if (sa) payload.started_at = sa;
const st = form.stopped_at.value;
if (st) payload.stopped_at = st;
else if (form.stopped_at.value === '') payload.stopped_at = null;
try {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await resp.text());
const result = await resp.json();
// Update displayed label
const newLabel = result.session_label || ('Session ' + SESSION_ID.slice(0, 8) + '…');
document.getElementById('header-label').textContent = newLabel;
document.getElementById('info-label').textContent = result.session_label || '—';
document.getElementById('info-period').textContent = {
weekday_day: 'Weekday Day', weekday_night: 'Weekday Night',
weekend_day: 'Weekend Day', weekend_night: 'Weekend Night'
}[result.period_type] || '—';
document.getElementById('info-report-date').textContent = result.report_date || '— (auto)';
status.className = 'text-xs text-center pt-1 text-green-600 dark:text-green-400';
status.textContent = 'Saved!';
setTimeout(() => status.classList.add('hidden'), 2500);
} catch(err) {
status.className = 'text-xs text-center pt-1 text-red-500';
status.textContent = 'Error: ' + err.message;
}
}
function openSingleFileReport(fileId, filename) {
window.location.href = `/api/projects/${PROJECT_ID}/files/${fileId}/view-rnd`;
}
</script>
{% endblock %}

View File

@@ -278,6 +278,22 @@
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
</div>
<!-- Deployment History -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment History</h3>
<button onclick="openNewDeploymentModal()" class="px-3 py-1.5 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm 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="M12 4v16m8-8H4"></path>
</svg>
Log Deployment
</button>
</div>
<div id="deploymentHistory" class="space-y-3">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
</div>
</div>
<!-- Unit History Timeline -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
@@ -320,6 +336,53 @@
</div>
</div>
<!-- Deployment Modal -->
<div id="deploymentModal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg">
<div class="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
<h3 id="deploymentModalTitle" class="text-lg font-semibold text-gray-900 dark:text-white">Log Deployment</h3>
<button onclick="closeDeploymentModal()" 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"></path>
</svg>
</button>
</div>
<div class="p-6 space-y-4">
<input type="hidden" id="deploymentModalId">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Deployed Date</label>
<input type="date" id="deploymentDeployedDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Est. Removal Date</label>
<input type="date" id="deploymentEstRemovalDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
</div>
</div>
<div id="actualRemovalRow">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Actual Removal Date <span class="text-gray-400 font-normal">(fill when returned)</span></label>
<input type="date" id="deploymentActualRemovalDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Job / Project</label>
<input type="text" id="deploymentProjectRef" placeholder="e.g. Fay I-80, CMU Campus" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location Name</label>
<input type="text" id="deploymentLocationName" placeholder="e.g. North Gate, VP-001" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<textarea id="deploymentNotes" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange resize-none"></textarea>
</div>
</div>
<div class="flex justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
<button onclick="closeDeploymentModal()" class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg text-sm transition-colors">Cancel</button>
<button onclick="saveDeployment()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm transition-colors">Save</button>
</div>
</div>
</div>
<!-- Edit Mode: Unit Information Form (Hidden by default) -->
<div id="editMode" class="hidden rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex justify-between items-center mb-6">
@@ -499,7 +562,7 @@
<!-- Status Checkboxes -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
<div class="flex items-center gap-6">
<div class="flex items-center gap-6 flex-wrap">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="deployed" id="deployed" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
@@ -510,6 +573,18 @@
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="allocated" id="allocated" value="true"
onchange="document.getElementById('allocatedProjectRow').style.display = this.checked ? '' : 'none'"
class="w-4 h-4 text-orange-500 focus:ring-orange-400 rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Allocated</span>
</label>
</div>
<div id="allocatedProjectRow" style="display:none">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Allocated to Project</label>
<input type="text" name="allocated_to_project_id" id="allocatedToProjectId"
placeholder="Project name or ID"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-400 text-sm">
</div>
<!-- Hidden field for retired — controlled by the Retire button below -->
<input type="hidden" name="retired" id="retired" value="">
@@ -818,10 +893,14 @@ function populateViewMode() {
document.getElementById('age').textContent = unitStatus.age || '--';
} else {
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400';
// Show "Benched" if not deployed, otherwise "No status data"
document.getElementById('statusText').textContent = !currentUnit.deployed ? 'Benched' : 'No status data';
const isAllocated = currentUnit.allocated && !currentUnit.deployed;
document.getElementById('statusIndicator').className = isAllocated
? 'w-3 h-3 rounded-full bg-orange-400'
: 'w-3 h-3 rounded-full bg-gray-400';
document.getElementById('statusText').className = isAllocated
? 'font-semibold text-orange-500 dark:text-orange-400'
: 'font-semibold text-gray-600 dark:text-gray-400';
document.getElementById('statusText').textContent = isAllocated ? 'Allocated' : (!currentUnit.deployed ? 'Benched' : 'No status data');
document.getElementById('lastSeen').textContent = '--';
document.getElementById('age').textContent = '--';
}
@@ -833,6 +912,11 @@ function populateViewMode() {
} 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 if (currentUnit.allocated && !currentUnit.deployed) {
document.getElementById('retiredStatus').textContent = currentUnit.allocated_to_project_id
? `Allocated — ${currentUnit.allocated_to_project_id}`
: 'Allocated';
document.getElementById('retiredStatus').className = 'font-medium text-orange-500 dark:text-orange-400';
} else {
document.getElementById('retiredStatus').textContent = 'Active';
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
@@ -1032,6 +1116,10 @@ function populateEditForm() {
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
updateRetireButton(currentUnit.retired);
document.getElementById('note').value = currentUnit.note || '';
const allocatedChecked = currentUnit.allocated || false;
document.getElementById('allocated').checked = allocatedChecked;
document.getElementById('allocatedToProjectId').value = currentUnit.allocated_to_project_id || '';
document.getElementById('allocatedProjectRow').style.display = allocatedChecked ? '' : 'none';
// Seismograph fields
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
@@ -1631,12 +1719,173 @@ async function pingModem() {
btn.disabled = false;
}
// ============================================================
// Deployment History
// ============================================================
async function loadDeploymentHistory() {
try {
const res = await fetch(`/api/deployments/${unitId}`);
const data = await res.json();
const container = document.getElementById('deploymentHistory');
const deployments = data.deployments || [];
if (deployments.length === 0) {
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment records yet.</p>';
return;
}
container.innerHTML = '';
deployments.forEach(d => {
container.appendChild(createDeploymentRow(d));
});
} catch (e) {
document.getElementById('deploymentHistory').innerHTML =
'<p class="text-sm text-red-500">Failed to load deployment history.</p>';
}
}
function formatDateDisplay(iso) {
if (!iso) return '—';
const [y, m, d] = iso.split('-');
return `${parseInt(m)}/${parseInt(d)}/${y}`;
}
function createDeploymentRow(d) {
const div = document.createElement('div');
div.className = 'flex items-start gap-3 p-3 rounded-lg ' +
(d.is_active
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-gray-50 dark:bg-slate-700/50');
const statusDot = d.is_active
? '<span class="mt-1 flex-shrink-0 w-2.5 h-2.5 rounded-full bg-green-500"></span>'
: '<span class="mt-1 flex-shrink-0 w-2.5 h-2.5 rounded-full bg-gray-400 dark:bg-gray-500"></span>';
const jobLabel = d.project_ref || d.project_id || 'Unspecified job';
const locLabel = d.location_name ? `<span class="text-gray-500 dark:text-gray-400"> · ${d.location_name}</span>` : '';
const deployedStr = formatDateDisplay(d.deployed_date);
const estStr = d.estimated_removal_date ? formatDateDisplay(d.estimated_removal_date) : 'TBD';
const actualStr = d.actual_removal_date ? formatDateDisplay(d.actual_removal_date) : null;
const dateRange = actualStr
? `${deployedStr}${actualStr}`
: `${deployedStr} → <span class="font-medium ${d.is_active ? 'text-green-700 dark:text-green-400' : 'text-gray-600 dark:text-gray-300'}">Est. ${estStr}</span>`;
const activeTag = d.is_active
? '<span class="ml-2 px-1.5 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-400 rounded">In Field</span>'
: '';
div.innerHTML = `
${statusDot}
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">
${jobLabel}${activeTag}${locLabel}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${dateRange}</div>
${d.notes ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 italic">${d.notes}</div>` : ''}
</div>
<div class="flex gap-1 flex-shrink-0">
<button onclick="openEditDeploymentModal(${JSON.stringify(d).replace(/"/g, '&quot;')})"
class="p-1.5 text-gray-400 hover:text-seismo-orange rounded transition-colors" 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"></path>
</svg>
</button>
<button onclick="deleteDeployment('${d.id}')"
class="p-1.5 text-gray-400 hover:text-red-500 rounded transition-colors" 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"></path>
</svg>
</button>
</div>
`;
return div;
}
function openNewDeploymentModal() {
document.getElementById('deploymentModalTitle').textContent = 'Log Deployment';
document.getElementById('deploymentModalId').value = '';
document.getElementById('deploymentDeployedDate').value = '';
document.getElementById('deploymentEstRemovalDate').value = '';
document.getElementById('deploymentActualRemovalDate').value = '';
document.getElementById('deploymentProjectRef').value = '';
document.getElementById('deploymentLocationName').value = '';
document.getElementById('deploymentNotes').value = '';
document.getElementById('deploymentModal').classList.remove('hidden');
}
function openEditDeploymentModal(d) {
document.getElementById('deploymentModalTitle').textContent = 'Edit Deployment';
document.getElementById('deploymentModalId').value = d.id;
document.getElementById('deploymentDeployedDate').value = d.deployed_date || '';
document.getElementById('deploymentEstRemovalDate').value = d.estimated_removal_date || '';
document.getElementById('deploymentActualRemovalDate').value = d.actual_removal_date || '';
document.getElementById('deploymentProjectRef').value = d.project_ref || '';
document.getElementById('deploymentLocationName').value = d.location_name || '';
document.getElementById('deploymentNotes').value = d.notes || '';
document.getElementById('deploymentModal').classList.remove('hidden');
}
function closeDeploymentModal() {
document.getElementById('deploymentModal').classList.add('hidden');
}
async function saveDeployment() {
const id = document.getElementById('deploymentModalId').value;
const payload = {
deployed_date: document.getElementById('deploymentDeployedDate').value || null,
estimated_removal_date: document.getElementById('deploymentEstRemovalDate').value || null,
actual_removal_date: document.getElementById('deploymentActualRemovalDate').value || null,
project_ref: document.getElementById('deploymentProjectRef').value || null,
location_name: document.getElementById('deploymentLocationName').value || null,
notes: document.getElementById('deploymentNotes').value || null,
};
try {
let res;
if (id) {
res = await fetch(`/api/deployments/${unitId}/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
} else {
res = await fetch(`/api/deployments/${unitId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error(await res.text());
closeDeploymentModal();
loadDeploymentHistory();
} catch (e) {
alert('Failed to save deployment: ' + e.message);
}
}
async function deleteDeployment(deploymentId) {
if (!confirm('Delete this deployment record?')) return;
try {
const res = await fetch(`/api/deployments/${unitId}/${deploymentId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
loadDeploymentHistory();
} catch (e) {
alert('Failed to delete: ' + e.message);
}
}
// Load data when page loads
loadCalibrationInterval();
setupCalibrationAutoCalc();
loadUnitData().then(() => {
loadPhotos();
loadUnitHistory();
loadDeploymentHistory();
});
// ===== Pair Device Modal Functions =====

View File

@@ -37,7 +37,7 @@
{{ location.name }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Monitoring Location {{ project.name }}
Monitoring Location &bull; {{ project.name }}
</p>
</div>
<div class="flex gap-2">
@@ -116,20 +116,36 @@
<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">
<!-- Seismograph row -->
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Seismograph</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>
{% if assigned_unit.unit_type %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ assigned_unit.unit_type }}</div>
{% endif %}
</div>
{% if assigned_unit.device_type %}
<!-- Modem row -->
<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 class="text-sm text-gray-600 dark:text-gray-400">Modem</div>
{% if assigned_modem %}
<div class="text-lg font-medium text-gray-900 dark:text-white">
<a href="/unit/{{ assigned_modem.id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ assigned_modem.id }}
</a>
</div>
{% if assigned_modem.hardware_model or assigned_modem.ip_address %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ assigned_modem.hardware_model or '' }}{% if assigned_modem.hardware_model and assigned_modem.ip_address %} &bull; {% endif %}{{ assigned_modem.ip_address or '' }}
</div>
{% endif %}
{% else %}
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No modem paired</div>
{% endif %}
</div>
{% endif %}
{% if assignment %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
@@ -142,10 +158,14 @@
</div>
{% endif %}
{% endif %}
<div class="pt-2">
<div class="pt-2 flex gap-2 flex-wrap">
<button onclick="openSwapModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors text-sm">
Swap Unit / Modem
</button>
<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
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 text-sm">
Unassign
</button>
</div>
</div>
@@ -155,7 +175,7 @@
<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()"
<button onclick="openSwapModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Assign a Unit
</button>
@@ -214,47 +234,55 @@
</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">
<!-- Assign / Swap Modal -->
<div id="swap-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>
<h2 id="swap-modal-title" 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">Select a seismograph and optionally a modem for this location</p>
</div>
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<button onclick="closeSwapModal()" 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">
<form id="swap-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"
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Seismograph <span class="text-red-500">*</span></label>
<select id="swap-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>
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Modem <span class="text-xs text-gray-400">(optional)</span></label>
<select id="swap-modem-id" name="modem_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">
<option value="">No modem</option>
</select>
</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"
<textarea id="swap-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 id="swap-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeAssignModal()"
<button type="button" onclick="closeSwapModal()"
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"
<button type="submit" id="swap-submit-btn"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Assign Unit
Assign
</button>
</div>
</form>
@@ -264,6 +292,7 @@
<script>
const projectId = "{{ project_id }}";
const locationId = "{{ location_id }}";
const hasAssignment = {{ 'true' if assigned_unit else 'false' }};
// Tab switching
function switchTab(tabName) {
@@ -314,60 +343,89 @@ document.getElementById('location-settings-form').addEventListener('submit', asy
}
});
// Assign modal
function openAssignModal() {
document.getElementById('assign-modal').classList.remove('hidden');
loadAvailableUnits();
// Swap / Assign modal
async function openSwapModal() {
document.getElementById('swap-modal').classList.remove('hidden');
document.getElementById('swap-modal-title').textContent = hasAssignment ? 'Swap Unit / Modem' : 'Assign Unit';
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
document.getElementById('swap-error').classList.add('hidden');
document.getElementById('swap-notes').value = '';
await Promise.all([loadSwapUnits(), loadSwapModems()]);
}
function closeAssignModal() {
document.getElementById('assign-modal').classList.add('hidden');
function closeSwapModal() {
document.getElementById('swap-modal').classList.add('hidden');
}
async function loadAvailableUnits() {
async function loadSwapUnits() {
try {
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
if (!response.ok) throw new Error('Failed to load available units');
if (!response.ok) throw new Error('Failed to load units');
const data = await response.json();
const select = document.getElementById('assign-unit-id');
select.innerHTML = '<option value="">Select a unit</option>';
const select = document.getElementById('swap-unit-id');
select.innerHTML = '<option value="">Select a seismograph</option>';
if (!data.length) {
document.getElementById('assign-empty').classList.remove('hidden');
return;
document.getElementById('swap-units-empty').classList.remove('hidden');
} else {
document.getElementById('swap-units-empty').classList.add('hidden');
}
data.forEach(unit => {
const option = document.createElement('option');
option.value = unit.id;
option.textContent = `${unit.id}${unit.model || unit.device_type}`;
option.textContent = unit.id + (unit.model ? ` \u2022 ${unit.model}` : '') + (unit.location ? ` \u2014 ${unit.location}` : '');
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('swap-error').textContent = 'Failed to load seismographs.';
document.getElementById('swap-error').classList.remove('hidden');
}
}
document.getElementById('assign-form').addEventListener('submit', async function(e) {
async function loadSwapModems() {
try {
const response = await fetch(`/api/projects/${projectId}/available-modems`);
if (!response.ok) throw new Error('Failed to load modems');
const data = await response.json();
const select = document.getElementById('swap-modem-id');
select.innerHTML = '<option value="">No modem</option>';
data.forEach(modem => {
const option = document.createElement('option');
option.value = modem.id;
let label = modem.id;
if (modem.hardware_model) label += ` \u2022 ${modem.hardware_model}`;
if (modem.ip_address) label += ` \u2014 ${modem.ip_address}`;
option.textContent = label;
select.appendChild(option);
});
} catch (err) {
// Modem list failure is non-fatal — just leave blank
console.warn('Failed to load modems:', err);
}
}
document.getElementById('swap-form').addEventListener('submit', async function(e) {
e.preventDefault();
const unitId = document.getElementById('assign-unit-id').value;
const notes = document.getElementById('assign-notes').value.trim();
const unitId = document.getElementById('swap-unit-id').value;
const modemId = document.getElementById('swap-modem-id').value;
const notes = document.getElementById('swap-notes').value.trim();
if (!unitId) {
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
document.getElementById('assign-error').classList.remove('hidden');
document.getElementById('swap-error').textContent = 'Please select a seismograph.';
document.getElementById('swap-error').classList.remove('hidden');
return;
}
try {
const formData = new FormData();
formData.append('unit_id', unitId);
formData.append('notes', notes);
if (modemId) formData.append('modem_id', modemId);
if (notes) formData.append('notes', notes);
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/swap`, {
method: 'POST',
body: formData
});
@@ -379,9 +437,8 @@ document.getElementById('assign-form').addEventListener('submit', async function
window.location.reload();
} catch (err) {
const errorEl = document.getElementById('assign-error');
errorEl.textContent = err.message || 'Failed to assign unit.';
errorEl.classList.remove('hidden');
document.getElementById('swap-error').textContent = err.message || 'Failed to assign unit.';
document.getElementById('swap-error').classList.remove('hidden');
}
});
@@ -405,11 +462,11 @@ async function unassignUnit(assignmentId) {
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAssignModal();
if (e.key === 'Escape') closeSwapModal();
});
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeAssignModal();
document.getElementById('swap-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeSwapModal();
});
</script>
{% endblock %}