diff --git a/CHANGELOG.md b/CHANGELOG.md index d559ead..afc34f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/README.md b/README.md index 64f10cb..93865ae 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/main.py b/backend/main.py index 5f5d7ea..4d9cc1e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) 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/migrate_add_deployment_records.py b/backend/migrate_add_deployment_records.py new file mode 100644 index 0000000..cc9069a --- /dev/null +++ b/backend/migrate_add_deployment_records.py @@ -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() diff --git a/backend/models.py b/backend/models.py index 30bec47..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 @@ -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 # ============================================================================ diff --git a/backend/routers/deployments.py b/backend/routers/deployments.py new file mode 100644 index 0000000..acdcd93 --- /dev/null +++ b/backend/routers/deployments.py @@ -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} 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 ea799ed..e343b83 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -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() diff --git a/backend/routers/seismo_dashboard.py b/backend/routers/seismo_dashboard.py index fde33b9..72cf7f9 100644 --- a/backend/routers/seismo_dashboard.py +++ b/backend/routers/seismo_dashboard.py @@ -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)) diff --git a/backend/services/fleet_calendar_service.py b/backend/services/fleet_calendar_service.py index 4331cf5..fecc160 100644 --- a/backend/services/fleet_calendar_service.py +++ b/backend/services/fleet_calendar_service.py @@ -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) 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 -- +
+ Allocated + -- +

By Device Type:

@@ -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; diff --git a/templates/fleet_calendar.html b/templates/fleet_calendar.html index 112b5b7..cb75bcf 100644 --- a/templates/fleet_calendar.html +++ b/templates/fleet_calendar.html @@ -650,7 +650,7 @@

Fleet Summary

-
+
+ + +
@@ -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 = `
- ${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 + ? 'Deployed' + : 'Benched'; + + // Out for cal badge + if (u.out_for_calibration) { + document.getElementById('uqm-outforcal-badge').innerHTML = + 'Out for Cal'; + } + + // 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 `
+ + ${r.name} + ${s}–${e}${loc} +
`; + }).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 ``; + }; - 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 = ` - - - - - `; +
+ ${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')} +
+
+ ${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)} +
`; - // 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 = '

No units found

'; + list.innerHTML = '

No units match

