Compare commits
3 Commits
57a85f565b
...
64d4423308
| Author | SHA1 | Date | |
|---|---|---|---|
| 64d4423308 | |||
| 4f71d528ce | |||
| 4f56dea4f3 |
@@ -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
|
||||||
@@ -703,6 +707,33 @@ async def devices_all_partial(request: Request):
|
|||||||
"hardware_model": unit_data.get("hardware_model"),
|
"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
|
# Add out-for-calibration units
|
||||||
for unit_id, unit_data in snapshot["out_for_calibration"].items():
|
for unit_id, unit_data in snapshot["out_for_calibration"].items():
|
||||||
units_list.append({
|
units_list.append({
|
||||||
@@ -780,17 +811,19 @@ async def devices_all_partial(request: Request):
|
|||||||
|
|
||||||
# Sort by status category, then by ID
|
# Sort by status category, then by ID
|
||||||
def sort_key(unit):
|
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"]:
|
if unit["deployed"]:
|
||||||
return (0, unit["id"])
|
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"])
|
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"])
|
return (2, unit["id"])
|
||||||
elif unit["retired"]:
|
elif unit["out_for_calibration"]:
|
||||||
return (3, unit["id"])
|
return (3, unit["id"])
|
||||||
else:
|
elif unit["retired"]:
|
||||||
return (4, unit["id"])
|
return (4, unit["id"])
|
||||||
|
else:
|
||||||
|
return (5, unit["id"])
|
||||||
|
|
||||||
units_list.sort(key=sort_key)
|
units_list.sort(key=sort_key)
|
||||||
|
|
||||||
|
|||||||
35
backend/migrate_add_allocated.py
Normal file
35
backend/migrate_add_allocated.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add allocated and allocated_to_project_id columns to roster table.
|
||||||
|
Run once: python backend/migrate_add_allocated.py
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'seismo_fleet.db')
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Check existing columns
|
||||||
|
cur.execute("PRAGMA table_info(roster)")
|
||||||
|
cols = {row[1] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
if 'allocated' not in cols:
|
||||||
|
cur.execute("ALTER TABLE roster ADD COLUMN allocated BOOLEAN DEFAULT 0 NOT NULL")
|
||||||
|
print("Added column: allocated")
|
||||||
|
else:
|
||||||
|
print("Column already exists: allocated")
|
||||||
|
|
||||||
|
if 'allocated_to_project_id' not in cols:
|
||||||
|
cur.execute("ALTER TABLE roster ADD COLUMN allocated_to_project_id VARCHAR")
|
||||||
|
print("Added column: allocated_to_project_id")
|
||||||
|
else:
|
||||||
|
print("Column already exists: allocated_to_project_id")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
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()
|
||||||
@@ -33,6 +33,8 @@ class RosterUnit(Base):
|
|||||||
deployed = Column(Boolean, default=True)
|
deployed = Column(Boolean, default=True)
|
||||||
retired = Column(Boolean, default=False)
|
retired = Column(Boolean, default=False)
|
||||||
out_for_calibration = 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)
|
note = Column(String, nullable=True)
|
||||||
project_id = Column(String, nullable=True)
|
project_id = Column(String, nullable=True)
|
||||||
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
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
|
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}
|
||||||
@@ -701,6 +701,8 @@ async def get_planner_availability(
|
|||||||
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
|
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
|
||||||
"deployed": u.deployed,
|
"deployed": u.deployed,
|
||||||
"out_for_calibration": u.out_for_calibration or False,
|
"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 "",
|
"note": u.note or "",
|
||||||
"reservations": unit_reservations.get(u.id, [])
|
"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)
|
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
||||||
async def get_available_units_partial(
|
async def get_available_units_partial(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -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"""
|
||||||
@@ -467,6 +500,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"out_for_calibration": unit.out_for_calibration or False,
|
"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 "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"location": unit.location or "",
|
||||||
@@ -499,6 +534,8 @@ async def edit_roster_unit(
|
|||||||
deployed: str = Form(None),
|
deployed: str = Form(None),
|
||||||
retired: str = Form(None),
|
retired: str = Form(None),
|
||||||
out_for_calibration: str = Form(None),
|
out_for_calibration: str = Form(None),
|
||||||
|
allocated: str = Form(None),
|
||||||
|
allocated_to_project_id: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: 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
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired 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
|
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
|
# Convert port strings to integers
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
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.deployed = deployed_bool
|
||||||
unit.retired = retired_bool
|
unit.retired = retired_bool
|
||||||
unit.out_for_calibration = out_for_calibration_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.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
unit.location = location
|
unit.location = location
|
||||||
@@ -679,6 +719,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 +836,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)
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ def emit_status_snapshot():
|
|||||||
age = "N/A"
|
age = "N/A"
|
||||||
last_seen = None
|
last_seen = None
|
||||||
fname = ""
|
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:
|
else:
|
||||||
if e:
|
if e:
|
||||||
last_seen = ensure_utc(e.last_seen)
|
last_seen = ensure_utc(e.last_seen)
|
||||||
@@ -110,6 +116,8 @@ def emit_status_snapshot():
|
|||||||
"note": r.note or "",
|
"note": r.note or "",
|
||||||
"retired": r.retired,
|
"retired": r.retired,
|
||||||
"out_for_calibration": r.out_for_calibration or False,
|
"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 and type-specific fields
|
||||||
"device_type": r.device_type or "seismograph",
|
"device_type": r.device_type or "seismograph",
|
||||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
@@ -141,6 +149,8 @@ def emit_status_snapshot():
|
|||||||
"note": "",
|
"note": "",
|
||||||
"retired": False,
|
"retired": False,
|
||||||
"out_for_calibration": False,
|
"out_for_calibration": False,
|
||||||
|
"allocated": False,
|
||||||
|
"allocated_to_project_id": "",
|
||||||
# Device type and type-specific fields (defaults for unknown units)
|
# Device type and type-specific fields (defaults for unknown units)
|
||||||
"device_type": "seismograph", # default
|
"device_type": "seismograph", # default
|
||||||
"last_calibrated": None,
|
"last_calibrated": None,
|
||||||
@@ -192,7 +202,12 @@ def emit_status_snapshot():
|
|||||||
|
|
||||||
benched_units = {
|
benched_units = {
|
||||||
uid: u for uid, u in units.items()
|
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 = {
|
retired_units = {
|
||||||
@@ -216,13 +231,15 @@ def emit_status_snapshot():
|
|||||||
"units": units,
|
"units": units,
|
||||||
"active": active_units,
|
"active": active_units,
|
||||||
"benched": benched_units,
|
"benched": benched_units,
|
||||||
|
"allocated": allocated_units,
|
||||||
"retired": retired_units,
|
"retired": retired_units,
|
||||||
"out_for_calibration": out_for_calibration_units,
|
"out_for_calibration": out_for_calibration_units,
|
||||||
"unknown": unknown_units,
|
"unknown": unknown_units,
|
||||||
"summary": {
|
"summary": {
|
||||||
"total": len(active_units) + len(benched_units),
|
"total": len(active_units) + len(benched_units) + len(allocated_units),
|
||||||
"active": len(active_units),
|
"active": len(active_units),
|
||||||
"benched": len(benched_units),
|
"benched": len(benched_units),
|
||||||
|
"allocated": len(allocated_units),
|
||||||
"retired": len(retired_units),
|
"retired": len(retired_units),
|
||||||
"out_for_calibration": len(out_for_calibration_units),
|
"out_for_calibration": len(out_for_calibration_units),
|
||||||
"unknown": len(unknown_units),
|
"unknown": len(unknown_units),
|
||||||
|
|||||||
@@ -57,6 +57,10 @@
|
|||||||
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||||
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
|
||||||
|
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
|
||||||
|
</div>
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||||
<div class="flex justify-between items-center mb-1">
|
<div class="flex justify-between items-center mb-1">
|
||||||
@@ -703,6 +707,7 @@ function updateDashboard(event) {
|
|||||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 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-ok').textContent = data.summary?.ok ?? 0;
|
||||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
|
|||||||
@@ -650,7 +650,7 @@
|
|||||||
<!-- Fleet Summary (shown on jobs list) -->
|
<!-- Fleet Summary (shown on jobs list) -->
|
||||||
<div id="right-fleet-summary" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
|
<div id="right-fleet-summary" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||||
<div id="fleet-summary-stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
|
<div id="fleet-summary-stats" class="flex flex-col gap-0">
|
||||||
<!-- Populated by JS -->
|
<!-- Populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="summary-search" placeholder="Search by unit ID..."
|
<input type="text" id="summary-search" placeholder="Search by unit ID..."
|
||||||
@@ -713,6 +713,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Quick-Info Modal -->
|
||||||
|
<div id="unit-quick-modal" class="fixed inset-0 z-50 hidden">
|
||||||
|
<div class="fixed inset-0 bg-black/50" onclick="closeUnitQuickModal()"></div>
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center p-4 pointer-events-none">
|
||||||
|
<div id="unit-quick-modal-inner" class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md pointer-events-auto" onclick="event.stopPropagation()">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h3 id="uqm-title" class="text-lg font-bold text-gray-900 dark:text-white"></h3>
|
||||||
|
<span id="uqm-deployed-badge"></span>
|
||||||
|
<span id="uqm-outforcal-badge"></span>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeUnitQuickModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="px-5 py-4 flex flex-col gap-4">
|
||||||
|
<!-- Cal row -->
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Last Calibration</p>
|
||||||
|
<p id="uqm-cal-date" class="text-sm font-medium text-gray-900 dark:text-white"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Cal Due</p>
|
||||||
|
<p id="uqm-cal-due" class="text-sm font-medium"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Location / address -->
|
||||||
|
<div id="uqm-address-row" class="hidden">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Address / Location</p>
|
||||||
|
<p id="uqm-address" class="text-sm text-gray-800 dark:text-gray-200"></p>
|
||||||
|
</div>
|
||||||
|
<!-- Project -->
|
||||||
|
<div id="uqm-project-row" class="hidden">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Project</p>
|
||||||
|
<p id="uqm-project" class="text-sm text-gray-800 dark:text-gray-200"></p>
|
||||||
|
</div>
|
||||||
|
<!-- Modem -->
|
||||||
|
<div id="uqm-modem-row" class="hidden">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Deployed With Modem</p>
|
||||||
|
<p id="uqm-modem" class="text-sm text-gray-800 dark:text-gray-200"></p>
|
||||||
|
</div>
|
||||||
|
<!-- Last seen -->
|
||||||
|
<div id="uqm-lastseen-row" class="hidden">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Last Seen</p>
|
||||||
|
<p id="uqm-lastseen" class="text-sm text-gray-800 dark:text-gray-200"></p>
|
||||||
|
</div>
|
||||||
|
<!-- Note -->
|
||||||
|
<div id="uqm-note-row" class="hidden">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Note</p>
|
||||||
|
<p id="uqm-note" class="text-sm text-gray-800 dark:text-gray-200 italic"></p>
|
||||||
|
</div>
|
||||||
|
<!-- Reservations -->
|
||||||
|
<div id="uqm-reservations-row" class="hidden">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Upcoming Jobs</p>
|
||||||
|
<div id="uqm-reservations" class="flex flex-col gap-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Day Detail Slide Panel -->
|
<!-- Day Detail Slide Panel -->
|
||||||
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
|
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
|
||||||
<div id="day-panel" class="slide-panel">
|
<div id="day-panel" class="slide-panel">
|
||||||
@@ -1434,6 +1498,8 @@ function toggleJobLayer(layer) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Reservation Planner
|
// Reservation Planner
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
let plannerSelectedSlotIdx = null;
|
||||||
|
|
||||||
let plannerState = {
|
let plannerState = {
|
||||||
reservation_id: null, // null = creating new
|
reservation_id: null, // null = creating new
|
||||||
slots: [], // array of {unit_id: string|null, power_type: string|null, notes: string|null, location_name: string|null}
|
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 placeholder = document.getElementById('planner-units-placeholder');
|
||||||
const list = document.getElementById('planner-units-list');
|
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) {
|
if (plannerState.allUnits.length === 0) {
|
||||||
placeholder.classList.remove('hidden');
|
placeholder.classList.remove('hidden');
|
||||||
const start = document.getElementById('planner-start').value;
|
const start = document.getElementById('planner-start').value;
|
||||||
@@ -1660,7 +1742,7 @@ function plannerRenderUnits() {
|
|||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<button onclick="event.stopPropagation(); openUnitDetailModal('${unit.id}')"
|
<button onclick="event.stopPropagation(); openUnitQuickModal('${unit.id}')"
|
||||||
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${unit.id}</button>
|
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${unit.id}</button>
|
||||||
${deployedBadge}
|
${deployedBadge}
|
||||||
${expiryWarning}
|
${expiryWarning}
|
||||||
@@ -1689,6 +1771,108 @@ function closeUnitDetailModal() {
|
|||||||
document.getElementById('unit-detail-iframe').src = '';
|
document.getElementById('unit-detail-iframe').src = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openUnitQuickModal(unitId) {
|
||||||
|
document.getElementById('unit-quick-modal').classList.remove('hidden');
|
||||||
|
// Reset while loading
|
||||||
|
document.getElementById('uqm-title').textContent = unitId;
|
||||||
|
document.getElementById('uqm-deployed-badge').innerHTML = '';
|
||||||
|
document.getElementById('uqm-outforcal-badge').innerHTML = '';
|
||||||
|
document.getElementById('uqm-cal-date').textContent = '…';
|
||||||
|
document.getElementById('uqm-cal-due').textContent = '…';
|
||||||
|
['uqm-address-row','uqm-project-row','uqm-modem-row','uqm-lastseen-row','uqm-note-row','uqm-reservations-row']
|
||||||
|
.forEach(id => document.getElementById(id).classList.add('hidden'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/fleet-calendar/unit-quick-info/${unitId}`);
|
||||||
|
if (!resp.ok) throw new Error('Not found');
|
||||||
|
const u = await resp.json();
|
||||||
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
|
||||||
|
// Deployed badge
|
||||||
|
document.getElementById('uqm-deployed-badge').innerHTML = u.deployed
|
||||||
|
? '<span class="text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full">Deployed</span>'
|
||||||
|
: '<span class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full">Benched</span>';
|
||||||
|
|
||||||
|
// Out for cal badge
|
||||||
|
if (u.out_for_calibration) {
|
||||||
|
document.getElementById('uqm-outforcal-badge').innerHTML =
|
||||||
|
'<span class="text-xs px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded-full">Out for Cal</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cal date
|
||||||
|
const calDateEl = document.getElementById('uqm-cal-date');
|
||||||
|
calDateEl.textContent = u.last_calibrated
|
||||||
|
? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
|
||||||
|
: 'No record';
|
||||||
|
calDateEl.className = `text-sm font-medium ${!u.last_calibrated ? 'text-red-500 dark:text-red-400' : 'text-gray-900 dark:text-white'}`;
|
||||||
|
|
||||||
|
// Cal due
|
||||||
|
const calDueEl = document.getElementById('uqm-cal-due');
|
||||||
|
if (u.next_calibration_due) {
|
||||||
|
const due = new Date(u.next_calibration_due + 'T00:00:00');
|
||||||
|
const expired = due < today;
|
||||||
|
calDueEl.textContent = due.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) + (expired ? ' (expired)' : '');
|
||||||
|
calDueEl.className = `text-sm font-medium ${expired ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`;
|
||||||
|
} else {
|
||||||
|
calDueEl.textContent = '—';
|
||||||
|
calDueEl.className = 'text-sm font-medium text-red-500 dark:text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address
|
||||||
|
if (u.address) {
|
||||||
|
document.getElementById('uqm-address-row').classList.remove('hidden');
|
||||||
|
document.getElementById('uqm-address').textContent = u.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project
|
||||||
|
if (u.project_id) {
|
||||||
|
document.getElementById('uqm-project-row').classList.remove('hidden');
|
||||||
|
document.getElementById('uqm-project').textContent = u.project_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modem
|
||||||
|
if (u.deployed_with_modem_id) {
|
||||||
|
document.getElementById('uqm-modem-row').classList.remove('hidden');
|
||||||
|
document.getElementById('uqm-modem').textContent = u.deployed_with_modem_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last seen
|
||||||
|
if (u.last_seen) {
|
||||||
|
document.getElementById('uqm-lastseen-row').classList.remove('hidden');
|
||||||
|
document.getElementById('uqm-lastseen').textContent =
|
||||||
|
new Date(u.last_seen).toLocaleString('en-US', {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note
|
||||||
|
if (u.note) {
|
||||||
|
document.getElementById('uqm-note-row').classList.remove('hidden');
|
||||||
|
document.getElementById('uqm-note').textContent = u.note;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reservations
|
||||||
|
if (u.reservations && u.reservations.length > 0) {
|
||||||
|
document.getElementById('uqm-reservations-row').classList.remove('hidden');
|
||||||
|
document.getElementById('uqm-reservations').innerHTML = u.reservations.map(r => {
|
||||||
|
const s = r.start_date ? new Date(r.start_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : '';
|
||||||
|
const e = r.end_date_tbd ? 'TBD' : (r.end_date ? new Date(r.end_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : 'TBD');
|
||||||
|
const loc = r.location_name ? ` · ${r.location_name}` : '';
|
||||||
|
return `<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:${r.color}"></span>
|
||||||
|
<span class="font-medium text-gray-800 dark:text-gray-200">${r.name}</span>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">${s}–${e}${loc}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('uqm-cal-date').textContent = 'Error loading';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUnitQuickModal() {
|
||||||
|
document.getElementById('unit-quick-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
function plannerAddSlot() {
|
function plannerAddSlot() {
|
||||||
plannerState.slots.push({ unit_id: null, power_type: null, notes: null, location_name: null });
|
plannerState.slots.push({ unit_id: null, power_type: null, notes: null, location_name: null });
|
||||||
plannerRenderSlots();
|
plannerRenderSlots();
|
||||||
@@ -1698,7 +1882,7 @@ function plannerAddSlot() {
|
|||||||
// Fleet Summary (right panel on jobs list)
|
// Fleet Summary (right panel on jobs list)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
let summaryAllUnits = [];
|
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() {
|
async function loadFleetSummary() {
|
||||||
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
|
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 resp = await fetch(`/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
summaryAllUnits = data.units || [];
|
summaryAllUnits = data.units || [];
|
||||||
summaryActiveFilter = null;
|
summaryActiveFilters = new Set();
|
||||||
renderFleetSummary();
|
renderFleetSummary();
|
||||||
} catch(e) { console.error('Fleet summary load error', e); }
|
} catch(e) { console.error('Fleet summary load error', e); }
|
||||||
}
|
}
|
||||||
@@ -1715,88 +1899,158 @@ function summaryFilterUnits() {
|
|||||||
renderFleetSummary();
|
renderFleetSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stat cards: set exactly this one filter (or clear all if already the only active one)
|
||||||
function summarySetFilter(f) {
|
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();
|
renderFleetSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFleetSummary() {
|
function renderFleetSummary() {
|
||||||
const search = document.getElementById('summary-search')?.value.toLowerCase() || '';
|
const search = document.getElementById('summary-search')?.value.toLowerCase() || '';
|
||||||
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
|
||||||
// Stats (always against full list)
|
// Computed flags for each unit
|
||||||
const total = summaryAllUnits.length;
|
const withFlags = summaryAllUnits.map(u => {
|
||||||
const deployed = summaryAllUnits.filter(u => u.deployed).length;
|
const expiry = u.expiry_date ? new Date(u.expiry_date + 'T00:00:00') : null;
|
||||||
const benched = summaryAllUnits.filter(u => !u.deployed).length;
|
return {
|
||||||
const calExpired = summaryAllUnits.filter(u => u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date()).length;
|
...u,
|
||||||
|
_calExpired: !u.last_calibrated || (expiry && expiry < today),
|
||||||
|
_calGood: u.last_calibrated && expiry && expiry >= today,
|
||||||
|
_outForCal: !!u.out_for_calibration,
|
||||||
|
_allocated: !!u.allocated,
|
||||||
|
_reserved: (u.reservations || []).length > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Counts always against full list
|
||||||
|
const counts = {
|
||||||
|
total: withFlags.length,
|
||||||
|
deployed: withFlags.filter(u => u.deployed).length,
|
||||||
|
benched: withFlags.filter(u => !u.deployed).length,
|
||||||
|
cal_expired: withFlags.filter(u => u._calExpired).length,
|
||||||
|
cal_good: withFlags.filter(u => u._calGood).length,
|
||||||
|
out_for_cal: withFlags.filter(u => u._outForCal).length,
|
||||||
|
allocated: withFlags.filter(u => u._allocated).length,
|
||||||
|
reserved: withFlags.filter(u => u._reserved).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const af = summaryActiveFilters;
|
||||||
|
|
||||||
|
// Stat cards — single-shortcut behavior, highlighted when they're the sole active filter
|
||||||
|
const cardActive = (f) => af.size === 1 && af.has(f);
|
||||||
|
const card = (f, label, count, colorClass, ringColor) => {
|
||||||
|
const isActive = f === null ? af.size === 0 : cardActive(f);
|
||||||
|
return `<button onclick="summarySetFilter(${f === null ? 'null' : `'${f}'`})"
|
||||||
|
class="rounded-lg p-3 text-left w-full cursor-pointer transition-all ring-2 ${isActive ? ringColor : 'ring-transparent'} ${colorClass}">
|
||||||
|
<p class="text-2xl font-bold">${count}</p>
|
||||||
|
<p class="text-xs opacity-80">${label}</p>
|
||||||
|
</button>`;
|
||||||
|
};
|
||||||
|
|
||||||
const cardBase = 'rounded-lg p-3 text-left w-full cursor-pointer transition-all ring-2';
|
|
||||||
const active = summaryActiveFilter;
|
|
||||||
document.getElementById('fleet-summary-stats').innerHTML = `
|
document.getElementById('fleet-summary-stats').innerHTML = `
|
||||||
<button onclick="summarySetFilter(null)"
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
class="${cardBase} ${!active ? 'ring-gray-400 dark:ring-gray-300' : 'ring-transparent'} bg-gray-50 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-slate-600">
|
${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')}
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">${total}</p>
|
${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')}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Total</p>
|
${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')}
|
||||||
</button>
|
${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')}
|
||||||
<button onclick="summarySetFilter('deployed')"
|
</div>
|
||||||
class="${cardBase} ${active === 'deployed' ? 'ring-green-500' : 'ring-transparent'} bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40">
|
<div class="flex flex-wrap gap-1.5 mt-2">
|
||||||
<p class="text-2xl font-bold text-green-700 dark:text-green-400">${deployed}</p>
|
${summaryPill('cal_expired', 'Cal Expired', counts.cal_expired, af)}
|
||||||
<p class="text-xs text-green-600 dark:text-green-500">Deployed</p>
|
${summaryPill('out_for_cal', 'Out for Cal', counts.out_for_cal, af)}
|
||||||
</button>
|
${summaryPill('allocated', 'Allocated', counts.allocated, af)}
|
||||||
<button onclick="summarySetFilter('benched')"
|
${summaryPill('reserved', 'Reserved', counts.reserved, af)}
|
||||||
class="${cardBase} ${active === 'benched' ? 'ring-blue-500' : 'ring-transparent'} bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40">
|
</div>`;
|
||||||
<p class="text-2xl font-bold text-blue-700 dark:text-blue-400">${benched}</p>
|
|
||||||
<p class="text-xs text-blue-600 dark:text-blue-500">Benched</p>
|
|
||||||
</button>
|
|
||||||
<button onclick="summarySetFilter('cal_expired')"
|
|
||||||
class="${cardBase} ${active === 'cal_expired' ? 'ring-red-500' : 'ring-transparent'} bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
|
|
||||||
<p class="text-2xl font-bold text-red-700 dark:text-red-400">${calExpired}</p>
|
|
||||||
<p class="text-xs text-red-600 dark:text-red-500">Cal Expired</p>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Apply filter + search to the list
|
// Apply all active filters (AND logic) + search
|
||||||
let units = summaryAllUnits;
|
const filterFns = {
|
||||||
if (active === 'deployed') units = units.filter(u => u.deployed);
|
deployed: u => u.deployed,
|
||||||
else if (active === 'benched') units = units.filter(u => !u.deployed);
|
benched: 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());
|
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));
|
if (search) units = units.filter(u => u.id.toLowerCase().includes(search));
|
||||||
|
|
||||||
// Unit list
|
|
||||||
const list = document.getElementById('fleet-summary-list');
|
const list = document.getElementById('fleet-summary-list');
|
||||||
if (units.length === 0) {
|
if (units.length === 0) {
|
||||||
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No units found</p>';
|
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No units match</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = units.map(u => {
|
list.innerHTML = units.map(u => {
|
||||||
const calDate = u.last_calibrated
|
const calDate = u.last_calibrated
|
||||||
? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
|
? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
|
||||||
: 'No cal date';
|
: null;
|
||||||
const expired = u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date();
|
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
|
const deployedBadge = u.deployed
|
||||||
? '<span class="text-xs px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
|
? '<span class="text-xs px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
|
||||||
: '<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
|
: '<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
|
||||||
const calBadge = expired
|
const outForCalBadge = u._outForCal
|
||||||
? `<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired</span>`
|
? '<span class="text-xs px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">Out for Cal</span>'
|
||||||
: `<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate}</span>`;
|
: '';
|
||||||
|
const allocatedBadge = u._allocated
|
||||||
|
? `<span class="text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded" title="${u.allocated_to_project_id ? 'For: ' + u.allocated_to_project_id : ''}">Allocated${u.allocated_to_project_id ? ': ' + u.allocated_to_project_id : ''}</span>`
|
||||||
|
: '';
|
||||||
|
let calBadge;
|
||||||
|
if (!calDate) {
|
||||||
|
calBadge = '<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired</span>';
|
||||||
|
} else if (u._calExpired) {
|
||||||
|
calBadge = `<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired ${expiryDate}</span>`;
|
||||||
|
} else {
|
||||||
|
calBadge = `<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate} · exp. ${expiryDate}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
const resBadges = (u.reservations || []).map(r => {
|
const 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 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';
|
const e = r.end_date ? new Date(r.end_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : 'TBD';
|
||||||
return `<span class="text-xs px-1.5 py-0.5 rounded font-medium" style="background-color:${r.color}22; color:${r.color}; border:1px solid ${r.color}66;"><span class="opacity-60">Reserved:</span> ${r.reservation_name} ${s}–${e}</span>`;
|
return `<span class="text-xs px-1.5 py-0.5 rounded font-medium" style="background-color:${r.color}22; color:${r.color}; border:1px solid ${r.color}66;">${r.reservation_name} ${s}–${e}</span>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="flex flex-col gap-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
<div class="flex flex-col gap-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<button onclick="openUnitDetailModal('${u.id}')"
|
<button onclick="openUnitQuickModal('${u.id}')"
|
||||||
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${u.id}</button>
|
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${u.id}</button>
|
||||||
${deployedBadge}
|
${deployedBadge}${outForCalBadge}${allocatedBadge}${calBadge}
|
||||||
${calBadge}
|
|
||||||
</div>
|
</div>
|
||||||
${resBadges ? `<div class="flex flex-wrap gap-1">${resBadges}</div>` : ''}
|
${resBadges ? `<div class="flex flex-wrap gap-1">${resBadges}</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summaryPill(f, label, count, activeSet) {
|
||||||
|
const isActive = activeSet.has(f);
|
||||||
|
const pillColors = {
|
||||||
|
cal_expired: isActive ? 'bg-red-600 text-white border-red-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-red-500 hover:text-red-600 dark:hover:text-red-400',
|
||||||
|
out_for_cal: isActive ? 'bg-purple-600 text-white border-purple-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-500 hover:text-purple-600 dark:hover:text-purple-400',
|
||||||
|
allocated: isActive ? 'bg-orange-500 text-white border-orange-500' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-orange-500 hover:text-orange-600 dark:hover:text-orange-400',
|
||||||
|
reserved: isActive ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400',
|
||||||
|
};
|
||||||
|
return `<button onclick="summaryToggleFilter('${f}')"
|
||||||
|
class="text-xs px-2.5 py-1 rounded-full font-medium border transition-colors ${pillColors[f]}">
|
||||||
|
${label} <span class="${isActive ? 'opacity-80' : 'opacity-60'}">${count}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
function showRightPanel(panel) {
|
function showRightPanel(panel) {
|
||||||
document.getElementById('right-fleet-summary').classList.toggle('hidden', panel !== 'summary');
|
document.getElementById('right-fleet-summary').classList.toggle('hidden', panel !== 'summary');
|
||||||
document.getElementById('right-available-units').classList.toggle('hidden', panel !== 'available');
|
document.getElementById('right-available-units').classList.toggle('hidden', panel !== 'available');
|
||||||
@@ -1838,13 +2092,24 @@ function plannerSyncSlotsToEstimate() {
|
|||||||
plannerRenderSlots();
|
plannerRenderSlots();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function plannerSelectSlot(idx) {
|
||||||
|
plannerSelectedSlotIdx = (plannerSelectedSlotIdx === idx) ? null : idx;
|
||||||
|
plannerRenderSlots();
|
||||||
|
plannerRenderUnits();
|
||||||
|
}
|
||||||
|
|
||||||
function plannerAssignUnit(unitId) {
|
function plannerAssignUnit(unitId) {
|
||||||
|
if (plannerSelectedSlotIdx !== null && plannerSelectedSlotIdx < plannerState.slots.length && !plannerState.slots[plannerSelectedSlotIdx].unit_id) {
|
||||||
|
plannerState.slots[plannerSelectedSlotIdx].unit_id = unitId;
|
||||||
|
plannerSelectedSlotIdx = null;
|
||||||
|
} else {
|
||||||
const emptyIdx = plannerState.slots.findIndex(s => !s.unit_id);
|
const emptyIdx = plannerState.slots.findIndex(s => !s.unit_id);
|
||||||
if (emptyIdx >= 0) {
|
if (emptyIdx >= 0) {
|
||||||
plannerState.slots[emptyIdx].unit_id = unitId;
|
plannerState.slots[emptyIdx].unit_id = unitId;
|
||||||
} else {
|
} else {
|
||||||
plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null, location_name: null });
|
plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null, location_name: null });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
plannerRenderSlots();
|
plannerRenderSlots();
|
||||||
plannerRenderUnits();
|
plannerRenderUnits();
|
||||||
}
|
}
|
||||||
@@ -1879,8 +2144,13 @@ function plannerRenderSlots() {
|
|||||||
emptyMsg.classList.add('hidden');
|
emptyMsg.classList.add('hidden');
|
||||||
|
|
||||||
plannerState.slots.forEach((slot, idx) => {
|
plannerState.slots.forEach((slot, idx) => {
|
||||||
|
const isSelected = !slot.unit_id && plannerSelectedSlotIdx === idx;
|
||||||
const row = document.createElement('div');
|
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.dataset.idx = idx;
|
||||||
row.draggable = !!slot.unit_id;
|
row.draggable = !!slot.unit_id;
|
||||||
|
|
||||||
@@ -1904,11 +2174,10 @@ function plannerRenderSlots() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
row.classList.remove('ring-2', 'ring-blue-400');
|
row.classList.remove('ring-2', 'ring-blue-400');
|
||||||
if (dragSrcIdx === null || dragSrcIdx === idx) return;
|
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 srcSlot = plannerState.slots[dragSrcIdx];
|
||||||
const dstSlot = plannerState.slots[idx];
|
const dstSlot = plannerState.slots[idx];
|
||||||
[srcSlot.unit_id, dstSlot.unit_id] = [dstSlot.unit_id, srcSlot.unit_id];
|
[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;
|
dragSrcIdx = null;
|
||||||
plannerRenderSlots();
|
plannerRenderSlots();
|
||||||
plannerRenderUnits();
|
plannerRenderUnits();
|
||||||
@@ -1926,15 +2195,45 @@ function plannerRenderSlots() {
|
|||||||
? `<span class="text-gray-300 dark:text-gray-600 cursor-grab active:cursor-grabbing select-none" title="Drag to reorder">⠿</span>`
|
? `<span class="text-gray-300 dark:text-gray-600 cursor-grab active:cursor-grabbing select-none" title="Drag to reorder">⠿</span>`
|
||||||
: `<span class="w-4"></span>`;
|
: `<span class="w-4"></span>`;
|
||||||
|
|
||||||
|
// Build inline cal text for filled slots
|
||||||
|
let calInline = '';
|
||||||
|
if (slot.unit_id) {
|
||||||
|
const uData = plannerState.allUnits.find(u => u.id === slot.unit_id);
|
||||||
|
if (uData) {
|
||||||
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
const expiry = uData.expiry_date ? new Date(uData.expiry_date + 'T00:00:00') : null;
|
||||||
|
const calExpired = !uData.last_calibrated || (expiry && expiry < today);
|
||||||
|
const start = document.getElementById('planner-start').value;
|
||||||
|
const end = document.getElementById('planner-end').value;
|
||||||
|
const jobStart = start ? new Date(start + 'T00:00:00') : null;
|
||||||
|
const jobEnd = end ? new Date(end + 'T00:00:00') : null;
|
||||||
|
const expiresInJob = expiry && jobStart && jobEnd && expiry >= jobStart && expiry <= jobEnd;
|
||||||
|
|
||||||
|
if (!uData.last_calibrated) {
|
||||||
|
calInline = `<span class="text-xs text-red-500 dark:text-red-400 font-medium">No cal</span>`;
|
||||||
|
} else if (calExpired) {
|
||||||
|
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
|
||||||
|
calInline = `<span class="text-xs text-red-500 dark:text-red-400 font-medium">Cal exp. ${expiryStr}</span>`;
|
||||||
|
} else if (expiresInJob) {
|
||||||
|
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
|
||||||
|
calInline = `<span class="text-xs text-amber-500 dark:text-amber-400 font-medium">Cal exp. ${expiryStr}</span>`;
|
||||||
|
} else {
|
||||||
|
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
|
||||||
|
calInline = `<span class="text-xs text-gray-400 dark:text-gray-500">Cal exp. ${expiryStr}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
${dragHandle}
|
${dragHandle}
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400 w-16 flex-shrink-0">Loc. ${idx + 1}</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400 w-16 flex-shrink-0">Loc. ${idx + 1}</span>
|
||||||
${slot.unit_id
|
${slot.unit_id
|
||||||
? `<span class="flex-1 font-medium text-gray-900 dark:text-white">${slot.unit_id}</span>
|
? `<button onclick="openUnitQuickModal('${slot.unit_id}')" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">${slot.unit_id}</button>
|
||||||
|
${calInline ? `<span class="flex-1">${calInline}</span>` : '<span class="flex-1"></span>'}
|
||||||
${powerSelect}
|
${powerSelect}
|
||||||
<button onclick="plannerClearSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove unit">✕</button>`
|
<button onclick="plannerClearSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove unit">✕</button>`
|
||||||
: `<span class="flex-1 text-sm text-gray-400 dark:text-gray-500 italic">Empty — click a unit</span>
|
: `<button onclick="plannerSelectSlot(${idx})" class="flex-1 text-left text-sm italic ${plannerSelectedSlotIdx === idx ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-400 dark:text-gray-500'}">${plannerSelectedSlotIdx === idx ? '← click a unit to assign here' : 'Empty — click to select'}</button>
|
||||||
${powerSelect}
|
${powerSelect}
|
||||||
<button onclick="plannerRemoveSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove location">✕</button>`
|
<button onclick="plannerRemoveSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove location">✕</button>`
|
||||||
}
|
}
|
||||||
@@ -1956,12 +2255,13 @@ function plannerRenderSlots() {
|
|||||||
|
|
||||||
function plannerClearSlot(idx) {
|
function plannerClearSlot(idx) {
|
||||||
plannerState.slots[idx].unit_id = null;
|
plannerState.slots[idx].unit_id = null;
|
||||||
plannerState.slots[idx].power_type = null;
|
plannerSelectedSlotIdx = null;
|
||||||
plannerRenderSlots();
|
plannerRenderSlots();
|
||||||
plannerRenderUnits();
|
plannerRenderUnits();
|
||||||
}
|
}
|
||||||
|
|
||||||
function plannerReset() {
|
function plannerReset() {
|
||||||
|
plannerSelectedSlotIdx = null;
|
||||||
plannerState = { reservation_id: null, slots: [], allUnits: [] };
|
plannerState = { reservation_id: null, slots: [], allUnits: [] };
|
||||||
document.getElementById('planner-name').value = '';
|
document.getElementById('planner-name').value = '';
|
||||||
document.getElementById('planner-project').value = '';
|
document.getElementById('planner-project').value = '';
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-id="{{ unit.id }}"
|
data-id="{{ unit.id }}"
|
||||||
data-type="{{ unit.device_type }}"
|
data-type="{{ unit.device_type }}"
|
||||||
@@ -62,6 +62,8 @@
|
|||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
{% if unit.out_for_calibration %}
|
{% if unit.out_for_calibration %}
|
||||||
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||||
|
{% elif unit.allocated %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-orange-400" title="Allocated"></span>
|
||||||
{% elif not unit.deployed %}
|
{% elif not unit.deployed %}
|
||||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
@@ -76,6 +78,8 @@
|
|||||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||||
{% elif unit.out_for_calibration %}
|
{% elif unit.out_for_calibration %}
|
||||||
<span class="w-2 h-2 rounded-full bg-purple-400" title="Out for Calibration"></span>
|
<span class="w-2 h-2 rounded-full bg-purple-400" title="Out for Calibration"></span>
|
||||||
|
{% elif unit.allocated %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-orange-400" title="Allocated"></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -207,7 +211,7 @@
|
|||||||
<div class="unit-card device-card"
|
<div class="unit-card device-card"
|
||||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-unit-id="{{ unit.id }}"
|
data-unit-id="{{ unit.id }}"
|
||||||
data-age="{{ unit.age }}">
|
data-age="{{ unit.age }}">
|
||||||
@@ -216,6 +220,8 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if unit.out_for_calibration %}
|
{% if unit.out_for_calibration %}
|
||||||
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||||
|
{% elif unit.allocated %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-orange-400" title="Allocated"></span>
|
||||||
{% elif not unit.deployed %}
|
{% elif not unit.deployed %}
|
||||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
@@ -231,12 +237,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
|
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
|
||||||
|
{% elif unit.allocated %}bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300
|
||||||
{% elif unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
{% elif unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
{% if unit.out_for_calibration %}Out for Cal{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
{% if unit.out_for_calibration %}Out for Cal{% elif unit.allocated %}Allocated{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
|
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
|
||||||
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
|
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
|
||||||
<button class="filter-btn filter-status" data-value="benched">Benched</button>
|
<button class="filter-btn filter-status" data-value="benched">Benched</button>
|
||||||
|
<button class="filter-btn filter-status" data-value="allocated">Allocated</button>
|
||||||
<button class="filter-btn filter-status" data-value="retired">Retired</button>
|
<button class="filter-btn filter-status" data-value="retired">Retired</button>
|
||||||
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
|
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1352,7 +1353,7 @@
|
|||||||
|
|
||||||
// Toggle health filter visibility (hide for retired/ignored)
|
// Toggle health filter visibility (hide for retired/ignored)
|
||||||
const healthGroup = document.getElementById('health-filter-group');
|
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';
|
healthGroup.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
healthGroup.style.display = 'flex';
|
healthGroup.style.display = 'flex';
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -499,7 +562,7 @@
|
|||||||
|
|
||||||
<!-- Status Checkboxes -->
|
<!-- Status Checkboxes -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6 flex-wrap">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="deployed" id="deployed" value="true"
|
<input type="checkbox" name="deployed" id="deployed" value="true"
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
@@ -510,6 +573,18 @@
|
|||||||
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
|
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="allocated" id="allocated" value="true"
|
||||||
|
onchange="document.getElementById('allocatedProjectRow').style.display = this.checked ? '' : 'none'"
|
||||||
|
class="w-4 h-4 text-orange-500 focus:ring-orange-400 rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Allocated</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="allocatedProjectRow" style="display:none">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Allocated to Project</label>
|
||||||
|
<input type="text" name="allocated_to_project_id" id="allocatedToProjectId"
|
||||||
|
placeholder="Project name or ID"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-400 text-sm">
|
||||||
</div>
|
</div>
|
||||||
<!-- Hidden field for retired — controlled by the Retire button below -->
|
<!-- Hidden field for retired — controlled by the Retire button below -->
|
||||||
<input type="hidden" name="retired" id="retired" value="">
|
<input type="hidden" name="retired" id="retired" value="">
|
||||||
@@ -818,10 +893,14 @@ function populateViewMode() {
|
|||||||
|
|
||||||
document.getElementById('age').textContent = unitStatus.age || '--';
|
document.getElementById('age').textContent = unitStatus.age || '--';
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
|
const isAllocated = currentUnit.allocated && !currentUnit.deployed;
|
||||||
document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400';
|
document.getElementById('statusIndicator').className = isAllocated
|
||||||
// Show "Benched" if not deployed, otherwise "No status data"
|
? 'w-3 h-3 rounded-full bg-orange-400'
|
||||||
document.getElementById('statusText').textContent = !currentUnit.deployed ? 'Benched' : 'No status data';
|
: 'w-3 h-3 rounded-full bg-gray-400';
|
||||||
|
document.getElementById('statusText').className = isAllocated
|
||||||
|
? 'font-semibold text-orange-500 dark:text-orange-400'
|
||||||
|
: 'font-semibold text-gray-600 dark:text-gray-400';
|
||||||
|
document.getElementById('statusText').textContent = isAllocated ? 'Allocated' : (!currentUnit.deployed ? 'Benched' : 'No status data');
|
||||||
document.getElementById('lastSeen').textContent = '--';
|
document.getElementById('lastSeen').textContent = '--';
|
||||||
document.getElementById('age').textContent = '--';
|
document.getElementById('age').textContent = '--';
|
||||||
}
|
}
|
||||||
@@ -833,6 +912,11 @@ function populateViewMode() {
|
|||||||
} else if (currentUnit.out_for_calibration) {
|
} else if (currentUnit.out_for_calibration) {
|
||||||
document.getElementById('retiredStatus').textContent = 'Out for Calibration';
|
document.getElementById('retiredStatus').textContent = 'Out for Calibration';
|
||||||
document.getElementById('retiredStatus').className = 'font-medium text-purple-600 dark:text-purple-400';
|
document.getElementById('retiredStatus').className = 'font-medium text-purple-600 dark:text-purple-400';
|
||||||
|
} else if (currentUnit.allocated && !currentUnit.deployed) {
|
||||||
|
document.getElementById('retiredStatus').textContent = currentUnit.allocated_to_project_id
|
||||||
|
? `Allocated — ${currentUnit.allocated_to_project_id}`
|
||||||
|
: 'Allocated';
|
||||||
|
document.getElementById('retiredStatus').className = 'font-medium text-orange-500 dark:text-orange-400';
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('retiredStatus').textContent = 'Active';
|
document.getElementById('retiredStatus').textContent = 'Active';
|
||||||
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
|
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
|
||||||
@@ -1032,6 +1116,10 @@ function populateEditForm() {
|
|||||||
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
||||||
updateRetireButton(currentUnit.retired);
|
updateRetireButton(currentUnit.retired);
|
||||||
document.getElementById('note').value = currentUnit.note || '';
|
document.getElementById('note').value = currentUnit.note || '';
|
||||||
|
const allocatedChecked = currentUnit.allocated || false;
|
||||||
|
document.getElementById('allocated').checked = allocatedChecked;
|
||||||
|
document.getElementById('allocatedToProjectId').value = currentUnit.allocated_to_project_id || '';
|
||||||
|
document.getElementById('allocatedProjectRow').style.display = allocatedChecked ? '' : 'none';
|
||||||
|
|
||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
||||||
@@ -1631,12 +1719,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