feat: adds deployment records for seismographs.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
79
backend/migrate_add_deployment_records.py
Normal file
79
backend/migrate_add_deployment_records.py
Normal 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()
|
||||||
@@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
154
backend/routers/deployments.py
Normal file
154
backend/routers/deployments.py
Normal 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}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, '"')})"
|
||||||
|
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 =====
|
||||||
|
|||||||
Reference in New Issue
Block a user