diff --git a/backend/main.py b/backend/main.py index 5be71ee..86f1e0c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -707,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({ @@ -784,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) diff --git a/backend/migrate_add_allocated.py b/backend/migrate_add_allocated.py new file mode 100644 index 0000000..ac1900d --- /dev/null +++ b/backend/migrate_add_allocated.py @@ -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() diff --git a/backend/models.py b/backend/models.py index 7359f21..bb0ca10 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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 diff --git a/backend/routers/fleet_calendar.py b/backend/routers/fleet_calendar.py index eafcff9..0a100d0 100644 --- a/backend/routers/fleet_calendar.py +++ b/backend/routers/fleet_calendar.py @@ -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, diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 9972e50..e343b83 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -500,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 "", @@ -532,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), @@ -574,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 @@ -611,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 diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index e4340f2..da54f65 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -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), diff --git a/templates/dashboard.html b/templates/dashboard.html index 3ffa65b..d1ec900 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -57,6 +57,10 @@ Benched -- +
By Device Type: