14 Commits

Author SHA1 Message Date
0e3f512203 Feat: expands project reservation system.
-Reservation list view
-expandable project cards
2026-03-15 05:25:23 +00:00
e4d1f0d684 feat: start build of listed reservation system 2026-03-13 21:37:06 +00:00
serversdwn
b571dc29bc chore: make rebuild script executable 2026-03-13 17:34:32 +00:00
serversdwn
e2c841d5d7 doc: update readme v0.7.1 2026-03-12 22:41:47 +00:00
cc94493331 Merge pull request 'merge v0.7.1 dev into main.' (#31) from dev into main
Reviewed-on: #31
2026-03-12 18:40:15 -04:00
serversdwn
5a5426cceb v0.7.1 - Add out for call status, starting to work on reservation mode, fixed a big brain fart too. 2026-03-12 22:38:22 +00:00
serversdwn
66eddd6fe2 chore: db cleanups and migration script fixes. 2026-03-12 22:09:57 +00:00
serversdwn
c77794787c remove override 2026-03-12 21:41:30 +00:00
61c84bc71d fix: merge conflict fixed 2026-03-12 21:37:09 +00:00
fbf7f2a65d chore: clean up .gitignore 2026-03-12 21:22:51 +00:00
serversdwn
202fcaf91c Merge branch 'dev' of ssh://10.0.0.2:2222/serversdown/terra-view into dev 2026-03-12 20:25:02 +00:00
serversdwn
3a411d0a89 chore: docker and git stuff 2026-03-12 20:24:01 +00:00
0c2186f5d8 feat: reservation modal now usable. 2026-03-12 20:10:42 +00:00
c138e8c6a0 feat: add new "out for cal" status for units currently being calibrated.
-retire unit button changed to be more dramatic... lol
2026-03-12 17:59:42 +00:00
24 changed files with 1302 additions and 188 deletions

14
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Terra-View Specifics
# Dev build counter (local only, never commit)
build_number.txt
docker-compose.override.yml
# SQLite database files
*.db
@@ -9,7 +10,7 @@ data/
data-dev/
.aider*
.aider*
docker-compose.override.yml
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -219,3 +220,14 @@ marimo/_static/
marimo/_lsp/
__marimo__/
<<<<<<< HEAD
# Seismo Fleet Manager
# SQLite database files
*.db
*.db-journal
/data/
/data-dev/
.aider*
.aider*
=======
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027

View File

@@ -5,6 +5,27 @@ 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.7.1] - 2026-03-12
### Added
- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel
- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations
### Changed
- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks
### Fixed
- **Migration Scripts**: Fixed database path references in several migration scripts
- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate
### Migration Notes
Run the following migration script once per database before deploying:
```bash
python backend/migrate_add_out_for_calibration.py
```
---
## [0.7.0] - 2026-03-07
### Added

View File

@@ -1,4 +1,4 @@
# Terra-View v0.7.0
# Terra-View v0.7.1
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

View File

@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.7.0"
VERSION = "0.7.1"
if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0":
@@ -659,6 +659,7 @@ async def devices_all_partial(request: Request):
"last_seen": unit_data.get("last", "Never"),
"deployed": True,
"retired": False,
"out_for_calibration": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
@@ -683,6 +684,32 @@ async def devices_all_partial(request: Request):
"last_seen": unit_data.get("last", "Never"),
"deployed": False,
"retired": False,
"out_for_calibration": False,
"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({
"id": unit_id,
"status": "Out for Calibration",
"age": "N/A",
"last_seen": "N/A",
"deployed": False,
"retired": False,
"out_for_calibration": True,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
@@ -707,6 +734,7 @@ async def devices_all_partial(request: Request):
"last_seen": "N/A",
"deployed": False,
"retired": True,
"out_for_calibration": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
@@ -731,6 +759,7 @@ async def devices_all_partial(request: Request):
"last_seen": "N/A",
"deployed": False,
"retired": False,
"out_for_calibration": False,
"ignored": True,
"note": unit_data.get("note", unit_data.get("reason", "")),
"device_type": unit_data.get("device_type", "unknown"),
@@ -748,15 +777,17 @@ async def devices_all_partial(request: Request):
# Sort by status category, then by ID
def sort_key(unit):
# Priority: deployed (active) -> benched -> retired -> ignored
# Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored
if unit["deployed"]:
return (0, unit["id"])
elif not unit["retired"] and not unit["ignored"]:
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
return (1, unit["id"])
elif unit["retired"]:
elif unit["out_for_calibration"]:
return (2, unit["id"])
else:
elif unit["retired"]:
return (3, unit["id"])
else:
return (4, unit["id"])
units_list.sort(key=sort_key)

View File

