feat: adds deployment records for seismographs.

This commit is contained in:
2026-03-25 17:36:51 +00:00
parent 57a85f565b
commit 4f56dea4f3
7 changed files with 594 additions and 4 deletions

View File

@@ -126,6 +126,10 @@ app.include_router(recurring_schedules.router)
from backend.routers import fleet_calendar from backend.routers import fleet_calendar
app.include_router(fleet_calendar.router) 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 # Start scheduler service and device status monitor on application startup
from backend.services.scheduler import start_scheduler, stop_scheduler from backend.services.scheduler import start_scheduler, stop_scheduler
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor

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

@@ -448,6 +448,41 @@ class Alert(Base):
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time 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 # 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

@@ -9,7 +9,8 @@ import httpx
import os import os
from backend.database import get_db 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 from backend.services.slmm_sync import sync_slm_to_slmm
router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) 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") 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, 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): old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
"""Helper function to record a change in unit history""" """Helper function to record a change in unit history"""
@@ -679,6 +712,7 @@ async def edit_roster_unit(
status_text = "deployed" if deployed else "benched" status_text = "deployed" if deployed else "benched"
old_status_text = "deployed" if old_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") 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: if old_retired != retired:
status_text = "retired" if retired else "active" status_text = "retired" if retired else "active"
@@ -795,6 +829,7 @@ async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = D
new_value=status_text, new_value=status_text,
source="manual" source="manual"
) )
sync_deployment_record(db, unit, deployed)
db.commit() db.commit()

View File

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

View File

@@ -278,6 +278,22 @@
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p> <p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
</div> </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 --> <!-- Unit History Timeline -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6"> <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> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
@@ -320,6 +336,53 @@
</div> </div>
</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) --> <!-- 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 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"> <div class="flex justify-between items-center mb-6">
@@ -1631,12 +1694,173 @@ async function pingModem() {
btn.disabled = false; 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 // Load data when page loads
loadCalibrationInterval(); loadCalibrationInterval();
setupCalibrationAutoCalc(); setupCalibrationAutoCalc();
loadUnitData().then(() => { loadUnitData().then(() => {
loadPhotos(); loadPhotos();
loadUnitHistory(); loadUnitHistory();
loadDeploymentHistory();
}); });
// ===== Pair Device Modal Functions ===== // ===== Pair Device Modal Functions =====