'; 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 ? 'Deployed' : 'Benched'; - const calBadge = expired - ? `Cal expired` - : `Cal: ${calDate}`; + const outForCalBadge = u._outForCal + ? 'Out for Cal' + : ''; + const allocatedBadge = u._allocated + ? `Allocated${u.allocated_to_project_id ? ': ' + u.allocated_to_project_id : ''}` + : ''; + let calBadge; + if (!calDate) { + calBadge = 'Cal expired'; + } else if (u._calExpired) { + calBadge = `Cal expired ${expiryDate}`; + } else { + calBadge = `Cal: ${calDate} · exp. ${expiryDate}`; + } + 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 `Reserved: ${r.reservation_name} ${s}–${e}`; + return `${r.reservation_name} ${s}–${e}`; }).join(''); + return `
- - ${deployedBadge} - ${calBadge} + ${deployedBadge}${outForCalBadge}${allocatedBadge}${calBadge}
${resBadges ? `
${resBadges}
` : ''}
`; }).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 ``; +} + function showRightPanel(panel) { document.getElementById('right-fleet-summary').classList.toggle('hidden', panel !== 'summary'); document.getElementById('right-available-units').classList.toggle('hidden', panel !== 'available'); @@ -1838,12 +2092,23 @@ function plannerSyncSlotsToEstimate() { plannerRenderSlots(); } +function plannerSelectSlot(idx) { + plannerSelectedSlotIdx = (plannerSelectedSlotIdx === idx) ? null : idx; + plannerRenderSlots(); + plannerRenderUnits(); +} + function plannerAssignUnit(unitId) { - const emptyIdx = plannerState.slots.findIndex(s => !s.unit_id); - if (emptyIdx >= 0) { - plannerState.slots[emptyIdx].unit_id = unitId; + if (plannerSelectedSlotIdx !== null && plannerSelectedSlotIdx < plannerState.slots.length && !plannerState.slots[plannerSelectedSlotIdx].unit_id) { + plannerState.slots[plannerSelectedSlotIdx].unit_id = unitId; + plannerSelectedSlotIdx = null; } else { - plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null, location_name: null }); + const emptyIdx = plannerState.slots.findIndex(s => !s.unit_id); + if (emptyIdx >= 0) { + plannerState.slots[emptyIdx].unit_id = unitId; + } else { + plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null, location_name: null }); + } } plannerRenderSlots(); plannerRenderUnits(); @@ -1879,8 +2144,13 @@ function plannerRenderSlots() { emptyMsg.classList.add('hidden'); plannerState.slots.forEach((slot, idx) => { + const isSelected = !slot.unit_id && plannerSelectedSlotIdx === idx; const row = document.createElement('div'); - row.className = 'planner-slot-row flex flex-col gap-1.5 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700/50'; + row.className = `planner-slot-row flex flex-col gap-1.5 px-3 py-2 rounded-lg border ${ + isSelected + ? 'border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/30 ring-2 ring-blue-400 dark:ring-blue-500' + : 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700/50' + }`; row.dataset.idx = idx; row.draggable = !!slot.unit_id; @@ -1904,11 +2174,10 @@ function plannerRenderSlots() { e.preventDefault(); row.classList.remove('ring-2', 'ring-blue-400'); if (dragSrcIdx === null || dragSrcIdx === idx) return; - // Swap unit_id and power_type only (keep location notes in place) + // Swap unit_id only — power_type stays with the location slot const srcSlot = plannerState.slots[dragSrcIdx]; const dstSlot = plannerState.slots[idx]; [srcSlot.unit_id, dstSlot.unit_id] = [dstSlot.unit_id, srcSlot.unit_id]; - [srcSlot.power_type, dstSlot.power_type] = [dstSlot.power_type, srcSlot.power_type]; dragSrcIdx = null; plannerRenderSlots(); plannerRenderUnits(); @@ -1926,15 +2195,45 @@ function plannerRenderSlots() { ? `` : ``; + // 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 = `No cal`; + } else if (calExpired) { + const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}); + calInline = `Cal exp. ${expiryStr}`; + } else if (expiresInJob) { + const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}); + calInline = `Cal exp. ${expiryStr}`; + } else { + const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}); + calInline = `Cal exp. ${expiryStr}`; + } + } + } + row.innerHTML = `
${dragHandle} Loc. ${idx + 1} ${slot.unit_id - ? `${slot.unit_id} + ? ` + ${calInline ? `${calInline}` : ''} ${powerSelect} ` - : `Empty — click a unit + : ` ${powerSelect} ` } @@ -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 = ''; diff --git a/templates/partials/devices_table.html b/templates/partials/devices_table.html index c2b0127..2a85f06 100644 --- a/templates/partials/devices_table.html +++ b/templates/partials/devices_table.html @@ -51,7 +51,7 @@ {% for unit in units %} {% if unit.out_for_calibration %} + {% elif unit.allocated %} + {% elif not unit.deployed %} {% elif unit.status == 'OK' %} @@ -76,6 +78,8 @@ {% elif unit.out_for_calibration %} + {% elif unit.allocated %} + {% else %} {% endif %} @@ -207,7 +211,7 @@
@@ -216,6 +220,8 @@
{% if unit.out_for_calibration %} + {% elif unit.allocated %} + {% elif not unit.deployed %} {% elif unit.status == 'OK' %} @@ -231,12 +237,13 @@
- {% 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 %}
diff --git a/templates/partials/seismo_row_edit.html b/templates/partials/seismo_row_edit.html new file mode 100644 index 0000000..dc0945d --- /dev/null +++ b/templates/partials/seismo_row_edit.html @@ -0,0 +1,63 @@ + + + + {{ unit.id }} + + + + + + + {% if unit.deployed_with_modem_id %} + + {{ unit.deployed_with_modem_id }} + + {% else %} + None + {% endif %} + + + {% if unit.address %} + {{ unit.address }} + {% elif unit.coordinates %} + {{ unit.coordinates }} + {% else %} + + {% endif %} + + + + + + + + +
+ + +
+ + diff --git a/templates/partials/seismo_row_view.html b/templates/partials/seismo_row_view.html new file mode 100644 index 0000000..f41abdd --- /dev/null +++ b/templates/partials/seismo_row_view.html @@ -0,0 +1,93 @@ + + + + {{ unit.id }} + + + + {% if unit.deployed %} + + + + + Deployed + + {% elif unit.out_for_calibration %} + + + + + Out for Cal + + {% else %} + + + + + Benched + + {% endif %} + + + {% if unit.deployed_with_modem_id %} + + {{ unit.deployed_with_modem_id }} + + {% else %} + None + {% endif %} + + + {% if unit.address %} + {{ unit.address }} + {% elif unit.coordinates %} + {{ unit.coordinates }} + {% else %} + + {% endif %} + + + {% if unit.last_calibrated %} + + {% if unit.next_calibration_due and today %} + {% set days_until = (unit.next_calibration_due - today).days %} + {% if days_until < 0 %} + + {% elif days_until <= 14 %} + + {% else %} + + {% endif %} + {% else %} + + {% endif %} + {{ unit.last_calibrated.strftime('%Y-%m-%d') }} + + {% else %} + + {% endif %} + + + {% if unit.note %} + {{ unit.note }} + {% else %} + + {% endif %} + + + + + diff --git a/templates/partials/seismo_unit_list.html b/templates/partials/seismo_unit_list.html index 09cf087..2611984 100644 --- a/templates/partials/seismo_unit_list.html +++ b/templates/partials/seismo_unit_list.html @@ -92,88 +92,7 @@ {% for unit in units %} - - - - {{ unit.id }} - - - - {% if unit.deployed %} - - - - - Deployed - - {% elif unit.out_for_calibration %} - - - - - Out for Cal - - {% else %} - - - - - Benched - - {% endif %} - - - {% if unit.deployed_with_modem_id %} - - {{ unit.deployed_with_modem_id }} - - {% else %} - None - {% endif %} - - - {% if unit.address %} - {{ unit.address }} - {% elif unit.coordinates %} - {{ unit.coordinates }} - {% else %} - - {% endif %} - - - {% if unit.last_calibrated %} - - {% if unit.next_calibration_due and today %} - {% set days_until = (unit.next_calibration_due - today).days %} - {% if days_until < 0 %} - - {% elif days_until <= 14 %} - - {% else %} - - {% endif %} - {% else %} - - {% endif %} - {{ unit.last_calibrated.strftime('%Y-%m-%d') }} - - {% else %} - - {% endif %} - - - {% if unit.note %} - {{ unit.note }} - {% else %} - - {% endif %} - - - - View Details → - - - + {% include "partials/seismo_row_view.html" %} {% endfor %} diff --git a/templates/roster.html b/templates/roster.html index 0db9605..6380962 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -66,6 +66,7 @@ +
@@ -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'; diff --git a/templates/unit_detail.html b/templates/unit_detail.html index ed5a52a..d2fbf73 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -278,6 +278,22 @@

--

+ +
+
+

Deployment History

+ +
+
+

Loading...

+
+
+

Timeline

@@ -320,6 +336,53 @@
+ + +