@@ -0,0 +1,54 @@
"""
Database Migration: Add out_for_calibration field to roster table
Changes:
- Adds out_for_calibration BOOLEAN column (default FALSE) to roster table
- Safe to run multiple times (idempotent)
- No data loss
Usage:
python backend/migrate_add_out_for_calibration.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def migrate():
db = SessionLocal()
try:
print("=" * 60)
print("Migration: Add out_for_calibration to roster")
print("=" * 60)
# Check if column already exists
result = db.execute(text("PRAGMA table_info(roster)")).fetchall()
columns = [row[1] for row in result]
if "out_for_calibration" in columns:
print("Column out_for_calibration already exists. Skipping.")
else:
db.execute(text("ALTER TABLE roster ADD COLUMN out_for_calibration BOOLEAN DEFAULT FALSE"))
db.commit()
print("Added out_for_calibration column to roster table.")
print("Migration complete.")
except Exception as e:
db.rollback()
print(f"Error: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
migrate()

View File

@@ -44,7 +44,7 @@ def migrate(db_path: str):
if __name__ == "__main__":
db_path = "./data/terra-view.db"
db_path = "./data/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]

View File

@@ -42,7 +42,7 @@ def migrate(db_path: str):
if __name__ == "__main__":
db_path = "./data/terra-view.db"
db_path = "./data/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]

View File

@@ -32,6 +32,7 @@ class RosterUnit(Base):
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
deployed = Column(Boolean, default=True)
retired = Column(Boolean, default=False)
out_for_calibration = Column(Boolean, default=False)
note = Column(String, nullable=True)
project_id = Column(String, nullable=True)
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
@@ -494,3 +495,6 @@ 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

View File

@@ -223,6 +223,10 @@ async def get_reservation(
unit_ids = [a.unit_id for a in assignments]
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 power_type and notes lookup from assignments
power_type_map = {a.unit_id: a.power_type for a in assignments}
notes_map = {a.unit_id: a.notes for a in assignments}
return {
"id": reservation.id,
@@ -239,11 +243,13 @@ async def get_reservation(
"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": power_type_map.get(uid),
"notes": notes_map.get(uid)
}
for u in units
for uid in unit_ids
]
}
@@ -337,29 +343,27 @@ async def assign_units_to_reservation(
data = await request.json()
unit_ids = data.get("unit_ids", [])
# Optional per-unit power types: {"BE17354": "ac", "BE9441": "solar"}
power_types = data.get("power_types", {})
location_notes = data.get("location_notes", {})
if not unit_ids:
raise HTTPException(status_code=400, detail="No units specified")
# Verify units exist
# 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)}")
# Check for conflicts (already assigned to overlapping reservations)
# 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 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
if reservation.end_date:
overlapping = db.query(JobReservation).join(
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
).filter(
@@ -382,7 +386,9 @@ async def assign_units_to_reservation(
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)
)
db.add(assignment)
@@ -511,9 +517,8 @@ 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
# Include TBD reservations that started before window end — show ALL device types
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= end_date,
or_(
JobReservation.end_date >= start_date,
@@ -524,9 +529,25 @@ 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
unit_ids = [a.unit_id for a in assignments]
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,
"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
]
# Check for calibration conflicts
conflicts = check_calibration_conflicts(db, res.id)
@@ -534,6 +555,7 @@ async def get_reservations_list(
reservation_data.append({
"reservation": res,
"assigned_count": assigned_count,
"assigned_units": assigned_units,
"has_conflicts": len(conflicts) > 0,
"conflict_count": len(conflicts)
})
@@ -549,6 +571,56 @@ 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
from backend.models import RosterUnit as RU
from datetime import timedelta
all_units = db.query(RU).filter(
RU.device_type == device_type,
RU.retired == False
).all()
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,
"note": u.note or ""
})
# 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/available-units", response_class=HTMLResponse)
async def get_available_units_partial(
request: Request,

View File

@@ -146,6 +146,7 @@ async def add_roster_unit(
unit_type: str = Form("series3"),
deployed: str = Form(None),
retired: str = Form(None),
out_for_calibration: str = Form(None),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
@@ -177,6 +178,7 @@ async def add_roster_unit(
# Convert boolean strings to actual booleans
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
# Convert port strings to integers
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -211,6 +213,7 @@ async def add_roster_unit(
unit_type=unit_type,
deployed=deployed_bool,
retired=retired_bool,
out_for_calibration=out_for_calibration_bool,
note=note,
project_id=project_id,
location=location,
@@ -463,6 +466,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"unit_type": unit.unit_type,
"deployed": unit.deployed,
"retired": unit.retired,
"out_for_calibration": unit.out_for_calibration or False,
"note": unit.note or "",
"project_id": unit.project_id or "",
"location": unit.location or "",
@@ -494,6 +498,7 @@ async def edit_roster_unit(
unit_type: str = Form("series3"),
deployed: str = Form(None),
retired: str = Form(None),
out_for_calibration: str = Form(None),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
@@ -535,6 +540,7 @@ async def edit_roster_unit(
# Convert boolean strings to actual booleans
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
# Convert port strings to integers
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -564,12 +570,14 @@ async def edit_roster_unit(
old_note = unit.note
old_deployed = unit.deployed
old_retired = unit.retired
old_out_for_calibration = unit.out_for_calibration
# Update all fields
unit.device_type = device_type
unit.unit_type = unit_type
unit.deployed = deployed_bool
unit.retired = retired_bool
unit.out_for_calibration = out_for_calibration_bool
unit.note = note
unit.project_id = project_id
unit.location = location
@@ -677,6 +685,11 @@ async def edit_roster_unit(
old_status_text = "retired" if old_retired else "active"
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
if old_out_for_calibration != out_for_calibration_bool:
status_text = "out_for_calibration" if out_for_calibration_bool else "available"
old_status_text = "out_for_calibration" if old_out_for_calibration else "available"
record_history(db, unit_id, "calibration_status_change", "out_for_calibration", old_status_text, status_text, "manual")
# Handle cascade to paired device
cascaded_unit_id = None
if cascade_to_unit_id and cascade_to_unit_id.strip():

View File

@@ -28,7 +28,8 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
total = len(seismos)
deployed = sum(1 for s in seismos if s.deployed)
benched = sum(1 for s in seismos if not s.deployed)
benched = sum(1 for s in seismos if not s.deployed and not s.out_for_calibration)
out_for_calibration = sum(1 for s in seismos if s.out_for_calibration)
# Count modems assigned to deployed seismographs
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
@@ -41,6 +42,7 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
"total": total,
"deployed": deployed,
"benched": benched,
"out_for_calibration": out_for_calibration,
"with_modem": with_modem,
"without_modem": without_modem
}
@@ -77,7 +79,9 @@ async def get_seismo_units(
if status == "deployed":
query = query.filter(RosterUnit.deployed == True)
elif status == "benched":
query = query.filter(RosterUnit.deployed == False)
query = query.filter(RosterUnit.deployed == False, RosterUnit.out_for_calibration == False)
elif status == "out_for_calibration":
query = query.filter(RosterUnit.out_for_calibration == True)
# Apply modem filter
if modem == "with":

View File

@@ -646,22 +646,20 @@ def get_available_units_for_period(
if unit.id in reserved_unit_ids:
continue
# Check calibration through end of period
if not unit.last_calibrated:
continue # Needs calibration
if unit.last_calibrated:
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)
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 ""
})

View File

@@ -80,6 +80,12 @@ def emit_status_snapshot():
age = "N/A"
last_seen = None
fname = ""
elif r.out_for_calibration:
# Out for calibration units get separated later
status = "Out for Calibration"
age = "N/A"
last_seen = None
fname = ""
else:
if e:
last_seen = ensure_utc(e.last_seen)
@@ -103,6 +109,7 @@ def emit_status_snapshot():
"deployed": r.deployed,
"note": r.note or "",
"retired": r.retired,
"out_for_calibration": r.out_for_calibration or False,
# 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,
@@ -133,6 +140,7 @@ def emit_status_snapshot():
"deployed": False, # default
"note": "",
"retired": False,
"out_for_calibration": False,
# Device type and type-specific fields (defaults for unknown units)
"device_type": "seismograph", # default
"last_calibrated": None,
@@ -179,12 +187,12 @@ def emit_status_snapshot():
# Separate buckets for UI
active_units = {
uid: u for uid, u in units.items()
if not u["retired"] and u["deployed"] and uid not in ignored
if not u["retired"] and not u["out_for_calibration"] and u["deployed"] and uid not in ignored
}
benched_units = {
uid: u for uid, u in units.items()
if not u["retired"] and not u["deployed"] and uid not in ignored
if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored
}
retired_units = {
@@ -192,6 +200,11 @@ def emit_status_snapshot():
if u["retired"]
}
out_for_calibration_units = {
uid: u for uid, u in units.items()
if u["out_for_calibration"]
}
# Unknown units - emitters that aren't in the roster and aren't ignored
unknown_units = {
uid: u for uid, u in units.items()
@@ -204,12 +217,14 @@ def emit_status_snapshot():
"active": active_units,
"benched": benched_units,
"retired": retired_units,
"out_for_calibration": out_for_calibration_units,
"unknown": unknown_units,
"summary": {
"total": len(active_units) + len(benched_units),
"active": len(active_units),
"benched": len(benched_units),
"retired": len(retired_units),
"out_for_calibration": len(out_for_calibration_units),
"unknown": len(unknown_units),
# Status counts only for deployed units (active_units)
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),

View File

@@ -1,9 +1,7 @@
services:
# --- TERRA-VIEW PRODUCTION ---
terra-view:
build: .
container_name: terra-view
ports:
- "8001:8001"
volumes:
@@ -29,7 +27,6 @@ services:
build:
context: ../slmm
dockerfile: Dockerfile
container_name: slmm
network_mode: host
volumes:
- ../slmm/data:/app/data

0
rebuild-prod.sh Normal file → Executable file
View File

View File

@@ -8,7 +8,7 @@ import sys
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "sqlite:///data/sfm.db"
DATABASE_URL = "sqlite:///data/seismo_fleet.db"
def rename_unit(old_id: str, new_id: str):
"""

