Compare commits
14 Commits
v0.8.0
...
3c4b81cf78
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c4b81cf78 | |||
| d135727ebd | |||
| 64d4423308 | |||
| 4f71d528ce | |||
| 4f56dea4f3 | |||
| 57a85f565b | |||
|
|
e6555ba924 | ||
| 8694282dd0 | |||
| bc02dc9564 | |||
| 0d01715f81 | |||
| b3ec249c5e | |||
| b6e74258f1 | |||
| 0e3f512203 | |||
| e4d1f0d684 |
77
CHANGELOG.md
77
CHANGELOG.md
@@ -5,6 +5,83 @@ All notable changes to Terra-View will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.9.2] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations.
|
||||
- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators.
|
||||
- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster.
|
||||
- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page.
|
||||
- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing.
|
||||
- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page.
|
||||
|
||||
### Migration Notes
|
||||
Run on each database before deploying:
|
||||
```bash
|
||||
docker compose exec terra-view python3 backend/migrate_add_allocated.py
|
||||
docker compose exec terra-view python3 backend/migrate_add_deployment_records.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.9.1] - 2026-03-20
|
||||
|
||||
### Fixed
|
||||
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
|
||||
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
|
||||
- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility.
|
||||
|
||||
### Migration Notes
|
||||
Run on each database before deploying:
|
||||
```bash
|
||||
docker compose exec terra-view python3 backend/migrate_add_location_slots.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.9.0] - 2026-03-19
|
||||
|
||||
### Added
|
||||
- **Job Planner**: Full redesign of the Fleet Calendar into a two-tab Job Planner / Calendar interface
|
||||
- **Planner tab**: Create and manage job reservations with name, device type, dates, color, estimated units, and monitoring locations
|
||||
- **Calendar tab**: 12-month rolling heatmap with colored job bars per day; confirmed jobs solid, planned jobs dashed
|
||||
- **Monitoring Locations**: Each job has named location slots (filled = unit assigned, empty = needs a unit); progress shown as `2/5` with colored squares that fill as units are assigned
|
||||
- **Estimated Units**: Separate planning number independent of actual location count; shown prominently on job cards
|
||||
- **Fleet Summary panel**: Unit counts as clickable filter buttons; unit list shows reservation badges with job name, dates, and color
|
||||
- **Available Units panel**: Shows units available for the job's date range when assigning
|
||||
- **Smart color picker**: 18-swatch palette + custom color wheel; new jobs auto-pick a color maximally distant in hue from existing jobs
|
||||
- **Job card progress**: `est. N · X/Y (Z more)` with filled/empty squares; amber → green when fully assigned
|
||||
- **Promote to Project**: Promote a planned job to a tracked project directly from the planner form
|
||||
- **Collapsible job details**: Name, dates, device type, color, project link, and estimated units collapse into a summary header
|
||||
- **Calendar bar tooltips**: Hover any job bar to see job name and date range
|
||||
- **Hash-based tab persistence**: `#cal` in URL restores Calendar tab on refresh; device type toggle preserves active tab
|
||||
- **Auto-scroll to today**: Switching to Calendar tab smooth-scrolls to the current month
|
||||
- **Upcoming project status**: New `upcoming` status for projects promoted from reservations
|
||||
- **Job device type**: Reservations carry a device type so they only appear on the correct calendar
|
||||
- **Project filtering by device type**: Projects only appear on the calendar matching their type (vibration → seismograph, sound → SLM, combined → both)
|
||||
- **Confirmed/Planned toggles**: Independent show/hide toggles for job bar layers on the calendar
|
||||
- **Cal expire dots toggle**: Calibration expiry dots off by default, togglable
|
||||
|
||||
### Changed
|
||||
- **Renamed**: "Fleet Calendar" / "Reservation Planner" → **"Job Planner"** throughout UI and sidebar
|
||||
- **Project status dropdown**: Inline `<select>` in project header for quick status changes
|
||||
- **"All Projects" tab**: Shows everything except deleted; default view excludes archived/completed
|
||||
- **Toast notifications**: All `alert()` dialogs replaced with non-blocking toasts (green = success, red = error)
|
||||
|
||||
### Migration Notes
|
||||
Run on each database before deploying:
|
||||
```bash
|
||||
docker compose exec terra-view python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/app/data/seismo_fleet.db')
|
||||
conn.execute('ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.8.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Terra-View v0.8.0
|
||||
# Terra-View v0.9.2
|
||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.8.0"
|
||||
VERSION = "0.9.2"
|
||||
if ENVIRONMENT == "development":
|
||||
_build = os.getenv("BUILD_NUMBER", "0")
|
||||
if _build and _build != "0":
|
||||
@@ -126,6 +126,10 @@ app.include_router(recurring_schedules.router)
|
||||
from backend.routers import fleet_calendar
|
||||
app.include_router(fleet_calendar.router)
|
||||
|
||||
# Deployment Records router
|
||||
from backend.routers import deployments
|
||||
app.include_router(deployments.router)
|
||||
|
||||
# Start scheduler service and device status monitor on application startup
|
||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
||||
@@ -703,6 +707,33 @@ async def devices_all_partial(request: Request):
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add allocated units
|
||||
for unit_id, unit_data in snapshot.get("allocated", {}).items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": "Allocated",
|
||||
"age": "N/A",
|
||||
"last_seen": "N/A",
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"out_for_calibration": False,
|
||||
"allocated": True,
|
||||
"allocated_to_project_id": unit_data.get("allocated_to_project_id", ""),
|
||||
"ignored": False,
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add out-for-calibration units
|
||||
for unit_id, unit_data in snapshot["out_for_calibration"].items():
|
||||
units_list.append({
|
||||
@@ -780,17 +811,19 @@ async def devices_all_partial(request: Request):
|
||||
|
||||
# Sort by status category, then by ID
|
||||
def sort_key(unit):
|
||||
# Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored
|
||||
# Priority: deployed (active) -> allocated -> benched -> out_for_calibration -> retired -> ignored
|
||||
if unit["deployed"]:
|
||||
return (0, unit["id"])
|
||||
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
|
||||
elif unit.get("allocated"):
|
||||
return (1, unit["id"])
|
||||
elif unit["out_for_calibration"]:
|
||||
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
|
||||
return (2, unit["id"])
|
||||
elif unit["retired"]:
|
||||
elif unit["out_for_calibration"]:
|
||||
return (3, unit["id"])
|
||||
else:
|
||||
elif unit["retired"]:
|
||||
return (4, unit["id"])
|
||||
else:
|
||||
return (5, unit["id"])
|
||||
|
||||
units_list.sort(key=sort_key)
|
||||
|
||||
|
||||
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()
|
||||
62
backend/migrate_add_estimated_units.py
Normal file
62
backend/migrate_add_estimated_units.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Migration: Add estimated_units to job_reservations
|
||||
|
||||
Adds column:
|
||||
- job_reservations.estimated_units: Estimated number of units for the reservation (nullable integer)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Default database path (matches production pattern)
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
|
||||
def migrate(db_path: str):
|
||||
"""Run the migration."""
|
||||
print(f"Migrating database: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if job_reservations table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
|
||||
if not cursor.fetchone():
|
||||
print("job_reservations table does not exist. Skipping migration.")
|
||||
return
|
||||
|
||||
# Get existing columns in job_reservations
|
||||
cursor.execute("PRAGMA table_info(job_reservations)")
|
||||
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
# Add estimated_units column if it doesn't exist
|
||||
if 'estimated_units' not in existing_cols:
|
||||
print("Adding estimated_units column to job_reservations...")
|
||||
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER")
|
||||
else:
|
||||
print("estimated_units column already exists. Skipping.")
|
||||
|
||||
conn.commit()
|
||||
print("Migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
db_path = DB_PATH
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
db_path = sys.argv[1]
|
||||
|
||||
if not Path(db_path).exists():
|
||||
print(f"Database not found: {db_path}")
|
||||
sys.exit(1)
|
||||
|
||||
migrate(db_path)
|
||||
24
backend/migrate_add_location_slots.py
Normal file
24
backend/migrate_add_location_slots.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Migration: Add location_slots column to job_reservations table.
|
||||
Stores the full ordered slot list (including empty/unassigned slots) as JSON.
|
||||
Run once per database.
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/seismo_fleet.db")
|
||||
|
||||
def run():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
existing = [r[1] for r in cursor.execute("PRAGMA table_info(job_reservations)").fetchall()]
|
||||
if "location_slots" not in existing:
|
||||
cursor.execute("ALTER TABLE job_reservations ADD COLUMN location_slots TEXT")
|
||||
conn.commit()
|
||||
print("Added location_slots column to job_reservations.")
|
||||
else:
|
||||
print("location_slots column already exists, skipping.")
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -33,6 +33,8 @@ class RosterUnit(Base):
|
||||
deployed = Column(Boolean, default=True)
|
||||
retired = Column(Boolean, default=False)
|
||||
out_for_calibration = Column(Boolean, default=False)
|
||||
allocated = Column(Boolean, default=False) # Staged for an upcoming job, not yet deployed
|
||||
allocated_to_project_id = Column(String, nullable=True) # Which project it's allocated to
|
||||
note = Column(String, nullable=True)
|
||||
project_id = Column(String, nullable=True)
|
||||
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
||||
@@ -448,6 +450,41 @@ class Alert(Base):
|
||||
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Deployment Records
|
||||
# ============================================================================
|
||||
|
||||
class DeploymentRecord(Base):
|
||||
"""
|
||||
Deployment records: tracks each time a unit is sent to the field and returned.
|
||||
|
||||
Each row represents one deployment. The active deployment is the record
|
||||
with actual_removal_date IS NULL. The fleet calendar uses this to show
|
||||
units as "In Field" and surface their expected return date.
|
||||
|
||||
project_ref is a freeform string for legacy/vibration jobs like "Fay I-80".
|
||||
project_id will be populated once those jobs are migrated to proper Project records.
|
||||
"""
|
||||
__tablename__ = "deployment_records"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||
|
||||
deployed_date = Column(Date, nullable=True) # When unit left the yard
|
||||
estimated_removal_date = Column(Date, nullable=True) # Expected return date
|
||||
actual_removal_date = Column(Date, nullable=True) # Filled in when returned; NULL = still out
|
||||
|
||||
# Project linkage: freeform for legacy jobs, FK for proper project records
|
||||
project_ref = Column(String, nullable=True) # e.g. "Fay I-80" (vibration jobs)
|
||||
project_id = Column(String, nullable=True, index=True) # FK to Project.id (when available)
|
||||
|
||||
location_name = Column(String, nullable=True) # e.g. "North Gate", "VP-001"
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fleet Calendar & Job Reservations
|
||||
# ============================================================================
|
||||
@@ -480,6 +517,11 @@ class JobReservation(Base):
|
||||
# For quantity reservations
|
||||
device_type = Column(String, default="seismograph") # seismograph | slm
|
||||
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
|
||||
estimated_units = Column(Integer, nullable=True)
|
||||
|
||||
# Full slot list as JSON: [{"location_name": "North Gate", "unit_id": null}, ...]
|
||||
# Includes empty slots (no unit assigned yet). Filled slots are authoritative in JobReservationUnit.
|
||||
location_slots = Column(Text, nullable=True)
|
||||
|
||||
# Metadata
|
||||
notes = Column(Text, nullable=True)
|
||||
@@ -515,3 +557,10 @@ class JobReservationUnit(Base):
|
||||
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
|
||||
|
||||
# Power requirements for this deployment slot
|
||||
power_type = Column(String, nullable=True) # "ac" | "solar" | None
|
||||
|
||||
# Location identity
|
||||
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
||||
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
|
||||
|
||||
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}
|
||||
@@ -19,7 +19,7 @@ import logging
|
||||
from backend.database import get_db
|
||||
from backend.models import (
|
||||
RosterUnit, JobReservation, JobReservationUnit,
|
||||
UserPreferences, Project
|
||||
UserPreferences, Project, MonitoringLocation, UnitAssignment
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
from backend.services.fleet_calendar_service import (
|
||||
@@ -61,9 +61,53 @@ async def fleet_calendar_page(
|
||||
|
||||
# Get projects for the reservation form dropdown
|
||||
projects = db.query(Project).filter(
|
||||
Project.status == "active"
|
||||
Project.status.in_(["active", "upcoming", "on_hold"])
|
||||
).order_by(Project.name).all()
|
||||
|
||||
# Build a serializable list of items with dates for calendar bars
|
||||
# Includes both tracked Projects (with dates) and Job Reservations (matching device_type)
|
||||
project_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316']
|
||||
# Map calendar device_type to project_type_ids
|
||||
device_type_to_project_types = {
|
||||
"seismograph": ["vibration_monitoring", "combined"],
|
||||
"slm": ["sound_monitoring", "combined"],
|
||||
}
|
||||
relevant_project_types = device_type_to_project_types.get(device_type, [])
|
||||
|
||||
calendar_projects = []
|
||||
for i, p in enumerate(projects):
|
||||
if p.start_date and p.project_type_id in relevant_project_types:
|
||||
calendar_projects.append({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"start_date": p.start_date.isoformat(),
|
||||
"end_date": p.end_date.isoformat() if p.end_date else None,
|
||||
"color": project_colors[i % len(project_colors)],
|
||||
"confirmed": True,
|
||||
})
|
||||
|
||||
# Add job reservations for this device_type as bars
|
||||
from sqlalchemy import or_ as _or
|
||||
cal_window_end = date(year + ((month + 10) // 12), ((month + 10) % 12) + 1, 1)
|
||||
reservations_for_cal = db.query(JobReservation).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.start_date <= cal_window_end,
|
||||
_or(
|
||||
JobReservation.end_date >= date(year, month, 1),
|
||||
JobReservation.end_date == None,
|
||||
)
|
||||
).all()
|
||||
for res in reservations_for_cal:
|
||||
end = res.end_date or res.estimated_end_date
|
||||
calendar_projects.append({
|
||||
"id": res.id,
|
||||
"name": res.name,
|
||||
"start_date": res.start_date.isoformat(),
|
||||
"end_date": end.isoformat() if end else None,
|
||||
"color": res.color,
|
||||
"confirmed": bool(res.project_id),
|
||||
})
|
||||
|
||||
# Calculate prev/next month navigation
|
||||
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
@@ -81,6 +125,7 @@ async def fleet_calendar_page(
|
||||
"device_type": device_type,
|
||||
"calendar_data": calendar_data,
|
||||
"projects": projects,
|
||||
"calendar_projects": calendar_projects,
|
||||
"today": today.isoformat()
|
||||
}
|
||||
)
|
||||
@@ -167,6 +212,7 @@ async def create_reservation(
|
||||
if estimated_end_date and estimated_end_date < start_date:
|
||||
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
|
||||
|
||||
import json as _json
|
||||
reservation = JobReservation(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data["name"],
|
||||
@@ -178,6 +224,8 @@ async def create_reservation(
|
||||
assignment_type=data["assignment_type"],
|
||||
device_type=data.get("device_type", "seismograph"),
|
||||
quantity_needed=data.get("quantity_needed"),
|
||||
estimated_units=data.get("estimated_units"),
|
||||
location_slots=_json.dumps(data["location_slots"]) if data.get("location_slots") is not None else None,
|
||||
notes=data.get("notes"),
|
||||
color=data.get("color", "#3B82F6")
|
||||
)
|
||||
@@ -221,8 +269,16 @@ async def get_reservation(
|
||||
reservation_id=reservation_id
|
||||
).all()
|
||||
|
||||
unit_ids = [a.unit_id for a in assignments]
|
||||
# Sort assignments by slot_index so order is preserved
|
||||
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||
units_by_id = {u.id: u for u in units}
|
||||
# Build per-unit lookups from assignments
|
||||
assignment_map = {a.unit_id: a for a in assignments_sorted}
|
||||
|
||||
import json as _json
|
||||
stored_slots = _json.loads(reservation.location_slots) if reservation.location_slots else None
|
||||
|
||||
return {
|
||||
"id": reservation.id,
|
||||
@@ -235,15 +291,21 @@ async def get_reservation(
|
||||
"assignment_type": reservation.assignment_type,
|
||||
"device_type": reservation.device_type,
|
||||
"quantity_needed": reservation.quantity_needed,
|
||||
"estimated_units": reservation.estimated_units,
|
||||
"location_slots": stored_slots,
|
||||
"notes": reservation.notes,
|
||||
"color": reservation.color,
|
||||
"assigned_units": [
|
||||
{
|
||||
"id": u.id,
|
||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||
"deployed": u.deployed
|
||||
"id": uid,
|
||||
"last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None,
|
||||
"deployed": units_by_id[uid].deployed if uid in units_by_id else False,
|
||||
"power_type": assignment_map[uid].power_type,
|
||||
"notes": assignment_map[uid].notes,
|
||||
"location_name": assignment_map[uid].location_name,
|
||||
"slot_index": assignment_map[uid].slot_index,
|
||||
}
|
||||
for u in units
|
||||
for uid in unit_ids
|
||||
]
|
||||
}
|
||||
|
||||
@@ -278,6 +340,11 @@ async def update_reservation(
|
||||
reservation.assignment_type = data["assignment_type"]
|
||||
if "quantity_needed" in data:
|
||||
reservation.quantity_needed = data["quantity_needed"]
|
||||
if "estimated_units" in data:
|
||||
reservation.estimated_units = data["estimated_units"]
|
||||
if "location_slots" in data:
|
||||
import json as _json
|
||||
reservation.location_slots = _json.dumps(data["location_slots"]) if data["location_slots"] is not None else None
|
||||
if "notes" in data:
|
||||
reservation.notes = data["notes"]
|
||||
if "color" in data:
|
||||
@@ -337,52 +404,57 @@ async def assign_units_to_reservation(
|
||||
|
||||
data = await request.json()
|
||||
unit_ids = data.get("unit_ids", [])
|
||||
# Optional per-unit dicts keyed by unit_id
|
||||
power_types = data.get("power_types", {})
|
||||
location_notes = data.get("location_notes", {})
|
||||
location_names = data.get("location_names", {})
|
||||
# slot_indices: {"BE17354": 0, "BE9441": 1, ...}
|
||||
slot_indices = data.get("slot_indices", {})
|
||||
|
||||
if not unit_ids:
|
||||
raise HTTPException(status_code=400, detail="No units specified")
|
||||
# Verify units exist (allow empty list to clear all assignments)
|
||||
if unit_ids:
|
||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
||||
found_ids = {u.id for u in units}
|
||||
missing = set(unit_ids) - found_ids
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
||||
|
||||
# Verify units exist
|
||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
||||
found_ids = {u.id for u in units}
|
||||
missing = set(unit_ids) - found_ids
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
||||
# Full replace: delete all existing assignments for this reservation first
|
||||
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
|
||||
db.flush()
|
||||
|
||||
# Check for conflicts (already assigned to overlapping reservations)
|
||||
# Check for conflicts with other reservations and insert new assignments
|
||||
conflicts = []
|
||||
for unit_id in unit_ids:
|
||||
# Check if unit is already assigned to this reservation
|
||||
existing = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=reservation_id,
|
||||
unit_id=unit_id
|
||||
).first()
|
||||
if existing:
|
||||
continue # Already assigned, skip
|
||||
|
||||
# Check overlapping reservations
|
||||
overlapping = db.query(JobReservation).join(
|
||||
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
||||
).filter(
|
||||
JobReservationUnit.unit_id == unit_id,
|
||||
JobReservation.id != reservation_id,
|
||||
JobReservation.start_date <= reservation.end_date,
|
||||
JobReservation.end_date >= reservation.start_date
|
||||
).first()
|
||||
if reservation.end_date:
|
||||
overlapping = db.query(JobReservation).join(
|
||||
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
||||
).filter(
|
||||
JobReservationUnit.unit_id == unit_id,
|
||||
JobReservation.id != reservation_id,
|
||||
JobReservation.start_date <= reservation.end_date,
|
||||
JobReservation.end_date >= reservation.start_date
|
||||
).first()
|
||||
|
||||
if overlapping:
|
||||
conflicts.append({
|
||||
"unit_id": unit_id,
|
||||
"conflict_reservation": overlapping.name,
|
||||
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
||||
})
|
||||
continue
|
||||
if overlapping:
|
||||
conflicts.append({
|
||||
"unit_id": unit_id,
|
||||
"conflict_reservation": overlapping.name,
|
||||
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
||||
})
|
||||
continue
|
||||
|
||||
# Add assignment
|
||||
assignment = JobReservationUnit(
|
||||
id=str(uuid.uuid4()),
|
||||
reservation_id=reservation_id,
|
||||
unit_id=unit_id,
|
||||
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific"
|
||||
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
|
||||
power_type=power_types.get(unit_id),
|
||||
notes=location_notes.get(unit_id),
|
||||
location_name=location_names.get(unit_id),
|
||||
slot_index=slot_indices.get(unit_id),
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
@@ -511,7 +583,7 @@ async def get_reservations_list(
|
||||
else:
|
||||
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
||||
|
||||
# Include TBD reservations that started before window end
|
||||
# Filter by device_type and date window
|
||||
reservations = db.query(JobReservation).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.start_date <= end_date,
|
||||
@@ -524,16 +596,38 @@ async def get_reservations_list(
|
||||
# Get assignment counts
|
||||
reservation_data = []
|
||||
for res in reservations:
|
||||
assigned_count = db.query(JobReservationUnit).filter_by(
|
||||
assignments = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=res.id
|
||||
).count()
|
||||
).all()
|
||||
assigned_count = len(assignments)
|
||||
|
||||
# Enrich assignments with unit details, sorted by slot_index
|
||||
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||
units_by_id = {u.id: u for u in units}
|
||||
assigned_units = [
|
||||
{
|
||||
"id": a.unit_id,
|
||||
"power_type": a.power_type,
|
||||
"notes": a.notes,
|
||||
"location_name": a.location_name,
|
||||
"slot_index": a.slot_index,
|
||||
"deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False,
|
||||
"last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None,
|
||||
}
|
||||
for a in assignments_sorted
|
||||
]
|
||||
|
||||
# Check for calibration conflicts
|
||||
conflicts = check_calibration_conflicts(db, res.id)
|
||||
|
||||
location_count = res.quantity_needed or assigned_count
|
||||
reservation_data.append({
|
||||
"reservation": res,
|
||||
"assigned_count": assigned_count,
|
||||
"location_count": location_count,
|
||||
"assigned_units": assigned_units,
|
||||
"has_conflicts": len(conflicts) > 0,
|
||||
"conflict_count": len(conflicts)
|
||||
})
|
||||
@@ -549,6 +643,131 @@ async def get_reservations_list(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/planner-availability", response_class=JSONResponse)
|
||||
async def get_planner_availability(
|
||||
device_type: str = "seismograph",
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
exclude_reservation_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get available units for the reservation planner split-panel UI.
|
||||
Dates are optional — if omitted, returns all non-retired units regardless of reservations.
|
||||
"""
|
||||
if start_date and end_date:
|
||||
try:
|
||||
start = date.fromisoformat(start_date)
|
||||
end = date.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
|
||||
else:
|
||||
# No dates: return all non-retired units of this type, with current reservation info
|
||||
from backend.models import RosterUnit as RU
|
||||
from datetime import timedelta
|
||||
today = date.today()
|
||||
all_units = db.query(RU).filter(
|
||||
RU.device_type == device_type,
|
||||
RU.retired == False
|
||||
).all()
|
||||
|
||||
# Build a map: unit_id -> list of active/upcoming reservations
|
||||
active_assignments = db.query(JobReservationUnit).join(
|
||||
JobReservation, JobReservationUnit.reservation_id == JobReservation.id
|
||||
).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.end_date >= today
|
||||
).all()
|
||||
unit_reservations = {}
|
||||
for assignment in active_assignments:
|
||||
res = db.query(JobReservation).filter(JobReservation.id == assignment.reservation_id).first()
|
||||
if not res:
|
||||
continue
|
||||
unit_reservations.setdefault(assignment.unit_id, []).append({
|
||||
"reservation_id": res.id,
|
||||
"reservation_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,
|
||||
"color": res.color or "#3B82F6"
|
||||
})
|
||||
|
||||
units = []
|
||||
for u in all_units:
|
||||
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||
units.append({
|
||||
"id": u.id,
|
||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||
"expiry_date": expiry.isoformat() if expiry else None,
|
||||
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
|
||||
"deployed": u.deployed,
|
||||
"out_for_calibration": u.out_for_calibration or False,
|
||||
"allocated": getattr(u, 'allocated', False) or False,
|
||||
"allocated_to_project_id": getattr(u, 'allocated_to_project_id', None) or "",
|
||||
"note": u.note or "",
|
||||
"reservations": unit_reservations.get(u.id, [])
|
||||
})
|
||||
|
||||
# Sort: benched first (easier to assign), then deployed, then by ID
|
||||
units.sort(key=lambda u: (1 if u["deployed"] else 0, u["id"]))
|
||||
|
||||
return {
|
||||
"units": units,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"count": len(units)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/unit-quick-info/{unit_id}", response_class=JSONResponse)
|
||||
async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Return at-a-glance info for the planner quick-view modal."""
|
||||
from backend.models import Emitter
|
||||
u = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
today = date.today()
|
||||
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||
|
||||
# Active/upcoming reservations
|
||||
assignments = db.query(JobReservationUnit).filter(JobReservationUnit.unit_id == unit_id).all()
|
||||
reservations = []
|
||||
for a in assignments:
|
||||
res = db.query(JobReservation).filter(
|
||||
JobReservation.id == a.reservation_id,
|
||||
JobReservation.end_date >= today
|
||||
).first()
|
||||
if res:
|
||||
reservations.append({
|
||||
"name": res.name,
|
||||
"start_date": res.start_date.isoformat() if res.start_date else None,
|
||||
"end_date": res.end_date.isoformat() if res.end_date else None,
|
||||
"end_date_tbd": res.end_date_tbd,
|
||||
"color": res.color or "#3B82F6",
|
||||
"location_name": a.location_name,
|
||||
})
|
||||
|
||||
# Last seen from emitter
|
||||
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
||||
|
||||
return {
|
||||
"id": u.id,
|
||||
"unit_type": u.unit_type,
|
||||
"deployed": u.deployed,
|
||||
"out_for_calibration": u.out_for_calibration or False,
|
||||
"note": u.note or "",
|
||||
"project_id": u.project_id or "",
|
||||
"address": u.address or u.location or "",
|
||||
"coordinates": u.coordinates or "",
|
||||
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||
"cal_expired": not u.last_calibrated or (expiry and expiry < today),
|
||||
"last_seen": emitter.last_seen.isoformat() if emitter and emitter.last_seen else None,
|
||||
"reservations": reservations,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
||||
async def get_available_units_partial(
|
||||
request: Request,
|
||||
@@ -608,3 +827,102 @@ async def get_month_partial(
|
||||
"today": date.today().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Promote Reservation to Project
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/api/fleet-calendar/reservations/{reservation_id}/promote-to-project", response_class=JSONResponse)
|
||||
async def promote_reservation_to_project(
|
||||
reservation_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Promote a job reservation to a full project in the projects DB.
|
||||
Creates: Project + MonitoringLocations + UnitAssignments.
|
||||
"""
|
||||
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
|
||||
if not reservation:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
data = await request.json()
|
||||
project_number = data.get("project_number") or None
|
||||
client_name = data.get("client_name") or None
|
||||
|
||||
# Map device_type to project_type_id
|
||||
if reservation.device_type == "slm":
|
||||
project_type_id = "sound_monitoring"
|
||||
location_type = "sound"
|
||||
else:
|
||||
project_type_id = "vibration_monitoring"
|
||||
location_type = "vibration"
|
||||
|
||||
# Check for duplicate project name
|
||||
existing = db.query(Project).filter_by(name=reservation.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=f"A project named '{reservation.name}' already exists.")
|
||||
|
||||
# Create the project
|
||||
project_id = str(uuid.uuid4())
|
||||
project = Project(
|
||||
id=project_id,
|
||||
name=reservation.name,
|
||||
project_number=project_number,
|
||||
client_name=client_name,
|
||||
project_type_id=project_type_id,
|
||||
status="upcoming",
|
||||
start_date=reservation.start_date,
|
||||
end_date=reservation.end_date,
|
||||
description=reservation.notes,
|
||||
)
|
||||
db.add(project)
|
||||
db.flush()
|
||||
|
||||
# Load assignments sorted by slot_index
|
||||
assignments = db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).all()
|
||||
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||
|
||||
locations_created = 0
|
||||
units_assigned = 0
|
||||
|
||||
for i, assignment in enumerate(assignments_sorted):
|
||||
loc_num = str(i + 1).zfill(3)
|
||||
loc_name = assignment.location_name or f"Location {i + 1}"
|
||||
|
||||
location = MonitoringLocation(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
location_type=location_type,
|
||||
name=loc_name,
|
||||
description=assignment.notes,
|
||||
)
|
||||
db.add(location)
|
||||
db.flush()
|
||||
locations_created += 1
|
||||
|
||||
if assignment.unit_id:
|
||||
unit_assignment = UnitAssignment(
|
||||
id=str(uuid.uuid4()),
|
||||
unit_id=assignment.unit_id,
|
||||
location_id=location.id,
|
||||
project_id=project_id,
|
||||
device_type=reservation.device_type or "seismograph",
|
||||
status="active",
|
||||
notes=f"Power: {assignment.power_type}" if assignment.power_type else None,
|
||||
)
|
||||
db.add(unit_assignment)
|
||||
units_assigned += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Promoted reservation '{reservation.name}' to project {project_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"project_id": project_id,
|
||||
"project_name": reservation.name,
|
||||
"locations_created": locations_created,
|
||||
"units_assigned": units_assigned,
|
||||
}
|
||||
|
||||
@@ -373,11 +373,13 @@ async def get_projects_list(
|
||||
"""
|
||||
query = db.query(Project)
|
||||
|
||||
# Filter by status if provided; otherwise exclude soft-deleted projects
|
||||
if status:
|
||||
# Filter by status if provided; otherwise exclude archived/deleted from default view
|
||||
if status == "all":
|
||||
query = query.filter(Project.status != "deleted")
|
||||
elif status:
|
||||
query = query.filter(Project.status == status)
|
||||
else:
|
||||
query = query.filter(Project.status != "deleted")
|
||||
query = query.filter(Project.status.notin_(["deleted", "archived", "completed"]))
|
||||
|
||||
# Filter by project type if provided
|
||||
if project_type_id:
|
||||
@@ -438,6 +440,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
# Count projects by status (exclude deleted)
|
||||
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
|
||||
upcoming_projects = db.query(func.count(Project.id)).filter_by(status="upcoming").scalar()
|
||||
active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar()
|
||||
on_hold_projects = db.query(func.count(Project.id)).filter_by(status="on_hold").scalar()
|
||||
completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()
|
||||
@@ -459,6 +462,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"request": request,
|
||||
"total_projects": total_projects,
|
||||
"active_projects": active_projects,
|
||||
"upcoming_projects": upcoming_projects,
|
||||
"on_hold_projects": on_hold_projects,
|
||||
"completed_projects": completed_projects,
|
||||
"total_locations": total_locations,
|
||||
|
||||
@@ -9,7 +9,8 @@ import httpx
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
||||
import uuid
|
||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||
|
||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||
@@ -27,6 +28,38 @@ def get_calibration_interval(db: Session) -> int:
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
def sync_deployment_record(db: Session, unit: RosterUnit, new_deployed: bool):
|
||||
"""
|
||||
Keep DeploymentRecord in sync with the deployed flag.
|
||||
|
||||
deployed True → open a new DeploymentRecord if none is already open.
|
||||
deployed False → close the active DeploymentRecord by setting actual_removal_date = today.
|
||||
"""
|
||||
if new_deployed:
|
||||
existing = db.query(DeploymentRecord).filter(
|
||||
DeploymentRecord.unit_id == unit.id,
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).first()
|
||||
if not existing:
|
||||
record = DeploymentRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
unit_id=unit.id,
|
||||
project_ref=unit.project_id or None,
|
||||
deployed_date=date.today(),
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(record)
|
||||
else:
|
||||
active = db.query(DeploymentRecord).filter(
|
||||
DeploymentRecord.unit_id == unit.id,
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).first()
|
||||
if active:
|
||||
active.actual_removal_date = date.today()
|
||||
active.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
||||
"""Helper function to record a change in unit history"""
|
||||
@@ -467,6 +500,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"deployed": unit.deployed,
|
||||
"retired": unit.retired,
|
||||
"out_for_calibration": unit.out_for_calibration or False,
|
||||
"allocated": getattr(unit, 'allocated', False) or False,
|
||||
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
||||
"note": unit.note or "",
|
||||
"project_id": unit.project_id or "",
|
||||
"location": unit.location or "",
|
||||
@@ -499,6 +534,8 @@ async def edit_roster_unit(
|
||||
deployed: str = Form(None),
|
||||
retired: str = Form(None),
|
||||
out_for_calibration: str = Form(None),
|
||||
allocated: str = Form(None),
|
||||
allocated_to_project_id: str = Form(None),
|
||||
note: str = Form(""),
|
||||
project_id: str = Form(None),
|
||||
location: str = Form(None),
|
||||
@@ -541,6 +578,7 @@ async def edit_roster_unit(
|
||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
|
||||
allocated_bool = allocated in ['true', 'True', '1', 'yes'] if allocated else False
|
||||
|
||||
# Convert port strings to integers
|
||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||
@@ -578,6 +616,8 @@ async def edit_roster_unit(
|
||||
unit.deployed = deployed_bool
|
||||
unit.retired = retired_bool
|
||||
unit.out_for_calibration = out_for_calibration_bool
|
||||
unit.allocated = allocated_bool
|
||||
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
||||
unit.note = note
|
||||
unit.project_id = project_id
|
||||
unit.location = location
|
||||
@@ -679,6 +719,7 @@ async def edit_roster_unit(
|
||||
status_text = "deployed" if deployed else "benched"
|
||||
old_status_text = "deployed" if old_deployed else "benched"
|
||||
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
||||
sync_deployment_record(db, unit, deployed_bool)
|
||||
|
||||
if old_retired != retired:
|
||||
status_text = "retired" if retired else "active"
|
||||
@@ -795,6 +836,7 @@ async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = D
|
||||
new_value=status_text,
|
||||
source="manual"
|
||||
)
|
||||
sync_deployment_record(db, unit, deployed)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ Seismograph Dashboard API Router
|
||||
Provides endpoints for the seismograph-specific dashboard
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Query
|
||||
from fastapi import APIRouter, Request, Depends, Query, Form, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.models import RosterUnit, UnitHistory, UserPreferences
|
||||
from backend.templates_config import templates
|
||||
|
||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||
@@ -120,3 +120,109 @@ async def get_seismo_units(
|
||||
"today": date.today()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_calibration_interval(db: Session) -> int:
|
||||
prefs = db.query(UserPreferences).first()
|
||||
if prefs and prefs.calibration_interval_days:
|
||||
return prefs.calibration_interval_days
|
||||
return 365
|
||||
|
||||
|
||||
def _row_context(request: Request, unit: RosterUnit) -> dict:
|
||||
return {"request": request, "unit": unit, "today": date.today()}
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}/view-row", response_class=HTMLResponse)
|
||||
async def get_seismo_view_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}/edit-row", response_class=HTMLResponse)
|
||||
async def get_seismo_edit_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
return templates.TemplateResponse("partials/seismo_row_edit.html", _row_context(request, unit))
|
||||
|
||||
|
||||
@router.post("/unit/{unit_id}/quick-update", response_class=HTMLResponse)
|
||||
async def quick_update_seismo_unit(
|
||||
unit_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
status: str = Form(...),
|
||||
last_calibrated: str = Form(""),
|
||||
note: str = Form(""),
|
||||
):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
# --- Status ---
|
||||
old_deployed = unit.deployed
|
||||
old_out_for_cal = unit.out_for_calibration
|
||||
if status == "deployed":
|
||||
unit.deployed = True
|
||||
unit.out_for_calibration = False
|
||||
elif status == "out_for_calibration":
|
||||
unit.deployed = False
|
||||
unit.out_for_calibration = True
|
||||
else:
|
||||
unit.deployed = False
|
||||
unit.out_for_calibration = False
|
||||
|
||||
if unit.deployed != old_deployed or unit.out_for_calibration != old_out_for_cal:
|
||||
old_status = "deployed" if old_deployed else ("out_for_calibration" if old_out_for_cal else "benched")
|
||||
db.add(UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type="deployed_change",
|
||||
field_name="status",
|
||||
old_value=old_status,
|
||||
new_value=status,
|
||||
source="manual",
|
||||
))
|
||||
|
||||
# --- Last calibrated ---
|
||||
old_cal = unit.last_calibrated
|
||||
if last_calibrated:
|
||||
try:
|
||||
new_cal = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
unit.last_calibrated = new_cal
|
||||
unit.next_calibration_due = new_cal + timedelta(days=_get_calibration_interval(db))
|
||||
else:
|
||||
unit.last_calibrated = None
|
||||
unit.next_calibration_due = None
|
||||
|
||||
if unit.last_calibrated != old_cal:
|
||||
db.add(UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type="calibration_status_change",
|
||||
field_name="last_calibrated",
|
||||
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
|
||||
new_value=last_calibrated or None,
|
||||
source="manual",
|
||||
))
|
||||
|
||||
# --- Note ---
|
||||
old_note = unit.note
|
||||
unit.note = note or None
|
||||
if unit.note != old_note:
|
||||
db.add(UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type="note_change",
|
||||
field_name="note",
|
||||
old_value=old_note,
|
||||
new_value=unit.note,
|
||||
source="manual",
|
||||
))
|
||||
|
||||
db.commit()
|
||||
db.refresh(unit)
|
||||
|
||||
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
|
||||
|
||||
@@ -267,7 +267,8 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
payload = await request.json()
|
||||
|
||||
source = payload.get("source", "series4_emitter")
|
||||
# Accept source_id (new standard field) with fallback to legacy "source" key
|
||||
source = payload.get("source_id") or payload.get("source", "series4_emitter")
|
||||
units = payload.get("units", [])
|
||||
version = payload.get("version")
|
||||
log_tail = payload.get("log_tail")
|
||||
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy import and_, or_
|
||||
|
||||
from backend.models import (
|
||||
RosterUnit, JobReservation, JobReservationUnit,
|
||||
UserPreferences, Project
|
||||
UserPreferences, Project, DeploymentRecord
|
||||
)
|
||||
|
||||
|
||||
@@ -70,6 +70,19 @@ def get_unit_reservations_on_date(
|
||||
return reservations
|
||||
|
||||
|
||||
def get_active_deployment(db: Session, unit_id: str) -> Optional[DeploymentRecord]:
|
||||
"""Return the active (unreturned) deployment record for a unit, or None."""
|
||||
return (
|
||||
db.query(DeploymentRecord)
|
||||
.filter(
|
||||
DeploymentRecord.unit_id == unit_id,
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
)
|
||||
.order_by(DeploymentRecord.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def is_unit_available_on_date(
|
||||
db: Session,
|
||||
unit: RosterUnit,
|
||||
@@ -82,8 +95,8 @@ def is_unit_available_on_date(
|
||||
Returns:
|
||||
(is_available, status, reservation_name)
|
||||
- is_available: True if unit can be assigned to new work
|
||||
- status: "available", "reserved", "expired", "retired", "needs_calibration"
|
||||
- reservation_name: Name of blocking reservation (if any)
|
||||
- status: "available", "reserved", "expired", "retired", "needs_calibration", "in_field"
|
||||
- reservation_name: Name of blocking reservation or project ref (if any)
|
||||
"""
|
||||
# Check if retired
|
||||
if unit.retired:
|
||||
@@ -96,6 +109,12 @@ def is_unit_available_on_date(
|
||||
if cal_status == "needs_calibration":
|
||||
return False, "needs_calibration", None
|
||||
|
||||
# Check for an active deployment record (unit is physically in the field)
|
||||
active_deployment = get_active_deployment(db, unit.id)
|
||||
if active_deployment:
|
||||
label = active_deployment.project_ref or "Field deployment"
|
||||
return False, "in_field", label
|
||||
|
||||
# Check if already reserved
|
||||
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
|
||||
if reservations:
|
||||
@@ -136,6 +155,7 @@ def get_day_summary(
|
||||
expired_units = []
|
||||
expiring_soon_units = []
|
||||
needs_calibration_units = []
|
||||
in_field_units = []
|
||||
cal_expiring_today = [] # Units whose calibration expires ON this day
|
||||
|
||||
for unit in units:
|
||||
@@ -167,6 +187,9 @@ def get_day_summary(
|
||||
available_units.append(unit_info)
|
||||
if cal_status == "expiring_soon":
|
||||
expiring_soon_units.append(unit_info)
|
||||
elif status == "in_field":
|
||||
unit_info["project_ref"] = reservation_name
|
||||
in_field_units.append(unit_info)
|
||||
elif status == "reserved":
|
||||
unit_info["reservation_name"] = reservation_name
|
||||
reserved_units.append(unit_info)
|
||||
@@ -207,6 +230,7 @@ def get_day_summary(
|
||||
"date": check_date.isoformat(),
|
||||
"device_type": device_type,
|
||||
"available_units": available_units,
|
||||
"in_field_units": in_field_units,
|
||||
"reserved_units": reserved_units,
|
||||
"expired_units": expired_units,
|
||||
"expiring_soon_units": expiring_soon_units,
|
||||
@@ -215,6 +239,7 @@ def get_day_summary(
|
||||
"reservations": reservation_list,
|
||||
"counts": {
|
||||
"available": len(available_units),
|
||||
"in_field": len(in_field_units),
|
||||
"reserved": len(reserved_units),
|
||||
"expired": len(expired_units),
|
||||
"expiring_soon": len(expiring_soon_units),
|
||||
@@ -285,6 +310,14 @@ def get_calendar_year_data(
|
||||
unit_reservations[unit_id] = []
|
||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||
|
||||
# Build set of unit IDs that have an active deployment record (still in the field)
|
||||
unit_ids = [u.id for u in units]
|
||||
active_deployments = db.query(DeploymentRecord.unit_id).filter(
|
||||
DeploymentRecord.unit_id.in_(unit_ids),
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).all()
|
||||
unit_in_field = {row.unit_id for row in active_deployments}
|
||||
|
||||
# Generate data for each month
|
||||
months_data = {}
|
||||
|
||||
@@ -301,6 +334,7 @@ def get_calendar_year_data(
|
||||
|
||||
while current_day <= last_day:
|
||||
available = 0
|
||||
in_field = 0
|
||||
reserved = 0
|
||||
expired = 0
|
||||
expiring_soon = 0
|
||||
@@ -328,6 +362,11 @@ def get_calendar_year_data(
|
||||
needs_cal += 1
|
||||
continue
|
||||
|
||||
# Check active deployment record (in field)
|
||||
if unit.id in unit_in_field:
|
||||
in_field += 1
|
||||
continue
|
||||
|
||||
# Check if reserved
|
||||
is_reserved = False
|
||||
if unit.id in unit_reservations:
|
||||
@@ -346,6 +385,7 @@ def get_calendar_year_data(
|
||||
|
||||
days_data[current_day.day] = {
|
||||
"available": available,
|
||||
"in_field": in_field,
|
||||
"reserved": reserved,
|
||||
"expired": expired,
|
||||
"expiring_soon": expiring_soon,
|
||||
@@ -462,6 +502,14 @@ def get_rolling_calendar_data(
|
||||
unit_reservations[unit_id] = []
|
||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||
|
||||
# Build set of unit IDs that have an active deployment record (still in the field)
|
||||
unit_ids = [u.id for u in units]
|
||||
active_deployments = db.query(DeploymentRecord.unit_id).filter(
|
||||
DeploymentRecord.unit_id.in_(unit_ids),
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).all()
|
||||
unit_in_field = {row.unit_id for row in active_deployments}
|
||||
|
||||
# Generate data for each of the 12 months
|
||||
months_data = []
|
||||
current_year = start_year
|
||||
@@ -640,28 +688,37 @@ def get_available_units_for_period(
|
||||
for a in assigned:
|
||||
reserved_unit_ids.add(a.unit_id)
|
||||
|
||||
# Get units with active deployment records (still in the field)
|
||||
unit_ids = [u.id for u in units]
|
||||
active_deps = db.query(DeploymentRecord.unit_id).filter(
|
||||
DeploymentRecord.unit_id.in_(unit_ids),
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).all()
|
||||
in_field_unit_ids = {row.unit_id for row in active_deps}
|
||||
|
||||
available_units = []
|
||||
for unit in units:
|
||||
# Check if already reserved
|
||||
if unit.id in reserved_unit_ids:
|
||||
continue
|
||||
# Check if currently in the field
|
||||
if unit.id in in_field_unit_ids:
|
||||
continue
|
||||
|
||||
# Check calibration through end of period
|
||||
if not unit.last_calibrated:
|
||||
continue # Needs calibration
|
||||
|
||||
expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||
if expiry_date <= end_date:
|
||||
continue # Calibration expires during period
|
||||
|
||||
cal_status = get_calibration_status(unit, end_date, warning_days)
|
||||
if unit.last_calibrated:
|
||||
expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||
cal_status = get_calibration_status(unit, end_date, warning_days)
|
||||
else:
|
||||
expiry_date = None
|
||||
cal_status = "needs_calibration"
|
||||
|
||||
available_units.append({
|
||||
"id": unit.id,
|
||||
"last_calibrated": unit.last_calibrated.isoformat(),
|
||||
"expiry_date": expiry_date.isoformat(),
|
||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||
"expiry_date": expiry_date.isoformat() if expiry_date else None,
|
||||
"calibration_status": cal_status,
|
||||
"deployed": unit.deployed,
|
||||
"out_for_calibration": unit.out_for_calibration or False,
|
||||
"note": unit.note or ""
|
||||
})
|
||||
|
||||
|
||||
@@ -86,6 +86,12 @@ def emit_status_snapshot():
|
||||
age = "N/A"
|
||||
last_seen = None
|
||||
fname = ""
|
||||
elif getattr(r, 'allocated', False) and not r.deployed:
|
||||
# Allocated: staged for an upcoming job, not yet physically deployed
|
||||
status = "Allocated"
|
||||
age = "N/A"
|
||||
last_seen = None
|
||||
fname = ""
|
||||
else:
|
||||
if e:
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
@@ -110,6 +116,8 @@ def emit_status_snapshot():
|
||||
"note": r.note or "",
|
||||
"retired": r.retired,
|
||||
"out_for_calibration": r.out_for_calibration or False,
|
||||
"allocated": getattr(r, 'allocated', False) or False,
|
||||
"allocated_to_project_id": getattr(r, 'allocated_to_project_id', None) or "",
|
||||
# Device type and type-specific fields
|
||||
"device_type": r.device_type or "seismograph",
|
||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||
@@ -141,6 +149,8 @@ def emit_status_snapshot():
|
||||
"note": "",
|
||||
"retired": False,
|
||||
"out_for_calibration": False,
|
||||
"allocated": False,
|
||||
"allocated_to_project_id": "",
|
||||
# Device type and type-specific fields (defaults for unknown units)
|
||||
"device_type": "seismograph", # default
|
||||
"last_calibrated": None,
|
||||
@@ -192,7 +202,12 @@ def emit_status_snapshot():
|
||||
|
||||
benched_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored
|
||||
if not u["retired"] and not u["out_for_calibration"] and not u["allocated"] and not u["deployed"] and uid not in ignored
|
||||
}
|
||||
|
||||
allocated_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if not u["retired"] and not u["out_for_calibration"] and u["allocated"] and not u["deployed"] and uid not in ignored
|
||||
}
|
||||
|
||||
retired_units = {
|
||||
@@ -216,13 +231,15 @@ def emit_status_snapshot():
|
||||
"units": units,
|
||||
"active": active_units,
|
||||
"benched": benched_units,
|
||||
"allocated": allocated_units,
|
||||
"retired": retired_units,
|
||||
"out_for_calibration": out_for_calibration_units,
|
||||
"unknown": unknown_units,
|
||||
"summary": {
|
||||
"total": len(active_units) + len(benched_units),
|
||||
"total": len(active_units) + len(benched_units) + len(allocated_units),
|
||||
"active": len(active_units),
|
||||
"benched": len(benched_units),
|
||||
"allocated": len(allocated_units),
|
||||
"retired": len(retired_units),
|
||||
"out_for_calibration": len(out_for_calibration_units),
|
||||
"unknown": len(unknown_units),
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar (Responsive) -->
|
||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||
<!-- Logo -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<a href="/" class="block">
|
||||
@@ -155,7 +155,7 @@
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Fleet Calendar
|
||||
Job Planner
|
||||
</a>
|
||||
|
||||
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
@@ -193,14 +193,14 @@
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main-content flex-1 overflow-y-auto">
|
||||
<div class="p-8">
|
||||
<div class="{% if request.query_params.get('embed') == '1' %}p-4{% else %}p-8{% endif %}">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation (Mobile Only) -->
|
||||
<nav class="bottom-nav">
|
||||
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||
<div class="grid grid-cols-4 h-16">
|
||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
<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>
|
||||
</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">
|
||||
<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">
|
||||
@@ -509,7 +513,7 @@ function renderFilteredDashboard(data) {
|
||||
// Update the Recent Alerts section with filtering
|
||||
function updateAlertsFiltered(filteredActive) {
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing');
|
||||
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing' && u.device_type !== 'modem');
|
||||
|
||||
if (!missingUnits.length) {
|
||||
// Check if this is because of filters or genuinely no alerts
|
||||
@@ -703,6 +707,7 @@ function updateDashboard(event) {
|
||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
||||
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
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-id="{{ unit.id }}"
|
||||
data-type="{{ unit.device_type }}"
|
||||
@@ -62,6 +62,8 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if unit.out_for_calibration %}
|
||||
<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 %}
|
||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||
{% elif unit.status == 'OK' %}
|
||||
@@ -76,6 +78,8 @@
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||
{% elif unit.out_for_calibration %}
|
||||
<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 %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||
{% endif %}
|
||||
@@ -207,7 +211,7 @@
|
||||
<div class="unit-card device-card"
|
||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||
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-unit-id="{{ unit.id }}"
|
||||
data-age="{{ unit.age }}">
|
||||
@@ -216,6 +220,8 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{% if unit.out_for_calibration %}
|
||||
<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 %}
|
||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||
{% elif unit.status == 'OK' %}
|
||||
@@ -231,12 +237,13 @@
|
||||
</div>
|
||||
<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
|
||||
{% 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 == '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
|
||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,100 +1,203 @@
|
||||
<!-- Reservations List -->
|
||||
{% if reservations %}
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
{% for item in reservations %}
|
||||
{% set res = item.reservation %}
|
||||
<div class="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
{% set card_id = "res-card-" ~ res.id %}
|
||||
{% set detail_id = "res-detail-" ~ res.id %}
|
||||
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
style="border-left: 4px solid {{ res.color }};">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
||||
{% if item.has_conflicts %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full"
|
||||
title="{{ item.conflict_count }} unit(s) have calibration expiring during this job">
|
||||
{{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }}
|
||||
</span>
|
||||
|
||||
<!-- Header row (always visible, clickable) -->
|
||||
<div class="res-card-header flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors select-none"
|
||||
data-res-id="{{ res.id }}"
|
||||
onclick="toggleResCard('{{ res.id }}')">
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
||||
{% if res.device_type == 'slm' %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">SLM</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded">Seismograph</span>
|
||||
{% endif %}
|
||||
{% if item.has_conflicts %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded"
|
||||
title="{{ item.conflict_count }} unit(s) will need a calibration swap during this job">
|
||||
{{ item.conflict_count }} cal swap{{ 's' if item.conflict_count != 1 else '' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{ res.start_date.strftime('%b %d, %Y') }} –
|
||||
{% if res.end_date %}
|
||||
{{ res.end_date.strftime('%b %d, %Y') }}
|
||||
{% elif res.end_date_tbd %}
|
||||
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
||||
{% if res.estimated_end_date %}
|
||||
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Counts -->
|
||||
<div class="flex flex-col items-end gap-1 mx-4 flex-shrink-0">
|
||||
{% set full = item.assigned_count == item.location_count and item.location_count > 0 %}
|
||||
{% set remaining = item.location_count - item.assigned_count %}
|
||||
<!-- Number row -->
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">est. {% if res.estimated_units %}{{ res.estimated_units }}{% else %}—{% endif %}</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span class="text-base font-bold {% if full %}text-green-600 dark:text-green-400{% elif item.assigned_count == 0 %}text-gray-400 dark:text-gray-500{% else %}text-amber-500 dark:text-amber-400{% endif %}">
|
||||
{{ item.assigned_count }}/{{ item.location_count }}
|
||||
</span>
|
||||
{% if remaining > 0 %}
|
||||
<span class="text-xs text-amber-500 dark:text-amber-400 whitespace-nowrap">({{ remaining }} more)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Progress squares -->
|
||||
{% if item.location_count > 0 %}
|
||||
<div class="flex gap-0.5">
|
||||
{% for i in range(item.location_count) %}
|
||||
<span class="w-3 h-3 rounded-sm {% if i < item.assigned_count %}{% if full %}bg-green-500{% else %}bg-amber-500{% endif %}{% else %}bg-gray-300 dark:bg-gray-600{% endif %}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ res.start_date.strftime('%b %d, %Y') }} -
|
||||
{% if res.end_date %}
|
||||
{{ res.end_date.strftime('%b %d, %Y') }}
|
||||
{% elif res.end_date_tbd %}
|
||||
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
||||
{% if res.estimated_end_date %}
|
||||
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<!-- Assign units (always visible) -->
|
||||
<button onclick="event.stopPropagation(); openPlanner('{{ res.id }}')"
|
||||
class="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Assign units">
|
||||
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- "..." overflow menu -->
|
||||
<div class="relative" onclick="event.stopPropagation()">
|
||||
<button onclick="toggleResMenu('{{ res.id }}')"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="More options">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="res-menu-{{ res.id }}"
|
||||
class="hidden absolute right-0 top-8 z-20 w-44 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||
<button onclick="openPromoteModal('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
|
||||
</svg>
|
||||
Promote to Project
|
||||
</button>
|
||||
<button onclick="editReservation('{{ res.id }}'); toggleResMenu('{{ res.id }}')"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-blue-500" 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"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||
class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2">
|
||||
<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"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expandable detail panel -->
|
||||
<div id="{{ detail_id }}" class="hidden border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-slate-800/60 px-4 py-3">
|
||||
|
||||
{% if res.notes %}
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">{{ res.notes }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3 italic">{{ res.notes }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-sm mb-3">
|
||||
<div class="text-gray-500 dark:text-gray-400">Estimated</div>
|
||||
<div class="font-medium {% if res.estimated_units %}text-gray-800 dark:text-gray-200{% else %}text-gray-400 dark:text-gray-500 italic{% endif %}">
|
||||
{% if res.estimated_units %}{{ res.estimated_units }} unit{{ 's' if res.estimated_units != 1 else '' }}{% else %}not specified{% endif %}
|
||||
</div>
|
||||
<div class="text-gray-500 dark:text-gray-400">Locations</div>
|
||||
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} of {{ item.location_count }} filled</div>
|
||||
{% if item.assigned_count < item.location_count %}
|
||||
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
|
||||
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.location_count - item.assigned_count }} location{{ 's' if (item.location_count - item.assigned_count) != 1 else '' }} remaining</div>
|
||||
{% endif %}
|
||||
{% if item.has_conflicts %}
|
||||
<div class="text-gray-500 dark:text-gray-400">Cal swaps</div>
|
||||
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.conflict_count }} unit{{ 's' if item.conflict_count != 1 else '' }} will need swapping during job</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.assigned_units %}
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">Monitoring Locations</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
{% for u in item.assigned_units %}
|
||||
<div class="rounded bg-white dark:bg-slate-700 border border-gray-100 dark:border-gray-600 text-sm">
|
||||
<div class="flex items-center gap-3 px-3 py-1.5">
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs w-12 flex-shrink-0">Loc. {{ loop.index }}</span>
|
||||
<div class="flex flex-col min-w-0">
|
||||
{% if u.location_name %}
|
||||
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300 truncate">{{ u.location_name }}</span>
|
||||
{% endif %}
|
||||
<button onclick="openUnitDetailModal('{{ u.id }}')"
|
||||
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left text-sm">{{ u.id }}</button>
|
||||
</div>
|
||||
<span class="flex-1"></span>
|
||||
{% if u.power_type == 'ac' %}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded">A/C</span>
|
||||
{% elif u.power_type == 'solar' %}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 rounded">Solar</span>
|
||||
{% endif %}
|
||||
{% if u.deployed %}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded">Deployed</span>
|
||||
{% else %}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 rounded">Benched</span>
|
||||
{% endif %}
|
||||
{% if u.last_calibrated %}
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: {{ u.last_calibrated.strftime('%b %d, %Y') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if u.notes %}
|
||||
<p class="px-3 pb-1.5 text-xs text-gray-400 dark:text-gray-500 italic">{{ u.notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 italic">No units assigned yet. Click the clipboard icon to plan.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right ml-4">
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{% if res.assignment_type == 'quantity' %}
|
||||
{{ item.assigned_count }}/{{ res.quantity_needed or '?' }}
|
||||
{% else %}
|
||||
{{ item.assigned_count }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4 flex items-center gap-2">
|
||||
<button onclick="editReservation('{{ res.id }}')"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Edit reservation">
|
||||
<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="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"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}')"
|
||||
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Delete reservation">
|
||||
<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="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"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function deleteReservation(id, name) {
|
||||
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert('Error: ' + (data.detail || 'Failed to delete'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting reservation');
|
||||
}
|
||||
}
|
||||
|
||||
// editReservation is defined in fleet_calendar.html
|
||||
</script>
|
||||
<!-- toggleResCard, deleteReservation, editReservation, openUnitDetailModal defined in fleet_calendar.html -->
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400">No reservations for {{ year }}</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
|
||||
<p class="text-gray-500 dark:text-gray-400">No jobs yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Job" to start planning a deployment</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if project.status == 'active' %}
|
||||
{% if project.status == 'upcoming' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||
{% elif project.status == 'active' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||
{% elif project.status == 'on_hold' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||
|
||||
@@ -3,12 +3,26 @@
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||
{{ project.status|title }}
|
||||
</span>
|
||||
<div class="relative inline-block">
|
||||
<select onchange="quickUpdateStatus(this.value)"
|
||||
class="appearance-none cursor-pointer inline-flex items-center pl-3 pr-7 py-1 rounded-full text-sm font-medium border-0 focus:ring-2 focus:ring-offset-1 focus:ring-blue-500
|
||||
{% if project.status == 'upcoming' %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200
|
||||
{% elif project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif project.status == 'on_hold' %}bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200
|
||||
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||
<option value="upcoming" {% if project.status == 'upcoming' %}selected{% endif %}>Upcoming</option>
|
||||
<option value="active" {% if project.status == 'active' %}selected{% endif %}>Active</option>
|
||||
<option value="on_hold" {% if project.status == 'on_hold' %}selected{% endif %}>On Hold</option>
|
||||
<option value="completed" {% if project.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
<option value="archived" {% if project.status == 'archived' %}selected{% endif %}>Archived</option>
|
||||
</select>
|
||||
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-current opacity-60">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
{% if project_type %}
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.project.status == 'active' %}
|
||||
{% if item.project.status == 'upcoming' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||
{% elif item.project.status == 'active' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||
{% elif item.project.status == 'on_hold' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||
|
||||
63
templates/partials/seismo_row_edit.html
Normal file
63
templates/partials/seismo_row_edit.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<tr id="seismo-row-{{ unit.id }}" class="bg-blue-50 dark:bg-slate-600 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap">
|
||||
<select name="status"
|
||||
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<option value="deployed" {% if unit.deployed %}selected{% endif %}>Deployed</option>
|
||||
<option value="out_for_calibration" {% if unit.out_for_calibration %}selected{% endif %}>Out for Cal</option>
|
||||
<option value="benched" {% if not unit.deployed and not unit.out_for_calibration %}selected{% endif %}>Benched</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if unit.address %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||
{% elif unit.coordinates %}
|
||||
<span>{{ unit.coordinates }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap">
|
||||
<input type="date"
|
||||
name="last_calibrated"
|
||||
value="{{ unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '' }}"
|
||||
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<input type="text"
|
||||
name="note"
|
||||
value="{{ unit.note or '' }}"
|
||||
placeholder="Add a note..."
|
||||
class="w-full text-sm rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-right text-sm">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button hx-post="/api/seismo-dashboard/unit/{{ unit.id }}/quick-update"
|
||||
hx-include="closest tr"
|
||||
hx-target="#seismo-row-{{ unit.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||
Save
|
||||
</button>
|
||||
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/view-row"
|
||||
hx-target="#seismo-row-{{ unit.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
93
templates/partials/seismo_row_view.html
Normal file
93
templates/partials/seismo_row_view.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<tr id="seismo-row-{{ unit.id }}" class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if unit.deployed %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Deployed
|
||||
</span>
|
||||
{% elif unit.out_for_calibration %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Out for Cal
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.address %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||
{% elif unit.coordinates %}
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.last_calibrated %}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{% if unit.next_calibration_due and today %}
|
||||
{% set days_until = (unit.next_calibration_due - today).days %}
|
||||
{% if days_until < 0 %}
|
||||
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
|
||||
{% elif days_until <= 14 %}
|
||||
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
|
||||
{% endif %}
|
||||
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||
{% if unit.note %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/edit-row"
|
||||
hx-target="#seismo-row-{{ unit.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
title="Edit row">
|
||||
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View Details →
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -92,88 +92,7 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if unit.deployed %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Deployed
|
||||
</span>
|
||||
{% elif unit.out_for_calibration %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Out for Cal
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.address %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||
{% elif unit.coordinates %}
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.last_calibrated %}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{% if unit.next_calibration_due and today %}
|
||||
{% set days_until = (unit.next_calibration_due - today).days %}
|
||||
{% if days_until < 0 %}
|
||||
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
|
||||
{% elif days_until <= 14 %}
|
||||
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
|
||||
{% endif %}
|
||||
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||
{% if unit.note %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View Details →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% include "partials/seismo_row_view.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||
<select name="status" id="settings-status"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="on_hold">On Hold</option>
|
||||
<option value="completed">Completed</option>
|
||||
@@ -758,6 +759,24 @@ const projectId = "{{ project_id }}";
|
||||
let editingLocationId = null;
|
||||
let projectTypeId = null;
|
||||
|
||||
async function quickUpdateStatus(newStatus) {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
});
|
||||
if (response.ok) {
|
||||
// Reload the page to reflect new badge color and any side effects
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to update status');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error updating status');
|
||||
}
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab panels
|
||||
|
||||
@@ -36,12 +36,17 @@
|
||||
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
||||
<button onclick="switchTab('all')"
|
||||
id="tab-all"
|
||||
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||
All Projects
|
||||
</button>
|
||||
<button onclick="switchTab('upcoming')"
|
||||
id="tab-upcoming"
|
||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||
Upcoming
|
||||
</button>
|
||||
<button onclick="switchTab('active')"
|
||||
id="tab-active"
|
||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
||||
Active
|
||||
</button>
|
||||
<button onclick="switchTab('on_hold')"
|
||||
@@ -66,7 +71,7 @@
|
||||
<!-- Projects List -->
|
||||
<div id="projects-list"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
hx-get="/api/projects/list"
|
||||
hx-get="/api/projects/list?status=active"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Loading skeletons -->
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<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="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="ignored">Ignored</button>
|
||||
</div>
|
||||
@@ -1352,7 +1353,7 @@
|
||||
|
||||
// Toggle health filter visibility (hide for retired/ignored)
|
||||
const healthGroup = document.getElementById('health-filter-group');
|
||||
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored') {
|
||||
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored' || this.dataset.value === 'allocated') {
|
||||
healthGroup.style.display = 'none';
|
||||
} else {
|
||||
healthGroup.style.display = 'flex';
|
||||
|
||||
@@ -278,6 +278,22 @@
|
||||
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
||||
</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 -->
|
||||
<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>
|
||||
@@ -320,6 +336,53 @@
|
||||
</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) -->
|
||||
<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">
|
||||
@@ -499,7 +562,7 @@
|
||||
|
||||
<!-- Status Checkboxes -->
|
||||
<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">
|
||||
<input type="checkbox" name="deployed" id="deployed" value="true"
|
||||
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">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
|
||||
</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>
|
||||
<!-- Hidden field for retired — controlled by the Retire button below -->
|
||||
<input type="hidden" name="retired" id="retired" value="">
|
||||
@@ -818,10 +893,14 @@ function populateViewMode() {
|
||||
|
||||
document.getElementById('age').textContent = unitStatus.age || '--';
|
||||
} else {
|
||||
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
|
||||
document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400';
|
||||
// Show "Benched" if not deployed, otherwise "No status data"
|
||||
document.getElementById('statusText').textContent = !currentUnit.deployed ? 'Benched' : 'No status data';
|
||||
const isAllocated = currentUnit.allocated && !currentUnit.deployed;
|
||||
document.getElementById('statusIndicator').className = isAllocated
|
||||
? 'w-3 h-3 rounded-full bg-orange-400'
|
||||
: '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('age').textContent = '--';
|
||||
}
|
||||
@@ -833,6 +912,11 @@ function populateViewMode() {
|
||||
} else if (currentUnit.out_for_calibration) {
|
||||
document.getElementById('retiredStatus').textContent = 'Out for Calibration';
|
||||
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 {
|
||||
document.getElementById('retiredStatus').textContent = 'Active';
|
||||
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' : '';
|
||||
updateRetireButton(currentUnit.retired);
|
||||
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
|
||||
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
||||
@@ -1631,12 +1719,173 @@ async function pingModem() {
|
||||
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
|
||||
loadCalibrationInterval();
|
||||
setupCalibrationAutoCalc();
|
||||
loadUnitData().then(() => {
|
||||
loadPhotos();
|
||||
loadUnitHistory();
|
||||
loadDeploymentHistory();
|
||||
});
|
||||
|
||||
// ===== Pair Device Modal Functions =====
|
||||
|
||||
Reference in New Issue
Block a user