merge v0.9.2. #40

Merged
serversdown merged 5 commits from dev into main 2026-03-27 14:58:34 -04:00
20 changed files with 1438 additions and 167 deletions

View File

@@ -5,11 +5,31 @@ 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.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.2
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.2"
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
@@ -703,6 +707,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 +811,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

@@ -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
@@ -448,6 +450,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

@@ -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

@@ -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,13 +2092,24 @@ function plannerSyncSlotsToEstimate() {
plannerRenderSlots();
}
function plannerSelectSlot(idx) {
plannerSelectedSlotIdx = (plannerSelectedSlotIdx === idx) ? null : idx;
plannerRenderSlots();
plannerRenderUnits();
}
function plannerAssignUnit(unitId) {
if (plannerSelectedSlotIdx !== null && plannerSelectedSlotIdx < plannerState.slots.length && !plannerState.slots[plannerSelectedSlotIdx].unit_id) {
plannerState.slots[plannerSelectedSlotIdx].unit_id = unitId;
plannerSelectedSlotIdx = null;
} else {
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

@@ -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

@@ -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

@@ -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 =====