View File

@@ -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
Reservation 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">

View File

@@ -223,9 +223,22 @@
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Calendar</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Plan unit assignments and track calibrations</p>
</div>
</div>
</div>
<!-- View Tabs -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
<button id="tab-btn-planner" onclick="switchTab('planner')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
Reservation Planner
</button>
<button id="tab-btn-calendar" onclick="switchTab('calendar')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Calendar
</button>
</div>
</div>
<div id="view-calendar" class="hidden">
<!-- Summary Stats -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
@@ -375,6 +388,47 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Reservations</h2>
<div id="reservations-list"
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
hx-trigger="calendar-reservations-refresh from:body"
hx-swap="innerHTML">
<p class="text-gray-500">Loading reservations...</p>
</div>
</div>
</div><!-- end #view-calendar -->
<!-- Reservation Planner View -->
<div id="view-planner">
<div class="flex flex-col lg:flex-row gap-6 min-h-[70vh]">
<!-- LEFT PANEL: sub-tabs switch content here only -->
<div class="lg:w-2/5 flex flex-col gap-4">
<!-- Sub-tab bar -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 w-fit">
<button id="ptab-btn-list" onclick="switchPlannerTab('list')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
Reservations
</button>
<button id="ptab-btn-assign" onclick="switchPlannerTab('assign')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Assign Units
</button>
</div>
<!-- Sub-tab: Reservations list -->
<div id="ptab-list" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Project Reservations</h2>
<button onclick="plannerReset(); switchPlannerTab('assign')"
class="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm 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"/>
</svg>
New
</button>
</div>
<div id="planner-reservations-list" class="overflow-y-visible"
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
hx-trigger="load"
hx-swap="innerHTML">
@@ -382,6 +436,187 @@
</div>
</div>
<!-- Sub-tab: Assign Units form -->
<div id="ptab-assign" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4 flex-1">
<div class="flex items-center gap-3">
<button onclick="switchPlannerTab('list')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" title="Back to reservations">
<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="M15 19l-7-7 7-7"/>
</svg>
</button>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="planner-form-title">New Reservation</h2>
</div>
<!-- Metadata fields: only shown when creating a new reservation -->
<div id="planner-meta-fields">
<!-- Name -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Job / Reservation Name *</label>
<input type="text" id="planner-name" placeholder="e.g., Pine Street May Deployment"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<!-- Device Type -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Device Type *</label>
<div class="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
<label class="flex-1 cursor-pointer">
<input type="radio" name="planner_device_type" value="seismograph" checked class="sr-only peer" onchange="plannerDatesChanged()">
<span class="block text-center px-4 py-2 text-sm font-medium bg-white dark:bg-slate-700 peer-checked:bg-blue-600 peer-checked:text-white text-gray-700 dark:text-gray-300 transition-colors">
Seismograph
</span>
</label>
<label class="flex-1 cursor-pointer border-l border-gray-300 dark:border-gray-600">
<input type="radio" name="planner_device_type" value="slm" class="sr-only peer" onchange="plannerDatesChanged()">
<span class="block text-center px-4 py-2 text-sm font-medium bg-white dark:bg-slate-700 peer-checked:bg-blue-600 peer-checked:text-white text-gray-700 dark:text-gray-300 transition-colors">
Sound Level Meter
</span>
</label>
</div>
</div>
<!-- Project -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Link to Project (optional)</label>
<select id="planner-project"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<option value="">-- No project --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date *</label>
<input type="date" id="planner-start"
onchange="plannerDatesChanged()"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date *</label>
<input type="date" id="planner-end"
onchange="plannerDatesChanged()"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- Color -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
<div class="flex gap-2" id="planner-colors">
{% for color in ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] %}
<label class="cursor-pointer">
<input type="radio" name="planner_color" value="{{ color }}" {% if loop.first %}checked{% endif %} class="sr-only peer">
<span class="block w-7 h-7 rounded-full peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-900 dark:peer-checked:ring-white"
style="background-color: {{ color }}"></span>
</label>
{% endfor %}
</div>
</div>
<!-- Estimated Units Needed -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estimated Units Needed</label>
<input type="number" id="planner-est-units" min="1" placeholder="e.g. 5"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
</div><!-- end #planner-meta-fields -->
<!-- Monitoring Locations -->
<div class="flex items-center justify-between mt-2">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Monitoring Locations</h3>
<button onclick="plannerAddSlot()"
class="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-700 dark:text-gray-300">
+ Add Location
</button>
</div>
<div id="planner-slots" class="flex flex-col gap-2 overflow-y-auto max-h-72">
<!-- Locations rendered by JS -->
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4" id="planner-slots-empty">
Set dates and click "+ Add Location" to start adding units
</p>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes (optional)</label>
<textarea id="planner-notes" rows="2" placeholder="Optional notes"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<!-- Save -->
<div class="flex gap-3 pt-2 border-t border-gray-200 dark:border-gray-700 mt-auto">
<button onclick="plannerReset()"
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-sm">
Clear
</button>
<button onclick="plannerSave()"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium" id="planner-save-btn">
Save Reservation
</button>
</div>
</div><!-- end ptab-assign -->
</div><!-- end left panel -->
<!-- RIGHT: Available Units (always visible) -->
<div class="lg:w-3/5 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Available Units
<span id="planner-avail-count" class="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400"></span>
</h2>
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="planner-deployed-only" onchange="plannerFilterUnits()"
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
Deployed only
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="planner-benched-only" onchange="plannerFilterUnits()"
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
Benched only
</label>
</div>
</div>
<input type="text" id="planner-search" placeholder="Search by unit ID..."
oninput="plannerFilterUnits()"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<div id="planner-units-list" class="flex flex-col gap-1 overflow-y-auto flex-1" style="max-height: 55vh;">
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8" id="planner-units-placeholder">
Set start and end dates to see available units
</p>
</div>
</div><!-- end right panel -->
</div><!-- end flex row -->
</div><!-- end view-planner -->
<!-- Unit Detail Modal (planner) -->
<div id="unit-detail-modal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeUnitDetailModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col" onclick="event.stopPropagation()">
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="font-semibold text-gray-900 dark:text-white" id="unit-detail-modal-title">Unit Detail</h3>
<button onclick="closeUnitDetailModal()" 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"/>
</svg>
</button>
</div>
<iframe id="unit-detail-iframe" src="" class="flex-1 rounded-b-xl" style="min-height: 70vh; border: none;"></iframe>
</div>
</div>
</div>
<!-- Day Detail Slide Panel -->
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
<div id="day-panel" class="slide-panel">
@@ -397,13 +632,14 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">New Reservation</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="modal-title">New Reservation</h2>
<button onclick="closeReservationModal()" 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"/>
</svg>
</button>
</div>
<input type="hidden" id="editing-reservation-id" value="">
<form id="reservation-form" onsubmit="submitReservation(event)">
<!-- Name -->
@@ -522,7 +758,7 @@
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit"
<button type="submit" id="modal-submit-btn"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Create Reservation
</button>
@@ -600,27 +836,115 @@ function closeDayPanel() {
let reservationModeActive = false;
function openReservationModal() {
// Reset to "create" mode
document.getElementById('modal-title').textContent = 'New Reservation';
document.getElementById('modal-submit-btn').textContent = 'Create Reservation';
document.getElementById('editing-reservation-id').value = '';
document.getElementById('reservation-form').reset();
document.getElementById('reservation-modal').classList.remove('hidden');
reservationModeActive = true;
// Show reservation legend, hide main legend
document.getElementById('main-legend').classList.add('md:hidden');
document.getElementById('main-legend').classList.remove('md:flex');
document.getElementById('reservation-legend').classList.remove('md:hidden');
document.getElementById('reservation-legend').classList.add('md:flex');
// Trigger availability update
updateCalendarAvailability();
}
function toggleResCard(id) {
const detail = document.getElementById('res-detail-' + id);
const chevron = document.getElementById('chevron-' + id);
if (!detail) return;
const isHidden = detail.classList.contains('hidden');
if (isHidden) {
detail.classList.remove('hidden');
detail.style.display = 'block';
} else {
detail.classList.add('hidden');
detail.style.display = 'none';
}
if (chevron) chevron.style.transform = isHidden ? 'rotate(180deg)' : '';
}
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) {
htmx.trigger('#planner-reservations-list', 'load');
} else {
const data = await response.json();
alert('Error: ' + (data.detail || 'Failed to delete'));
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting reservation');
}
}
async function editReservation(id) {
try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`);
if (!response.ok) { alert('Failed to load reservation'); return; }
const res = await response.json();
// Switch modal to "edit" mode
document.getElementById('modal-title').textContent = 'Edit Reservation';
document.getElementById('modal-submit-btn').textContent = 'Save Changes';
document.getElementById('editing-reservation-id').value = id;
// Populate fields
const form = document.getElementById('reservation-form');
form.querySelector('input[name="name"]').value = res.name;
form.querySelector('select[name="project_id"]').value = res.project_id || '';
form.querySelector('input[name="start_date"]').value = res.start_date;
form.querySelector('textarea[name="notes"]').value = res.notes || '';
// Color radio
const colorRadio = form.querySelector(`input[name="color"][value="${res.color}"]`);
if (colorRadio) colorRadio.checked = true;
// Assignment type
const atRadio = form.querySelector(`input[name="assignment_type"][value="${res.assignment_type}"]`);
if (atRadio) { atRadio.checked = true; toggleAssignmentType(res.assignment_type); }
if (res.assignment_type === 'quantity') {
form.querySelector('input[name="quantity_needed"]').value = res.quantity_needed || 1;
}
// End date / TBD
const tbdCheckbox = document.getElementById('end_date_tbd');
if (res.end_date_tbd) {
tbdCheckbox.checked = true;
form.querySelector('input[name="estimated_end_date"]').value = res.estimated_end_date || '';
} else {
tbdCheckbox.checked = false;
document.getElementById('end_date_input').value = res.end_date || '';
}
toggleEndDateTBD();
document.getElementById('reservation-modal').classList.remove('hidden');
reservationModeActive = true;
document.getElementById('main-legend').classList.add('md:hidden');
document.getElementById('main-legend').classList.remove('md:flex');
document.getElementById('reservation-legend').classList.remove('md:hidden');
document.getElementById('reservation-legend').classList.add('md:flex');
updateCalendarAvailability();
} catch (error) {
console.error('Error loading reservation:', error);
alert('Error loading reservation');
}
}
function closeReservationModal() {
document.getElementById('reservation-modal').classList.add('hidden');
document.getElementById('reservation-form').reset();
document.getElementById('editing-reservation-id').value = '';
reservationModeActive = false;
// Restore main legend
document.getElementById('main-legend').classList.remove('md:hidden');
document.getElementById('main-legend').classList.add('md:flex');
document.getElementById('reservation-legend').classList.add('md:hidden');
document.getElementById('reservation-legend').classList.remove('md:flex');
// Reset calendar colors
resetCalendarColors();
}
@@ -752,6 +1076,7 @@ async function submitReservation(event) {
const form = event.target;
const formData = new FormData(form);
const endDateTbd = formData.get('end_date_tbd') === 'on';
const editingId = document.getElementById('editing-reservation-id').value;
const data = {
name: formData.get('name'),
@@ -766,7 +1091,6 @@ async function submitReservation(event) {
notes: formData.get('notes') || null
};
// Validate: need either end_date or TBD checked
if (!data.end_date && !data.end_date_tbd) {
alert('Please enter an end date or check "TBD / Ongoing"');
return;
@@ -778,9 +1102,15 @@ async function submitReservation(event) {
data.unit_ids = formData.getAll('unit_ids');
}
const isEdit = editingId !== '';
const url = isEdit
? `/api/fleet-calendar/reservations/${editingId}`
: '/api/fleet-calendar/reservations';
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch('/api/fleet-calendar/reservations', {
method: 'POST',
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
@@ -789,14 +1119,13 @@ async function submitReservation(event) {
if (result.success) {
closeReservationModal();
// Reload the page to refresh calendar
window.location.reload();
} else {
alert('Error creating reservation: ' + (result.detail || 'Unknown error'));
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation: ` + (result.detail || 'Unknown error'));
}
} catch (error) {
console.error('Error:', error);
alert('Error creating reservation');
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation`);
}
}
@@ -807,5 +1136,446 @@ document.addEventListener('keydown', function(e) {
closeReservationModal();
}
});
// ============================================================
// Tab + sub-tab switching
// ============================================================
function switchPlannerTab(tab) {
const isAssign = tab === 'assign';
document.getElementById('ptab-list').classList.toggle('hidden', isAssign);
document.getElementById('ptab-assign').classList.toggle('hidden', !isAssign);
['list', 'assign'].forEach(t => {
const btn = document.getElementById(`ptab-btn-${t}`);
if (t === tab) {
btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
} else {
btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
}
});
}
function switchTab(tab) {
document.getElementById('view-calendar').classList.toggle('hidden', tab !== 'calendar');
document.getElementById('view-planner').classList.toggle('hidden', tab !== 'planner');
if (tab === 'calendar') htmx.trigger(document.body, 'calendar-reservations-refresh');
['calendar', 'planner'].forEach(t => {
const btn = document.getElementById(`tab-btn-${t}`);
if (t === tab) {
btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
} else {
btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
}
});
}
// ============================================================
// Reservation Planner
// ============================================================
let plannerState = {
reservation_id: null, // null = creating new
slots: [], // array of {unit_id: string|null, power_type: string|null, notes: string|null}
allUnits: [] // full list from server
};
let dragSrcIdx = null;
function plannerDatesChanged() {
plannerLoadUnits();
}
async function plannerLoadUnits() {
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
const excludeId = plannerState.reservation_id || '';
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
let url = `/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`;
if (start && end && end >= start) {
url += `&start_date=${start}&end_date=${end}`;
}
if (excludeId) url += `&exclude_reservation_id=${excludeId}`;
try {
const resp = await fetch(url);
const data = await resp.json();
plannerState.allUnits = data.units || [];
const hasDates = start && end;
document.getElementById('planner-avail-count').textContent =
hasDates ? `(${plannerState.allUnits.length} available for period)` : `(${plannerState.allUnits.length} total)`;
plannerRenderUnits();
} catch (e) {
console.error('Planner load error', e);
}
}
function plannerFilterUnits() {
// Mutually exclusive checkboxes
const deployedOnly = document.getElementById('planner-deployed-only');
const benchedOnly = document.getElementById('planner-benched-only');
if (event && event.target === deployedOnly && deployedOnly.checked) benchedOnly.checked = false;
if (event && event.target === benchedOnly && benchedOnly.checked) deployedOnly.checked = false;
plannerRenderUnits();
}
function plannerRenderUnits() {
const search = document.getElementById('planner-search').value.toLowerCase();
const deployedOnly = document.getElementById('planner-deployed-only').checked;
const benchedOnly = document.getElementById('planner-benched-only').checked;
const slottedIds = new Set(plannerState.slots.map(s => s.unit_id).filter(Boolean));
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
let units = plannerState.allUnits.filter(u => {
if (deployedOnly && !u.deployed) return false;
if (benchedOnly && u.deployed) return false;
if (search && !u.id.toLowerCase().includes(search)) return false;
return true;
});
const placeholder = document.getElementById('planner-units-placeholder');
const list = document.getElementById('planner-units-list');
if (plannerState.allUnits.length === 0) {
placeholder.classList.remove('hidden');
placeholder.textContent = 'Loading units...';
list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
return;
}
placeholder.classList.add('hidden');
list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
if (units.length === 0) {
const empty = document.createElement('p');
empty.className = 'planner-unit-row text-sm text-gray-400 dark:text-gray-500 text-center py-8';
empty.textContent = 'No units match your filter';
list.appendChild(empty);
return;
}
for (const unit of units) {
const isSlotted = slottedIds.has(unit.id);
const row = document.createElement('div');
row.className = `planner-unit-row flex items-center justify-between px-3 py-2 rounded-lg border transition-colors ${
isSlotted
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 opacity-60 cursor-default'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`;
row.dataset.unitId = unit.id;
if (!isSlotted) row.onclick = () => plannerAssignUnit(unit.id);
const calDate = unit.last_calibrated
? new Date(unit.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
: 'No cal date';
// Calibration expiry warning during deployment
let expiryWarning = '';
if (start && end && unit.expiry_date) {
const expiry = new Date(unit.expiry_date + 'T00:00:00');
const jobStart = new Date(start + 'T00:00:00');
const jobEnd = new Date(end + 'T00:00:00');
if (expiry >= jobStart && expiry <= jobEnd) {
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
expiryWarning = `<span class="text-xs px-1.5 py-0.5 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 rounded border border-amber-200 dark:border-amber-800" title="Will need swap during job">cal expires ${expiryStr}</span>`;
}
}
const deployedBadge = unit.deployed
? '<span class="text-xs px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
: '<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
row.innerHTML = `
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<button onclick="event.stopPropagation(); openUnitDetailModal('${unit.id}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${unit.id}</button>
${deployedBadge}
${expiryWarning}
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate}</span>
</div>
<div class="flex-shrink-0 ml-2">
${isSlotted
? '<span class="text-xs text-blue-600 dark:text-blue-400 font-medium">Assigned</span>'
: '<button class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 whitespace-nowrap">Assign →</button>'
}
</div>
`;
list.appendChild(row);
}
}
function openUnitDetailModal(unitId) {
document.getElementById('unit-detail-modal-title').textContent = unitId;
document.getElementById('unit-detail-iframe').src = `/unit/${unitId}?embed=1`;
document.getElementById('unit-detail-modal').classList.remove('hidden');
}
function closeUnitDetailModal() {
document.getElementById('unit-detail-modal').classList.add('hidden');
document.getElementById('unit-detail-iframe').src = '';
}
function plannerAddSlot() {
plannerState.slots.push({ unit_id: null, power_type: null, notes: null });
plannerRenderSlots();
}
function plannerAssignUnit(unitId) {
const emptyIdx = plannerState.slots.findIndex(s => !s.unit_id);
if (emptyIdx >= 0) {
plannerState.slots[emptyIdx].unit_id = unitId;
} else {
plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null });
}
plannerRenderSlots();
plannerRenderUnits();
}
function plannerRemoveSlot(idx) {
plannerState.slots.splice(idx, 1);
plannerRenderSlots();
plannerRenderUnits();
}
function plannerSetPowerType(idx, value) {
plannerState.slots[idx].power_type = value || null;
}
function plannerSetSlotNotes(idx, value) {
plannerState.slots[idx].notes = value || null;
}
function plannerRenderSlots() {
const container = document.getElementById('planner-slots');
const emptyMsg = document.getElementById('planner-slots-empty');
container.querySelectorAll('.planner-slot-row').forEach(el => el.remove());
if (plannerState.slots.length === 0) {
emptyMsg.classList.remove('hidden');
return;
}
emptyMsg.classList.add('hidden');
plannerState.slots.forEach((slot, idx) => {
const row = document.createElement('div');
row.className = 'planner-slot-row flex flex-col gap-1.5 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700/50';
row.dataset.idx = idx;
row.draggable = !!slot.unit_id;
// Drag events
if (slot.unit_id) {
row.addEventListener('dragstart', e => {
dragSrcIdx = idx;
e.dataTransfer.effectAllowed = 'move';
row.classList.add('opacity-50');
});
row.addEventListener('dragend', () => row.classList.remove('opacity-50'));
}
row.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
container.querySelectorAll('.planner-slot-row').forEach(r => r.classList.remove('ring-2', 'ring-blue-400'));
row.classList.add('ring-2', 'ring-blue-400');
});
row.addEventListener('dragleave', () => row.classList.remove('ring-2', 'ring-blue-400'));
row.addEventListener('drop', e => {
e.preventDefault();
row.classList.remove('ring-2', 'ring-blue-400');
if (dragSrcIdx === null || dragSrcIdx === idx) return;
// Swap unit_id and power_type only (keep location notes in place)
const srcSlot = plannerState.slots[dragSrcIdx];
const dstSlot = plannerState.slots[idx];
[srcSlot.unit_id, dstSlot.unit_id] = [dstSlot.unit_id, srcSlot.unit_id];
[srcSlot.power_type, dstSlot.power_type] = [dstSlot.power_type, srcSlot.power_type];
dragSrcIdx = null;
plannerRenderSlots();
plannerRenderUnits();
});
const powerSelect = `
<select onchange="plannerSetPowerType(${idx}, this.value)"
class="text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300 focus:ring-1 focus:ring-blue-500">
<option value="" ${!slot.power_type ? 'selected' : ''}>— power —</option>
<option value="ac" ${slot.power_type === 'ac' ? 'selected' : ''}>A/C Power</option>
<option value="solar" ${slot.power_type === 'solar' ? 'selected' : ''}>Solar</option>
</select>`;
const dragHandle = slot.unit_id
? `<span class="text-gray-300 dark:text-gray-600 cursor-grab active:cursor-grabbing select-none" title="Drag to reorder">⠿</span>`
: `<span class="w-4"></span>`;
row.innerHTML = `
<div class="flex items-center gap-2">
${dragHandle}
<span class="text-sm text-gray-500 dark:text-gray-400 w-16 flex-shrink-0">Loc. ${idx + 1}</span>
${slot.unit_id
? `<span class="flex-1 font-medium text-gray-900 dark:text-white">${slot.unit_id}</span>
${powerSelect}
<button onclick="plannerClearSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove unit">✕</button>`
: `<span class="flex-1 text-sm text-gray-400 dark:text-gray-500 italic">Empty — click a unit</span>
${powerSelect}
<button onclick="plannerRemoveSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove location">✕</button>`
}
</div>
<div class="pl-8">
<input type="text" value="${slot.notes ? slot.notes.replace(/"/g, '&quot;') : ''}"
oninput="plannerSetSlotNotes(${idx}, this.value)"
placeholder="Location notes (optional)"
class="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-600 dark:text-gray-400 placeholder-gray-300 dark:placeholder-gray-600 focus:ring-1 focus:ring-blue-500">
</div>
`;
container.appendChild(row);
});
}
function plannerClearSlot(idx) {
plannerState.slots[idx].unit_id = null;
plannerState.slots[idx].power_type = null;
plannerRenderSlots();
plannerRenderUnits();
}
function plannerReset() {
plannerState = { reservation_id: null, slots: [], allUnits: [] };
document.getElementById('planner-name').value = '';
document.getElementById('planner-project').value = '';
document.getElementById('planner-start').value = '';
document.getElementById('planner-end').value = '';
document.getElementById('planner-notes').value = '';
document.getElementById('planner-est-units').value = '';
document.getElementById('planner-search').value = '';
const defaultDt = document.querySelector('input[name="planner_device_type"][value="seismograph"]');
if (defaultDt) defaultDt.checked = true;
document.getElementById('planner-deployed-only').checked = false;
document.getElementById('planner-avail-count').textContent = '';
document.querySelector('input[name="planner_color"][value="#3B82F6"]').checked = true;
const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = 'New Reservation';
document.getElementById('planner-save-btn').textContent = 'Save Reservation';
document.getElementById('planner-meta-fields').style.display = '';
plannerRenderSlots();
plannerRenderUnits();
}
async function plannerSave() {
const name = document.getElementById('planner-name').value.trim();
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
const projectId = document.getElementById('planner-project').value;
const notes = document.getElementById('planner-notes').value.trim();
const color = document.querySelector('input[name="planner_color"]:checked')?.value || '#3B82F6';
const estUnits = parseInt(document.getElementById('planner-est-units').value) || null;
const filledSlots = plannerState.slots.filter(s => s.unit_id);
if (!name) { alert('Please enter a reservation name.'); return; }
if (!start || !end) { alert('Please set start and end dates.'); return; }
if (end < start) { alert('End date must be after start date.'); return; }
const btn = document.getElementById('planner-save-btn');
btn.disabled = true;
btn.textContent = 'Saving...';
try {
const isEdit = !!plannerState.reservation_id;
const url = isEdit
? `/api/fleet-calendar/reservations/${plannerState.reservation_id}`
: '/api/fleet-calendar/reservations';
const method = isEdit ? 'PUT' : 'POST';
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
const payload = {
name, start_date: start, end_date: end,
project_id: projectId || null,
assignment_type: 'specific',
device_type: plannerDeviceType,
color, notes: notes || null,
quantity_needed: estUnits
};
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await resp.json();
if (!result.success) throw new Error(result.detail || 'Save failed');
const reservationId = isEdit ? plannerState.reservation_id : result.reservation_id;
// Always call assign-units (even with empty list) — endpoint does a full replace
const unitIds = filledSlots.map(s => s.unit_id);
const powerTypes = {};
const locationNotes = {};
filledSlots.forEach(s => {
if (s.power_type) powerTypes[s.unit_id] = s.power_type;
if (s.notes) locationNotes[s.unit_id] = s.notes;
});
const assignResp = await fetch(
`/api/fleet-calendar/reservations/${reservationId}/assign-units`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unit_ids: unitIds, power_types: powerTypes, location_notes: locationNotes })
}
);
const assignResult = await assignResp.json();
if (assignResult.conflicts && assignResult.conflicts.length > 0) {
const conflictIds = assignResult.conflicts.map(c => c.unit_id).join(', ');
alert(`Saved! Note: ${assignResult.conflicts.length} unit(s) had conflicts and were not assigned: ${conflictIds}`);
}
plannerReset();
switchPlannerTab('list');
// Reload the reservations list partial
htmx.trigger('#planner-reservations-list', 'load');
} catch (e) {
console.error('Planner save error', e);
alert('Error saving reservation: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = plannerState.reservation_id ? 'Save Changes' : 'Save Reservation';
}
}
async function openPlanner(reservationId) {
plannerReset();
if (reservationId) {
try {
const resp = await fetch(`/api/fleet-calendar/reservations/${reservationId}`);
const res = await resp.json();
plannerState.reservation_id = reservationId;
document.getElementById('planner-name').value = res.name;
document.getElementById('planner-project').value = res.project_id || '';
document.getElementById('planner-start').value = res.start_date;
document.getElementById('planner-end').value = res.end_date || '';
document.getElementById('planner-notes').value = res.notes || '';
document.getElementById('planner-est-units').value = res.quantity_needed || '';
const colorRadio = document.querySelector(`input[name="planner_color"][value="${res.color}"]`);
if (colorRadio) colorRadio.checked = true;
const dtRadio = document.querySelector(`input[name="planner_device_type"][value="${res.device_type || 'seismograph'}"]`);
if (dtRadio) dtRadio.checked = true;
// Pre-fill slots from existing assigned units
for (const u of (res.assigned_units || [])) {
plannerState.slots.push({ unit_id: u.id, power_type: u.power_type || null, notes: u.notes || null });
}
const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = res.name;
document.getElementById('planner-save-btn').textContent = 'Save Changes';
document.getElementById('planner-meta-fields').style.display = 'none';
plannerRenderSlots();
if (res.start_date && res.end_date) plannerLoadUnits();
} catch (e) {
console.error('Error loading reservation for planner', e);
}
}
switchTab('planner');
switchPlannerTab('assign');
}
</script>
{% endblock %}

View File

@@ -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.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{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-id="{{ unit.id }}"
data-type="{{ unit.device_type }}"
@@ -60,7 +60,9 @@
data-note="{{ unit.note if unit.note else '' }}">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
{% if not unit.deployed %}
{% if unit.out_for_calibration %}
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></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' %}
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
@@ -72,6 +74,8 @@
{% if unit.deployed %}
<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>
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
{% endif %}
@@ -203,14 +207,16 @@
<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.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{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-unit-id="{{ unit.id }}"
data-age="{{ unit.age }}">
<!-- Header: Status Dot + Unit ID + Status Badge -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
{% if not unit.deployed %}
{% if unit.out_for_calibration %}
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></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' %}
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
@@ -224,12 +230,13 @@
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
</div>
<span class="px-3 py-1 rounded-full text-xs font-medium
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-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.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
{% if unit.out_for_calibration %}Out for Cal{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
</span>
</div>
@@ -279,6 +286,10 @@
<span class="text-xs text-blue-600 dark:text-blue-400">
⚡ Deployed
</span>
{% elif unit.out_for_calibration %}
<span class="text-xs text-purple-600 dark:text-purple-400">
🔧 Out for Calibration
</span>
{% else %}
<span class="text-xs text-gray-500 dark:text-gray-500">
📦 Benched

View File

@@ -1,22 +1,36 @@
<!-- 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">
<!-- 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-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 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-1">
{{ res.start_date.strftime('%b %d, %Y') }} -
<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 %}
@@ -28,76 +42,123 @@
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
{% endif %}
</p>
{% if res.notes %}
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">{{ res.notes }}</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 '?' }}
<!-- Unit count -->
<div class="text-right mx-4 flex-shrink-0">
<p class="text-base font-bold text-gray-900 dark:text-white">
{% if res.quantity_needed %}
{{ item.assigned_count }}/{{ res.quantity_needed }}
{% 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' }}
{{ 'assigned' if item.assigned_count != 1 else 'assigned' }}
{% if res.quantity_needed %} needed{% endif %}
</p>
</div>
<div class="ml-4 flex items-center gap-2">
<button onclick="editReservation('{{ res.id }}')"
<!-- Action buttons (stop propagation so clicks don't toggle card) -->
<div class="flex items-center gap-1 flex-shrink-0">
<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="Plan 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>
<button onclick="event.stopPropagation(); 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">
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"/>
</svg>
</button>
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}')"
<button onclick="event.stopPropagation(); 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">
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"/>
</svg>
</button>
<!-- Chevron (not in stopPropagation zone so clicking it still toggles the card) -->
<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-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">
{% if res.quantity_needed %}
<div class="text-gray-500 dark:text-gray-400">Est. units needed</div>
<div class="font-medium text-gray-800 dark:text-gray-200">{{ res.quantity_needed }}</div>
{% endif %}
<div class="text-gray-500 dark:text-gray-400">Assigned</div>
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} unit{{ 's' if item.assigned_count != 1 else '' }}</div>
{% if res.quantity_needed and item.assigned_count < res.quantity_needed %}
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
<div class="font-medium text-amber-600 dark:text-amber-400">{{ res.quantity_needed - item.assigned_count }} more</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>
<button onclick="openUnitDetailModal('{{ u.id }}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline">{{ u.id }}</button>
<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>
{% 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');
}
}
function editReservation(id) {
// For now, just show alert - can implement edit modal later
alert('Edit functionality coming soon. For now, delete and recreate the reservation.');
}
</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-gray-500 dark:text-gray-400">No reservations found</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
</div>
{% endif %}

View File

@@ -31,6 +31,9 @@
<div>
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
{% if out_for_calibration > 0 %}
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ out_for_calibration }} out for calibration</p>
{% endif %}
</div>
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>

View File

@@ -106,6 +106,13 @@
</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">

View File

@@ -56,6 +56,7 @@
<option value="">All Status</option>
<option value="deployed">Deployed</option>
<option value="benched">Benched</option>
<option value="out_for_calibration">Out for Calibration</option>
</select>
<!-- Modem Filter -->

View File

@@ -92,7 +92,7 @@
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
</div>
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Retired</span>
<span class="text-sm text-gray-500 dark:text-gray-400">Unit Status</span>
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
</div>
</div>
@@ -497,19 +497,29 @@
</div>
</div>
<!-- Checkboxes -->
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
<!-- 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">
<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">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="retired" id="retired" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
<input type="checkbox" name="out_for_calibration" id="outForCalibration" value="true"
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>
</div>
<!-- Hidden field for retired — controlled by the Retire button below -->
<input type="hidden" name="retired" id="retired" value="">
<div id="retireButtonSection">
<button type="button" id="retireBtn"
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
onclick="toggleRetired()">
</button>
</div>
</div>
<!-- Notes -->
<div>
@@ -817,7 +827,16 @@ function populateViewMode() {
}
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
if (currentUnit.retired) {
document.getElementById('retiredStatus').textContent = 'Retired';
document.getElementById('retiredStatus').className = 'font-medium text-red-600 dark:text-red-400';
} 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 {
document.getElementById('retiredStatus').textContent = 'Active';
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
}
// Basic info
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
@@ -1009,7 +1028,9 @@ function populateEditForm() {
document.getElementById('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('retired').checked = currentUnit.retired;
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
updateRetireButton(currentUnit.retired);
document.getElementById('note').value = currentUnit.note || '';
// Seismograph fields
@@ -1153,6 +1174,25 @@ function cancelEdit() {
populateEditForm();
}
function updateRetireButton(isRetired) {
const btn = document.getElementById('retireBtn');
if (!btn) return;
if (isRetired) {
btn.textContent = 'Un-Retire Unit';
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600';
} else {
btn.textContent = 'Retire Unit';
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40';
}
}
function toggleRetired() {
const hiddenInput = document.getElementById('retired');
const isCurrentlyRetired = hiddenInput.value === 'true';
hiddenInput.value = isCurrentlyRetired ? '' : 'true';
updateRetireButton(!isCurrentlyRetired);
}
// Handle form submission
document.getElementById('editForm').addEventListener('submit', async function(e) {
e.preventDefault();