version bump to 0.6 #26
35
CHANGELOG.md
35
CHANGELOG.md
@@ -5,6 +5,40 @@ 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.6.0] - 2026-02-06
|
||||
|
||||
### Added
|
||||
- **Calendar & Reservation Mode**: Fleet calendar view with reservation system for scheduling device deployments
|
||||
- **Device Pairing Interface**: New two-column pairing page (`/pair-devices`) for linking recorders (seismographs/SLMs) with modems
|
||||
- Visual pairing interface with drag-and-drop style interactions
|
||||
- Fuzzy-search modem pairing for SLMs
|
||||
- Pairing options now accessible from modem page
|
||||
- Improved pair status sharing across views
|
||||
- **Modem Dashboard Enhancements**:
|
||||
- Modem model number now a dedicated configuration field with per-model options
|
||||
- Direct link to modem login page from unit detail view
|
||||
- Modem view converted to list format
|
||||
- **Seismograph List Improvements**:
|
||||
- Enhanced visibility with better filtering and sorting
|
||||
- Calibration dates now color-coded for quick status assessment
|
||||
- User sets date of previous calibration (not expiry) for clearer workflow
|
||||
- **SLMM Device Control Lock**: Prevents command flooding to NL-43 devices
|
||||
|
||||
### Changed
|
||||
- **Calibration Date UX**: Users now set the date of the previous calibration rather than upcoming expiry dates - more intuitive workflow
|
||||
- **Settings Persistence**: Settings save no longer reloads the page
|
||||
- **Tab State**: Tab state now persists in URL hash for better navigation
|
||||
- **Scheduler Management**: Schedule changes now cascade to individual events
|
||||
- **Dashboard Filtering**: Enhanced dashboard with additional filtering options and SLM status sync
|
||||
- **SLMM Polling Intervals**: Fixed and improved polling intervals for better responsiveness
|
||||
- **24-Hour Scheduler Cycle**: Improved cycle handling to prevent issues with scheduled downloads
|
||||
|
||||
### Fixed
|
||||
- **SLM Modal Fields**: Modal now only contains correct device-specific fields
|
||||
- **IP Address Handling**: IP address correctly passed via modem pairing
|
||||
- **Mobile Type Display**: Fixed incorrect device type display in roster and device tables
|
||||
- **SLMM Scheduled Downloads**: Fixed issues with scheduled download operations
|
||||
|
||||
## [0.5.1] - 2026-01-27
|
||||
|
||||
### Added
|
||||
@@ -399,6 +433,7 @@ No database migration required for v0.4.0. All new features use existing databas
|
||||
- Photo management per unit
|
||||
- Automated status categorization (OK/Pending/Missing)
|
||||
|
||||
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
|
||||
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
|
||||
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
|
||||
|
||||
16
README.md
16
README.md
@@ -1,4 +1,4 @@
|
||||
# Terra-View v0.5.1
|
||||
# Terra-View v0.6.0
|
||||
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
|
||||
@@ -496,6 +496,14 @@ docker compose down -v
|
||||
|
||||
## Release Highlights
|
||||
|
||||
### v0.6.0 — 2026-02-06
|
||||
- **Calendar & Reservation Mode**: Fleet calendar view with device deployment scheduling and reservation system
|
||||
- **Device Pairing Interface**: New `/pair-devices` page with two-column layout for linking recorders with modems, fuzzy-search, and visual pairing workflow
|
||||
- **Calibration UX Overhaul**: Users now set date of previous calibration (not expiry); seismograph list enhanced with color-coded calibration status, filtering, and sorting
|
||||
- **Modem Dashboard**: Model number as dedicated config, modem login links, list view format, and pairing options accessible from modem page
|
||||
- **SLMM Improvements**: Device control lock prevents command flooding, fixed polling intervals and scheduled downloads
|
||||
- **UI Polish**: Tab state persists in URL hash, settings save without reload, scheduler changes cascade to events, fixed mobile type display
|
||||
|
||||
### v0.4.3 — 2026-01-14
|
||||
- **Sound Level Meter workflow**: Roster manager surfaces SLM metadata, supports rename actions, and adds return-to-project navigation plus schedule/unit templates for project planning.
|
||||
- **Project insight panels**: Project dashboards now expose file and session lists so teams can see what each project stores before diving into units.
|
||||
@@ -571,9 +579,11 @@ MIT
|
||||
|
||||
## Version
|
||||
|
||||
**Current: 0.5.1** — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
||||
**Current: 0.6.0** — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
||||
|
||||
Previous: 0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
||||
Previous: 0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
||||
|
||||
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
||||
|
||||
0.4.3 — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.5.1"
|
||||
VERSION = "0.6.0"
|
||||
app = FastAPI(
|
||||
title="Seismo Fleet Manager",
|
||||
description="Backend API for managing seismograph fleet status",
|
||||
@@ -115,6 +115,10 @@ app.include_router(alerts.router)
|
||||
from backend.routers import recurring_schedules
|
||||
app.include_router(recurring_schedules.router)
|
||||
|
||||
# Fleet Calendar router
|
||||
from backend.routers import fleet_calendar
|
||||
app.include_router(fleet_calendar.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
|
||||
|
||||
103
backend/migrate_add_job_reservations.py
Normal file
103
backend/migrate_add_job_reservations.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Migration script to add job reservations for the Fleet Calendar feature.
|
||||
|
||||
This creates two tables:
|
||||
- job_reservations: Track future unit assignments for jobs/projects
|
||||
- job_reservation_units: Link specific units to reservations
|
||||
|
||||
Run this script once to migrate an existing database.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# Database path
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
|
||||
def migrate_database():
|
||||
"""Create the job_reservations and job_reservation_units tables"""
|
||||
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
print("The database will be created automatically when you run the application.")
|
||||
return
|
||||
|
||||
print(f"Migrating database: {DB_PATH}")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if job_reservations table already exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
|
||||
if cursor.fetchone():
|
||||
print("Migration already applied - job_reservations table exists")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("Creating job_reservations table...")
|
||||
|
||||
try:
|
||||
# Create job_reservations table
|
||||
cursor.execute("""
|
||||
CREATE TABLE job_reservations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
assignment_type TEXT NOT NULL DEFAULT 'quantity',
|
||||
device_type TEXT DEFAULT 'seismograph',
|
||||
quantity_needed INTEGER,
|
||||
notes TEXT,
|
||||
color TEXT DEFAULT '#3B82F6',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
print(" Created job_reservations table")
|
||||
|
||||
# Create indexes for job_reservations
|
||||
cursor.execute("CREATE INDEX idx_job_reservations_project_id ON job_reservations(project_id)")
|
||||
print(" Created index on project_id")
|
||||
|
||||
cursor.execute("CREATE INDEX idx_job_reservations_dates ON job_reservations(start_date, end_date)")
|
||||
print(" Created index on dates")
|
||||
|
||||
# Create job_reservation_units table
|
||||
print("Creating job_reservation_units table...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE job_reservation_units (
|
||||
id TEXT PRIMARY KEY,
|
||||
reservation_id TEXT NOT NULL,
|
||||
unit_id TEXT NOT NULL,
|
||||
assignment_source TEXT DEFAULT 'specific',
|
||||
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (reservation_id) REFERENCES job_reservations(id),
|
||||
FOREIGN KEY (unit_id) REFERENCES roster(id)
|
||||
)
|
||||
""")
|
||||
print(" Created job_reservation_units table")
|
||||
|
||||
# Create indexes for job_reservation_units
|
||||
cursor.execute("CREATE INDEX idx_job_reservation_units_reservation_id ON job_reservation_units(reservation_id)")
|
||||
print(" Created index on reservation_id")
|
||||
|
||||
cursor.execute("CREATE INDEX idx_job_reservation_units_unit_id ON job_reservation_units(unit_id)")
|
||||
print(" Created index on unit_id")
|
||||
|
||||
conn.commit()
|
||||
print("\nMigration completed successfully!")
|
||||
print("You can now use the Fleet Calendar to manage unit reservations.")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"\nError during migration: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
89
backend/migrate_add_tbd_dates.py
Normal file
89
backend/migrate_add_tbd_dates.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Migration: Add TBD date support to job reservations
|
||||
|
||||
Adds columns:
|
||||
- job_reservations.estimated_end_date: For planning when end is TBD
|
||||
- job_reservations.end_date_tbd: Boolean flag for TBD end dates
|
||||
- job_reservation_units.unit_start_date: Unit-specific start (for swaps)
|
||||
- job_reservation_units.unit_end_date: Unit-specific end (for swaps)
|
||||
- job_reservation_units.unit_end_tbd: Unit-specific TBD flag
|
||||
- job_reservation_units.notes: Notes for the assignment
|
||||
|
||||
Also makes job_reservations.end_date nullable.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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 new columns to job_reservations if they don't exist
|
||||
if 'estimated_end_date' not in existing_cols:
|
||||
print("Adding estimated_end_date column to job_reservations...")
|
||||
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_end_date DATE")
|
||||
|
||||
if 'end_date_tbd' not in existing_cols:
|
||||
print("Adding end_date_tbd column to job_reservations...")
|
||||
cursor.execute("ALTER TABLE job_reservations ADD COLUMN end_date_tbd BOOLEAN DEFAULT 0")
|
||||
|
||||
# Get existing columns in job_reservation_units
|
||||
cursor.execute("PRAGMA table_info(job_reservation_units)")
|
||||
unit_cols = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
# Add new columns to job_reservation_units if they don't exist
|
||||
if 'unit_start_date' not in unit_cols:
|
||||
print("Adding unit_start_date column to job_reservation_units...")
|
||||
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_start_date DATE")
|
||||
|
||||
if 'unit_end_date' not in unit_cols:
|
||||
print("Adding unit_end_date column to job_reservation_units...")
|
||||
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_end_date DATE")
|
||||
|
||||
if 'unit_end_tbd' not in unit_cols:
|
||||
print("Adding unit_end_tbd column to job_reservation_units...")
|
||||
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_end_tbd BOOLEAN DEFAULT 0")
|
||||
|
||||
if 'notes' not in unit_cols:
|
||||
print("Adding notes column to job_reservation_units...")
|
||||
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN notes TEXT")
|
||||
|
||||
conn.commit()
|
||||
print("Migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Default to dev database
|
||||
db_path = "./data-dev/seismo_fleet.db"
|
||||
|
||||
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)
|
||||
105
backend/migrate_fix_end_date_nullable.py
Normal file
105
backend/migrate_fix_end_date_nullable.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Migration: Make job_reservations.end_date nullable for TBD support
|
||||
|
||||
SQLite doesn't support ALTER COLUMN, so we need to:
|
||||
1. Create a new table with the correct schema
|
||||
2. Copy data
|
||||
3. Drop old table
|
||||
4. Rename new table
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
# Check current schema
|
||||
cursor.execute("PRAGMA table_info(job_reservations)")
|
||||
columns = cursor.fetchall()
|
||||
col_info = {row[1]: row for row in columns}
|
||||
|
||||
# Check if end_date is already nullable (notnull=0)
|
||||
if 'end_date' in col_info and col_info['end_date'][3] == 0:
|
||||
print("end_date is already nullable. Skipping table recreation.")
|
||||
return
|
||||
|
||||
print("Recreating job_reservations table with nullable end_date...")
|
||||
|
||||
# Create new table with correct schema
|
||||
cursor.execute("""
|
||||
CREATE TABLE job_reservations_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
estimated_end_date DATE,
|
||||
end_date_tbd BOOLEAN DEFAULT 0,
|
||||
assignment_type TEXT NOT NULL DEFAULT 'quantity',
|
||||
device_type TEXT DEFAULT 'seismograph',
|
||||
quantity_needed INTEGER,
|
||||
notes TEXT,
|
||||
color TEXT DEFAULT '#3B82F6',
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy existing data
|
||||
cursor.execute("""
|
||||
INSERT INTO job_reservations_new
|
||||
SELECT
|
||||
id, name, project_id, start_date, end_date,
|
||||
COALESCE(estimated_end_date, NULL) as estimated_end_date,
|
||||
COALESCE(end_date_tbd, 0) as end_date_tbd,
|
||||
assignment_type, device_type, quantity_needed, notes, color,
|
||||
created_at, updated_at
|
||||
FROM job_reservations
|
||||
""")
|
||||
|
||||
# Drop old table
|
||||
cursor.execute("DROP TABLE job_reservations")
|
||||
|
||||
# Rename new table
|
||||
cursor.execute("ALTER TABLE job_reservations_new RENAME TO job_reservations")
|
||||
|
||||
# Recreate index
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS ix_job_reservations_id ON job_reservations (id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS ix_job_reservations_project_id ON job_reservations (project_id)")
|
||||
|
||||
conn.commit()
|
||||
print("Migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Default to dev database
|
||||
db_path = "./data-dev/seismo_fleet.db"
|
||||
|
||||
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)
|
||||
@@ -402,3 +402,72 @@ class Alert(Base):
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fleet Calendar & Job Reservations
|
||||
# ============================================================================
|
||||
|
||||
class JobReservation(Base):
|
||||
"""
|
||||
Job reservations: reserve units for future jobs/projects.
|
||||
|
||||
Supports two assignment modes:
|
||||
- "specific": Pick exact units (SN-001, SN-002, etc.)
|
||||
- "quantity": Reserve a number of units (e.g., "need 8 seismographs")
|
||||
|
||||
Used by the Fleet Calendar to visualize unit availability over time.
|
||||
"""
|
||||
__tablename__ = "job_reservations"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
name = Column(String, nullable=False) # "Job A - March deployment"
|
||||
project_id = Column(String, nullable=True, index=True) # Optional FK to Project
|
||||
|
||||
# Date range for the reservation
|
||||
start_date = Column(Date, nullable=False)
|
||||
end_date = Column(Date, nullable=True) # Nullable = TBD / ongoing
|
||||
estimated_end_date = Column(Date, nullable=True) # For planning when end is TBD
|
||||
end_date_tbd = Column(Boolean, default=False) # True = end date unknown
|
||||
|
||||
# Assignment type: "specific" or "quantity"
|
||||
assignment_type = Column(String, nullable=False, default="quantity")
|
||||
|
||||
# For quantity reservations
|
||||
device_type = Column(String, default="seismograph") # seismograph | slm
|
||||
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
|
||||
|
||||
# Metadata
|
||||
notes = Column(Text, nullable=True)
|
||||
color = Column(String, default="#3B82F6") # For calendar display (blue default)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class JobReservationUnit(Base):
|
||||
"""
|
||||
Links specific units to job reservations.
|
||||
|
||||
Used when:
|
||||
- assignment_type="specific": Units are directly assigned
|
||||
- assignment_type="quantity": Units can be filled in later
|
||||
|
||||
Supports unit swaps: same reservation can have multiple units with
|
||||
different date ranges (e.g., BE17353 Feb-Jun, then BE18438 Jun-Nov).
|
||||
"""
|
||||
__tablename__ = "job_reservation_units"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
reservation_id = Column(String, nullable=False, index=True) # FK to JobReservation
|
||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit
|
||||
|
||||
# Unit-specific date range (for swaps) - defaults to reservation dates if null
|
||||
unit_start_date = Column(Date, nullable=True) # When this specific unit starts
|
||||
unit_end_date = Column(Date, nullable=True) # When this unit ends (swap out date)
|
||||
unit_end_tbd = Column(Boolean, default=False) # True = end unknown (until cal expires or job ends)
|
||||
|
||||
# Track how this assignment was made
|
||||
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
|
||||
|
||||
610
backend/routers/fleet_calendar.py
Normal file
610
backend/routers/fleet_calendar.py
Normal file
@@ -0,0 +1,610 @@
|
||||
"""
|
||||
Fleet Calendar Router
|
||||
|
||||
API endpoints for the Fleet Calendar feature:
|
||||
- Calendar page and data
|
||||
- Job reservation CRUD
|
||||
- Unit assignment management
|
||||
- Availability checking
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional, List
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import (
|
||||
RosterUnit, JobReservation, JobReservationUnit,
|
||||
UserPreferences, Project
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
from backend.services.fleet_calendar_service import (
|
||||
get_day_summary,
|
||||
get_calendar_year_data,
|
||||
get_rolling_calendar_data,
|
||||
check_calibration_conflicts,
|
||||
get_available_units_for_period,
|
||||
get_calibration_status
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["fleet-calendar"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Calendar Page
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/fleet-calendar", response_class=HTMLResponse)
|
||||
async def fleet_calendar_page(
|
||||
request: Request,
|
||||
year: Optional[int] = None,
|
||||
month: Optional[int] = None,
|
||||
device_type: str = "seismograph",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Main Fleet Calendar page with rolling 12-month view."""
|
||||
today = date.today()
|
||||
|
||||
# Default to current month as the start
|
||||
if year is None:
|
||||
year = today.year
|
||||
if month is None:
|
||||
month = today.month
|
||||
|
||||
# Get calendar data for 12 months starting from year/month
|
||||
calendar_data = get_rolling_calendar_data(db, year, month, device_type)
|
||||
|
||||
# Get projects for the reservation form dropdown
|
||||
projects = db.query(Project).filter(
|
||||
Project.status == "active"
|
||||
).order_by(Project.name).all()
|
||||
|
||||
# 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)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"fleet_calendar.html",
|
||||
{
|
||||
"request": request,
|
||||
"start_year": year,
|
||||
"start_month": month,
|
||||
"prev_year": prev_year,
|
||||
"prev_month": prev_month,
|
||||
"next_year": next_year,
|
||||
"next_month": next_month,
|
||||
"device_type": device_type,
|
||||
"calendar_data": calendar_data,
|
||||
"projects": projects,
|
||||
"today": today.isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Calendar Data API
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/api/fleet-calendar/data", response_class=JSONResponse)
|
||||
async def get_calendar_data(
|
||||
year: int,
|
||||
device_type: str = "seismograph",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get calendar data for a specific year."""
|
||||
return get_calendar_year_data(db, year, device_type)
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/day/{date_str}", response_class=HTMLResponse)
|
||||
async def get_day_detail(
|
||||
request: Request,
|
||||
date_str: str,
|
||||
device_type: str = "seismograph",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get detailed view for a specific day (HTMX partial)."""
|
||||
try:
|
||||
check_date = date.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
day_data = get_day_summary(db, check_date, device_type)
|
||||
|
||||
# Get projects for display names
|
||||
projects = {p.id: p for p in db.query(Project).all()}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/fleet_calendar/day_detail.html",
|
||||
{
|
||||
"request": request,
|
||||
"day_data": day_data,
|
||||
"date_str": date_str,
|
||||
"date_display": check_date.strftime("%B %d, %Y"),
|
||||
"device_type": device_type,
|
||||
"projects": projects
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Reservation CRUD
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/api/fleet-calendar/reservations", response_class=JSONResponse)
|
||||
async def create_reservation(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new job reservation."""
|
||||
data = await request.json()
|
||||
|
||||
# Validate required fields
|
||||
required = ["name", "start_date", "assignment_type"]
|
||||
for field in required:
|
||||
if field not in data:
|
||||
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
||||
|
||||
# Need either end_date or end_date_tbd
|
||||
end_date_tbd = data.get("end_date_tbd", False)
|
||||
if not end_date_tbd and not data.get("end_date"):
|
||||
raise HTTPException(status_code=400, detail="End date is required unless marked as TBD")
|
||||
|
||||
try:
|
||||
start_date = date.fromisoformat(data["start_date"])
|
||||
end_date = date.fromisoformat(data["end_date"]) if data.get("end_date") else None
|
||||
estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data.get("estimated_end_date") else None
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if end_date and end_date < start_date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
if estimated_end_date and estimated_end_date < start_date:
|
||||
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
|
||||
|
||||
reservation = JobReservation(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data["name"],
|
||||
project_id=data.get("project_id"),
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
estimated_end_date=estimated_end_date,
|
||||
end_date_tbd=end_date_tbd,
|
||||
assignment_type=data["assignment_type"],
|
||||
device_type=data.get("device_type", "seismograph"),
|
||||
quantity_needed=data.get("quantity_needed"),
|
||||
notes=data.get("notes"),
|
||||
color=data.get("color", "#3B82F6")
|
||||
)
|
||||
|
||||
db.add(reservation)
|
||||
|
||||
# If specific units were provided, assign them
|
||||
if data.get("unit_ids") and data["assignment_type"] == "specific":
|
||||
for unit_id in data["unit_ids"]:
|
||||
assignment = JobReservationUnit(
|
||||
id=str(uuid.uuid4()),
|
||||
reservation_id=reservation.id,
|
||||
unit_id=unit_id,
|
||||
assignment_source="specific"
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Created reservation: {reservation.name} ({reservation.id})")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"reservation_id": reservation.id,
|
||||
"message": f"Created reservation: {reservation.name}"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse)
|
||||
async def get_reservation(
|
||||
reservation_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific reservation with its assigned units."""
|
||||
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
|
||||
if not reservation:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
# Get assigned units
|
||||
assignments = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=reservation_id
|
||||
).all()
|
||||
|
||||
unit_ids = [a.unit_id for a in assignments]
|
||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||
|
||||
return {
|
||||
"id": reservation.id,
|
||||
"name": reservation.name,
|
||||
"project_id": reservation.project_id,
|
||||
"start_date": reservation.start_date.isoformat(),
|
||||
"end_date": reservation.end_date.isoformat() if reservation.end_date else None,
|
||||
"estimated_end_date": reservation.estimated_end_date.isoformat() if reservation.estimated_end_date else None,
|
||||
"end_date_tbd": reservation.end_date_tbd,
|
||||
"assignment_type": reservation.assignment_type,
|
||||
"device_type": reservation.device_type,
|
||||
"quantity_needed": reservation.quantity_needed,
|
||||
"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
|
||||
}
|
||||
for u in units
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse)
|
||||
async def update_reservation(
|
||||
reservation_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an existing reservation."""
|
||||
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()
|
||||
|
||||
# Update fields if provided
|
||||
if "name" in data:
|
||||
reservation.name = data["name"]
|
||||
if "project_id" in data:
|
||||
reservation.project_id = data["project_id"]
|
||||
if "start_date" in data:
|
||||
reservation.start_date = date.fromisoformat(data["start_date"])
|
||||
if "end_date" in data:
|
||||
reservation.end_date = date.fromisoformat(data["end_date"]) if data["end_date"] else None
|
||||
if "estimated_end_date" in data:
|
||||
reservation.estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data["estimated_end_date"] else None
|
||||
if "end_date_tbd" in data:
|
||||
reservation.end_date_tbd = data["end_date_tbd"]
|
||||
if "assignment_type" in data:
|
||||
reservation.assignment_type = data["assignment_type"]
|
||||
if "quantity_needed" in data:
|
||||
reservation.quantity_needed = data["quantity_needed"]
|
||||
if "notes" in data:
|
||||
reservation.notes = data["notes"]
|
||||
if "color" in data:
|
||||
reservation.color = data["color"]
|
||||
|
||||
reservation.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Updated reservation: {reservation.name} ({reservation.id})")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Updated reservation: {reservation.name}"
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse)
|
||||
async def delete_reservation(
|
||||
reservation_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a reservation and its unit assignments."""
|
||||
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
|
||||
if not reservation:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
# Delete unit assignments first
|
||||
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
|
||||
|
||||
# Delete the reservation
|
||||
db.delete(reservation)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Deleted reservation: {reservation.name} ({reservation_id})")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Reservation deleted"
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Assignment
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/api/fleet-calendar/reservations/{reservation_id}/assign-units", response_class=JSONResponse)
|
||||
async def assign_units_to_reservation(
|
||||
reservation_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Assign specific units to a reservation."""
|
||||
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()
|
||||
unit_ids = data.get("unit_ids", [])
|
||||
|
||||
if not unit_ids:
|
||||
raise HTTPException(status_code=400, detail="No units specified")
|
||||
|
||||
# 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)}")
|
||||
|
||||
# Check for conflicts (already assigned to overlapping reservations)
|
||||
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 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"
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Check for calibration conflicts
|
||||
cal_conflicts = check_calibration_conflicts(db, reservation_id)
|
||||
|
||||
assigned_count = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=reservation_id
|
||||
).count()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"assigned_count": assigned_count,
|
||||
"conflicts": conflicts,
|
||||
"calibration_warnings": cal_conflicts,
|
||||
"message": f"Assigned {len(unit_ids) - len(conflicts)} units"
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/fleet-calendar/reservations/{reservation_id}/units/{unit_id}", response_class=JSONResponse)
|
||||
async def remove_unit_from_reservation(
|
||||
reservation_id: str,
|
||||
unit_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove a unit from a reservation."""
|
||||
assignment = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=reservation_id,
|
||||
unit_id=unit_id
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Unit assignment not found")
|
||||
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Removed {unit_id} from reservation"
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Availability & Conflicts
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/api/fleet-calendar/availability", response_class=JSONResponse)
|
||||
async def check_availability(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
device_type: str = "seismograph",
|
||||
exclude_reservation_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get units available for a specific date range."""
|
||||
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")
|
||||
|
||||
available = get_available_units_for_period(
|
||||
db, start, end, device_type, exclude_reservation_id
|
||||
)
|
||||
|
||||
return {
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"device_type": device_type,
|
||||
"available_units": available,
|
||||
"count": len(available)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/reservations/{reservation_id}/conflicts", response_class=JSONResponse)
|
||||
async def get_reservation_conflicts(
|
||||
reservation_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check for calibration conflicts in a reservation."""
|
||||
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
|
||||
if not reservation:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
conflicts = check_calibration_conflicts(db, reservation_id)
|
||||
|
||||
return {
|
||||
"reservation_id": reservation_id,
|
||||
"reservation_name": reservation.name,
|
||||
"conflicts": conflicts,
|
||||
"has_conflicts": len(conflicts) > 0
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HTMX Partials
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/api/fleet-calendar/reservations-list", response_class=HTMLResponse)
|
||||
async def get_reservations_list(
|
||||
request: Request,
|
||||
year: Optional[int] = None,
|
||||
month: Optional[int] = None,
|
||||
device_type: str = "seismograph",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get list of reservations as HTMX partial."""
|
||||
from sqlalchemy import or_
|
||||
|
||||
today = date.today()
|
||||
if year is None:
|
||||
year = today.year
|
||||
if month is None:
|
||||
month = today.month
|
||||
|
||||
# Calculate 12-month window
|
||||
start_date = date(year, month, 1)
|
||||
# End date is 12 months later
|
||||
end_year = year + ((month + 10) // 12)
|
||||
end_month = ((month + 10) % 12) + 1
|
||||
if end_month == 12:
|
||||
end_date = date(end_year, 12, 31)
|
||||
else:
|
||||
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
||||
|
||||
# Include TBD reservations that started before window end
|
||||
reservations = db.query(JobReservation).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.start_date <= end_date,
|
||||
or_(
|
||||
JobReservation.end_date >= start_date,
|
||||
JobReservation.end_date == None # TBD reservations
|
||||
)
|
||||
).order_by(JobReservation.start_date).all()
|
||||
|
||||
# Get assignment counts
|
||||
reservation_data = []
|
||||
for res in reservations:
|
||||
assigned_count = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=res.id
|
||||
).count()
|
||||
|
||||
# Check for calibration conflicts
|
||||
conflicts = check_calibration_conflicts(db, res.id)
|
||||
|
||||
reservation_data.append({
|
||||
"reservation": res,
|
||||
"assigned_count": assigned_count,
|
||||
"has_conflicts": len(conflicts) > 0,
|
||||
"conflict_count": len(conflicts)
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/fleet_calendar/reservations_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"reservations": reservation_data,
|
||||
"year": year,
|
||||
"device_type": device_type
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
||||
async def get_available_units_partial(
|
||||
request: Request,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
device_type: str = "seismograph",
|
||||
reservation_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get available units as HTMX partial for the assignment modal."""
|
||||
try:
|
||||
start = date.fromisoformat(start_date)
|
||||
end = date.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||
|
||||
available = get_available_units_for_period(
|
||||
db, start, end, device_type, reservation_id
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/fleet_calendar/available_units.html",
|
||||
{
|
||||
"request": request,
|
||||
"units": available,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"device_type": device_type,
|
||||
"reservation_id": reservation_id
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/month/{year}/{month}", response_class=HTMLResponse)
|
||||
async def get_month_partial(
|
||||
request: Request,
|
||||
year: int,
|
||||
month: int,
|
||||
device_type: str = "seismograph",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a single month calendar as HTMX partial."""
|
||||
calendar_data = get_calendar_year_data(db, year, device_type)
|
||||
month_data = calendar_data["months"].get(month)
|
||||
|
||||
if not month_data:
|
||||
raise HTTPException(status_code=404, detail="Invalid month")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/fleet_calendar/month_grid.html",
|
||||
{
|
||||
"request": request,
|
||||
"year": year,
|
||||
"month": month,
|
||||
"month_data": month_data,
|
||||
"device_type": device_type,
|
||||
"today": date.today().isoformat()
|
||||
}
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request, Query
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date
|
||||
from datetime import datetime, date, timedelta
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
@@ -9,12 +9,20 @@ import httpx
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences
|
||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||
|
||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_calibration_interval(db: Session) -> int:
|
||||
"""Get calibration interval from user preferences, default 365 days."""
|
||||
prefs = db.query(UserPreferences).first()
|
||||
if prefs and prefs.calibration_interval_days:
|
||||
return prefs.calibration_interval_days
|
||||
return 365
|
||||
|
||||
# SLMM backend URL for syncing device configs to cache
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
@@ -185,8 +193,13 @@ async def add_roster_unit(
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||
|
||||
# Auto-calculate next_calibration_due from last_calibrated using calibration interval
|
||||
next_cal_date = None
|
||||
if next_calibration_due:
|
||||
if last_cal_date:
|
||||
cal_interval = get_calibration_interval(db)
|
||||
next_cal_date = last_cal_date + timedelta(days=cal_interval)
|
||||
elif next_calibration_due:
|
||||
# Fallback: allow explicit setting if no last_calibrated
|
||||
try:
|
||||
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
@@ -517,8 +530,13 @@ async def edit_roster_unit(
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||
|
||||
# Auto-calculate next_calibration_due from last_calibrated using calibration interval
|
||||
next_cal_date = None
|
||||
if next_calibration_due:
|
||||
if last_cal_date:
|
||||
cal_interval = get_calibration_interval(db)
|
||||
next_cal_date = last_cal_date + timedelta(days=cal_interval)
|
||||
elif next_calibration_due:
|
||||
# Fallback: allow explicit setting if no last_calibrated
|
||||
try:
|
||||
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
@@ -995,8 +1013,14 @@ async def import_csv(
|
||||
|
||||
# Seismograph-specific fields
|
||||
if row.get('last_calibrated'):
|
||||
existing_unit.last_calibrated = _parse_date(row.get('last_calibrated'))
|
||||
if row.get('next_calibration_due'):
|
||||
last_cal = _parse_date(row.get('last_calibrated'))
|
||||
existing_unit.last_calibrated = last_cal
|
||||
# Auto-calculate next_calibration_due using calibration interval
|
||||
if last_cal:
|
||||
cal_interval = get_calibration_interval(db)
|
||||
existing_unit.next_calibration_due = last_cal + timedelta(days=cal_interval)
|
||||
elif row.get('next_calibration_due'):
|
||||
# Only use explicit next_calibration_due if no last_calibrated
|
||||
existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due'))
|
||||
if row.get('deployed_with_modem_id'):
|
||||
existing_unit.deployed_with_modem_id = _get_csv_value(row, 'deployed_with_modem_id')
|
||||
@@ -1033,6 +1057,14 @@ async def import_csv(
|
||||
|
||||
results["updated"].append(unit_id)
|
||||
else:
|
||||
# Calculate next_calibration_due from last_calibrated
|
||||
last_cal = _parse_date(row.get('last_calibrated', ''))
|
||||
if last_cal:
|
||||
cal_interval = get_calibration_interval(db)
|
||||
next_cal = last_cal + timedelta(days=cal_interval)
|
||||
else:
|
||||
next_cal = _parse_date(row.get('next_calibration_due', ''))
|
||||
|
||||
# Create new unit with all fields
|
||||
new_unit = RosterUnit(
|
||||
id=unit_id,
|
||||
@@ -1046,9 +1078,9 @@ async def import_csv(
|
||||
address=_get_csv_value(row, 'address'),
|
||||
coordinates=_get_csv_value(row, 'coordinates'),
|
||||
last_updated=datetime.utcnow(),
|
||||
# Seismograph fields
|
||||
last_calibrated=_parse_date(row.get('last_calibrated', '')),
|
||||
next_calibration_due=_parse_date(row.get('next_calibration_due', '')),
|
||||
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
||||
last_calibrated=last_cal,
|
||||
next_calibration_due=next_cal,
|
||||
deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'),
|
||||
# Modem fields
|
||||
ip_address=_get_csv_value(row, 'ip_address'),
|
||||
|
||||
@@ -3,6 +3,8 @@ Seismograph Dashboard API Router
|
||||
Provides endpoints for the seismograph-specific dashboard
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -49,10 +51,14 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
||||
async def get_seismo_units(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
search: str = Query(None)
|
||||
search: str = Query(None),
|
||||
sort: str = Query("id"),
|
||||
order: str = Query("asc"),
|
||||
status: str = Query(None),
|
||||
modem: str = Query(None)
|
||||
):
|
||||
"""
|
||||
Returns HTML partial with filterable seismograph unit list
|
||||
Returns HTML partial with filterable and sortable seismograph unit list
|
||||
"""
|
||||
query = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
@@ -61,20 +67,52 @@ async def get_seismo_units(
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
query = query.filter(
|
||||
(RosterUnit.id.ilike(f"%{search}%")) |
|
||||
(RosterUnit.note.ilike(f"%{search}%")) |
|
||||
(RosterUnit.address.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
seismos = query.order_by(RosterUnit.id).all()
|
||||
# Apply status filter
|
||||
if status == "deployed":
|
||||
query = query.filter(RosterUnit.deployed == True)
|
||||
elif status == "benched":
|
||||
query = query.filter(RosterUnit.deployed == False)
|
||||
|
||||
# Apply modem filter
|
||||
if modem == "with":
|
||||
query = query.filter(RosterUnit.deployed_with_modem_id.isnot(None))
|
||||
elif modem == "without":
|
||||
query = query.filter(RosterUnit.deployed_with_modem_id.is_(None))
|
||||
|
||||
# Apply sorting
|
||||
sort_column_map = {
|
||||
"id": RosterUnit.id,
|
||||
"status": RosterUnit.deployed,
|
||||
"modem": RosterUnit.deployed_with_modem_id,
|
||||
"location": RosterUnit.address,
|
||||
"last_calibrated": RosterUnit.last_calibrated,
|
||||
"notes": RosterUnit.note
|
||||
}
|
||||
sort_column = sort_column_map.get(sort, RosterUnit.id)
|
||||
|
||||
if order == "desc":
|
||||
query = query.order_by(sort_column.desc())
|
||||
else:
|
||||
query = query.order_by(sort_column.asc())
|
||||
|
||||
seismos = query.all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/seismo_unit_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"units": seismos,
|
||||
"search": search or ""
|
||||
"search": search or "",
|
||||
"sort": sort,
|
||||
"order": order,
|
||||
"status": status or "",
|
||||
"modem": modem or "",
|
||||
"today": date.today()
|
||||
}
|
||||
)
|
||||
|
||||
668
backend/services/fleet_calendar_service.py
Normal file
668
backend/services/fleet_calendar_service.py
Normal file
@@ -0,0 +1,668 @@
|
||||
"""
|
||||
Fleet Calendar Service
|
||||
|
||||
Business logic for:
|
||||
- Calculating unit availability on any given date
|
||||
- Calibration status tracking (valid, expiring soon, expired)
|
||||
- Job reservation management
|
||||
- Conflict detection (calibration expires mid-job)
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from backend.models import (
|
||||
RosterUnit, JobReservation, JobReservationUnit,
|
||||
UserPreferences, Project
|
||||
)
|
||||
|
||||
|
||||
def get_calibration_status(
|
||||
unit: RosterUnit,
|
||||
check_date: date,
|
||||
warning_days: int = 30
|
||||
) -> str:
|
||||
"""
|
||||
Determine calibration status for a unit on a specific date.
|
||||
|
||||
Returns:
|
||||
"valid" - Calibration is good on this date
|
||||
"expiring_soon" - Within warning_days of expiry
|
||||
"expired" - Calibration has expired
|
||||
"needs_calibration" - No calibration date set
|
||||
"""
|
||||
if not unit.last_calibrated:
|
||||
return "needs_calibration"
|
||||
|
||||
# Calculate expiry date (1 year from last calibration)
|
||||
expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||
|
||||
if check_date >= expiry_date:
|
||||
return "expired"
|
||||
elif check_date >= expiry_date - timedelta(days=warning_days):
|
||||
return "expiring_soon"
|
||||
else:
|
||||
return "valid"
|
||||
|
||||
|
||||
def get_unit_reservations_on_date(
|
||||
db: Session,
|
||||
unit_id: str,
|
||||
check_date: date
|
||||
) -> List[JobReservation]:
|
||||
"""Get all reservations that include this unit on the given date."""
|
||||
|
||||
# Get reservation IDs that have this unit assigned
|
||||
assigned_reservation_ids = db.query(JobReservationUnit.reservation_id).filter(
|
||||
JobReservationUnit.unit_id == unit_id
|
||||
).subquery()
|
||||
|
||||
# Get reservations that:
|
||||
# 1. Have this unit assigned AND date is within range
|
||||
reservations = db.query(JobReservation).filter(
|
||||
JobReservation.id.in_(assigned_reservation_ids),
|
||||
JobReservation.start_date <= check_date,
|
||||
JobReservation.end_date >= check_date
|
||||
).all()
|
||||
|
||||
return reservations
|
||||
|
||||
|
||||
def is_unit_available_on_date(
|
||||
db: Session,
|
||||
unit: RosterUnit,
|
||||
check_date: date,
|
||||
warning_days: int = 30
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Check if a unit is available on a specific 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)
|
||||
"""
|
||||
# Check if retired
|
||||
if unit.retired:
|
||||
return False, "retired", None
|
||||
|
||||
# Check calibration status
|
||||
cal_status = get_calibration_status(unit, check_date, warning_days)
|
||||
if cal_status == "expired":
|
||||
return False, "expired", None
|
||||
if cal_status == "needs_calibration":
|
||||
return False, "needs_calibration", None
|
||||
|
||||
# Check if already reserved
|
||||
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
|
||||
if reservations:
|
||||
return False, "reserved", reservations[0].name
|
||||
|
||||
# Unit is available (even if expiring soon - that's just a warning)
|
||||
return True, "available", None
|
||||
|
||||
|
||||
def get_day_summary(
|
||||
db: Session,
|
||||
check_date: date,
|
||||
device_type: str = "seismograph"
|
||||
) -> Dict:
|
||||
"""
|
||||
Get a complete summary of fleet status for a specific day.
|
||||
|
||||
Returns dict with:
|
||||
- available_units: List of available unit IDs with calibration info
|
||||
- reserved_units: List of reserved unit IDs with reservation info
|
||||
- expired_units: List of units with expired calibration
|
||||
- expiring_soon_units: List of units expiring within warning period
|
||||
- reservations: List of active reservations on this date
|
||||
- counts: Summary counts
|
||||
"""
|
||||
# Get user preferences for warning days
|
||||
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||
warning_days = prefs.calibration_warning_days if prefs else 30
|
||||
|
||||
# Get all non-retired units of the specified device type
|
||||
units = db.query(RosterUnit).filter(
|
||||
RosterUnit.device_type == device_type,
|
||||
RosterUnit.retired == False
|
||||
).all()
|
||||
|
||||
available_units = []
|
||||
reserved_units = []
|
||||
expired_units = []
|
||||
expiring_soon_units = []
|
||||
needs_calibration_units = []
|
||||
cal_expiring_today = [] # Units whose calibration expires ON this day
|
||||
|
||||
for unit in units:
|
||||
is_avail, status, reservation_name = is_unit_available_on_date(
|
||||
db, unit, check_date, warning_days
|
||||
)
|
||||
|
||||
cal_status = get_calibration_status(unit, check_date, warning_days)
|
||||
expiry_date = None
|
||||
if unit.last_calibrated:
|
||||
expiry_date = (unit.last_calibrated + timedelta(days=365)).isoformat()
|
||||
|
||||
unit_info = {
|
||||
"id": unit.id,
|
||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||
"expiry_date": expiry_date,
|
||||
"calibration_status": cal_status,
|
||||
"deployed": unit.deployed,
|
||||
"note": unit.note or ""
|
||||
}
|
||||
|
||||
# Check if calibration expires ON this specific day
|
||||
if unit.last_calibrated:
|
||||
unit_expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||
if unit_expiry_date == check_date:
|
||||
cal_expiring_today.append(unit_info)
|
||||
|
||||
if status == "available":
|
||||
available_units.append(unit_info)
|
||||
if cal_status == "expiring_soon":
|
||||
expiring_soon_units.append(unit_info)
|
||||
elif status == "reserved":
|
||||
unit_info["reservation_name"] = reservation_name
|
||||
reserved_units.append(unit_info)
|
||||
if cal_status == "expiring_soon":
|
||||
expiring_soon_units.append(unit_info)
|
||||
elif status == "expired":
|
||||
expired_units.append(unit_info)
|
||||
elif status == "needs_calibration":
|
||||
needs_calibration_units.append(unit_info)
|
||||
|
||||
# Get active reservations on this date
|
||||
reservations = db.query(JobReservation).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.start_date <= check_date,
|
||||
JobReservation.end_date >= check_date
|
||||
).all()
|
||||
|
||||
reservation_list = []
|
||||
for res in reservations:
|
||||
# Count assigned units for this reservation
|
||||
assigned_count = db.query(JobReservationUnit).filter(
|
||||
JobReservationUnit.reservation_id == res.id
|
||||
).count()
|
||||
|
||||
reservation_list.append({
|
||||
"id": res.id,
|
||||
"name": res.name,
|
||||
"start_date": res.start_date.isoformat(),
|
||||
"end_date": res.end_date.isoformat(),
|
||||
"assignment_type": res.assignment_type,
|
||||
"quantity_needed": res.quantity_needed,
|
||||
"assigned_count": assigned_count,
|
||||
"color": res.color,
|
||||
"project_id": res.project_id
|
||||
})
|
||||
|
||||
return {
|
||||
"date": check_date.isoformat(),
|
||||
"device_type": device_type,
|
||||
"available_units": available_units,
|
||||
"reserved_units": reserved_units,
|
||||
"expired_units": expired_units,
|
||||
"expiring_soon_units": expiring_soon_units,
|
||||
"needs_calibration_units": needs_calibration_units,
|
||||
"cal_expiring_today": cal_expiring_today,
|
||||
"reservations": reservation_list,
|
||||
"counts": {
|
||||
"available": len(available_units),
|
||||
"reserved": len(reserved_units),
|
||||
"expired": len(expired_units),
|
||||
"expiring_soon": len(expiring_soon_units),
|
||||
"needs_calibration": len(needs_calibration_units),
|
||||
"cal_expiring_today": len(cal_expiring_today),
|
||||
"total": len(units)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_calendar_year_data(
|
||||
db: Session,
|
||||
year: int,
|
||||
device_type: str = "seismograph"
|
||||
) -> Dict:
|
||||
"""
|
||||
Get calendar data for an entire year.
|
||||
|
||||
For performance, this returns summary counts per day rather than
|
||||
full unit lists. Use get_day_summary() for detailed day data.
|
||||
"""
|
||||
# Get user preferences
|
||||
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||
warning_days = prefs.calibration_warning_days if prefs else 30
|
||||
|
||||
# Get all units
|
||||
units = db.query(RosterUnit).filter(
|
||||
RosterUnit.device_type == device_type,
|
||||
RosterUnit.retired == False
|
||||
).all()
|
||||
|
||||
# Get all reservations that overlap with this year
|
||||
# Include TBD reservations (end_date is null) that started before year end
|
||||
year_start = date(year, 1, 1)
|
||||
year_end = date(year, 12, 31)
|
||||
|
||||
reservations = db.query(JobReservation).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.start_date <= year_end,
|
||||
or_(
|
||||
JobReservation.end_date >= year_start,
|
||||
JobReservation.end_date == None # TBD reservations
|
||||
)
|
||||
).all()
|
||||
|
||||
# Get all unit assignments for these reservations
|
||||
reservation_ids = [r.id for r in reservations]
|
||||
assignments = db.query(JobReservationUnit).filter(
|
||||
JobReservationUnit.reservation_id.in_(reservation_ids)
|
||||
).all() if reservation_ids else []
|
||||
|
||||
# Build a lookup: unit_id -> list of (start_date, end_date, reservation_name)
|
||||
# For TBD reservations, use estimated_end_date if available, or a far future date
|
||||
unit_reservations = {}
|
||||
for res in reservations:
|
||||
res_assignments = [a for a in assignments if a.reservation_id == res.id]
|
||||
for assignment in res_assignments:
|
||||
unit_id = assignment.unit_id
|
||||
# Use unit-specific dates if set, otherwise use reservation dates
|
||||
start_d = assignment.unit_start_date or res.start_date
|
||||
if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd):
|
||||
# TBD: use estimated date or far future for availability calculation
|
||||
end_d = res.estimated_end_date or date(year + 5, 12, 31)
|
||||
else:
|
||||
end_d = assignment.unit_end_date or res.end_date or date(year + 5, 12, 31)
|
||||
|
||||
if unit_id not in unit_reservations:
|
||||
unit_reservations[unit_id] = []
|
||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||
|
||||
# Generate data for each month
|
||||
months_data = {}
|
||||
|
||||
for month in range(1, 13):
|
||||
# Get first and last day of month
|
||||
first_day = date(year, month, 1)
|
||||
if month == 12:
|
||||
last_day = date(year, 12, 31)
|
||||
else:
|
||||
last_day = date(year, month + 1, 1) - timedelta(days=1)
|
||||
|
||||
days_data = {}
|
||||
current_day = first_day
|
||||
|
||||
while current_day <= last_day:
|
||||
available = 0
|
||||
reserved = 0
|
||||
expired = 0
|
||||
expiring_soon = 0
|
||||
needs_cal = 0
|
||||
cal_expiring_on_day = 0 # Units whose calibration expires ON this day
|
||||
cal_expired_on_day = 0 # Units whose calibration expired ON this day
|
||||
|
||||
for unit in units:
|
||||
# Check calibration
|
||||
cal_status = get_calibration_status(unit, current_day, warning_days)
|
||||
|
||||
# Check if calibration expires/expired ON this specific day
|
||||
if unit.last_calibrated:
|
||||
unit_expiry = unit.last_calibrated + timedelta(days=365)
|
||||
if unit_expiry == current_day:
|
||||
cal_expiring_on_day += 1
|
||||
# Check if expired yesterday (first day of being expired)
|
||||
elif unit_expiry == current_day - timedelta(days=1):
|
||||
cal_expired_on_day += 1
|
||||
|
||||
if cal_status == "expired":
|
||||
expired += 1
|
||||
continue
|
||||
if cal_status == "needs_calibration":
|
||||
needs_cal += 1
|
||||
continue
|
||||
|
||||
# Check if reserved
|
||||
is_reserved = False
|
||||
if unit.id in unit_reservations:
|
||||
for start_d, end_d, _ in unit_reservations[unit.id]:
|
||||
if start_d <= current_day <= end_d:
|
||||
is_reserved = True
|
||||
break
|
||||
|
||||
if is_reserved:
|
||||
reserved += 1
|
||||
else:
|
||||
available += 1
|
||||
|
||||
if cal_status == "expiring_soon":
|
||||
expiring_soon += 1
|
||||
|
||||
days_data[current_day.day] = {
|
||||
"available": available,
|
||||
"reserved": reserved,
|
||||
"expired": expired,
|
||||
"expiring_soon": expiring_soon,
|
||||
"needs_calibration": needs_cal,
|
||||
"cal_expiring_on_day": cal_expiring_on_day,
|
||||
"cal_expired_on_day": cal_expired_on_day
|
||||
}
|
||||
|
||||
current_day += timedelta(days=1)
|
||||
|
||||
months_data[month] = {
|
||||
"name": first_day.strftime("%B"),
|
||||
"short_name": first_day.strftime("%b"),
|
||||
"days": days_data,
|
||||
"first_weekday": first_day.weekday(), # 0=Monday, 6=Sunday
|
||||
"num_days": last_day.day
|
||||
}
|
||||
|
||||
# Also include reservation summary for the year
|
||||
reservation_list = []
|
||||
for res in reservations:
|
||||
assigned_count = len([a for a in assignments if a.reservation_id == res.id])
|
||||
reservation_list.append({
|
||||
"id": res.id,
|
||||
"name": res.name,
|
||||
"start_date": res.start_date.isoformat(),
|
||||
"end_date": res.end_date.isoformat(),
|
||||
"quantity_needed": res.quantity_needed,
|
||||
"assigned_count": assigned_count,
|
||||
"color": res.color
|
||||
})
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"device_type": device_type,
|
||||
"months": months_data,
|
||||
"reservations": reservation_list,
|
||||
"total_units": len(units)
|
||||
}
|
||||
|
||||
|
||||
def get_rolling_calendar_data(
|
||||
db: Session,
|
||||
start_year: int,
|
||||
start_month: int,
|
||||
device_type: str = "seismograph"
|
||||
) -> Dict:
|
||||
"""
|
||||
Get calendar data for 12 months starting from a specific month/year.
|
||||
|
||||
This supports the rolling calendar view where users can scroll through
|
||||
months one at a time, viewing any 12-month window.
|
||||
"""
|
||||
# Get user preferences
|
||||
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||
warning_days = prefs.calibration_warning_days if prefs else 30
|
||||
|
||||
# Get all units
|
||||
units = db.query(RosterUnit).filter(
|
||||
RosterUnit.device_type == device_type,
|
||||
RosterUnit.retired == False
|
||||
).all()
|
||||
|
||||
# Calculate the date range for 12 months
|
||||
first_date = date(start_year, start_month, 1)
|
||||
# Calculate end date (12 months later)
|
||||
end_year = start_year + 1 if start_month == 1 else start_year
|
||||
end_month = 12 if start_month == 1 else start_month - 1
|
||||
if start_month == 1:
|
||||
end_year = start_year
|
||||
end_month = 12
|
||||
else:
|
||||
# 12 months from start_month means we end at start_month - 1 next year
|
||||
end_year = start_year + 1
|
||||
end_month = start_month - 1
|
||||
|
||||
# Actually, simpler: go 11 months forward from start
|
||||
end_year = start_year + ((start_month + 10) // 12)
|
||||
end_month = ((start_month + 10) % 12) + 1
|
||||
if end_month == 12:
|
||||
last_date = date(end_year, 12, 31)
|
||||
else:
|
||||
last_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
||||
|
||||
# Get all reservations that overlap with this 12-month range
|
||||
reservations = db.query(JobReservation).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.start_date <= last_date,
|
||||
or_(
|
||||
JobReservation.end_date >= first_date,
|
||||
JobReservation.end_date == None # TBD reservations
|
||||
)
|
||||
).all()
|
||||
|
||||
# Get all unit assignments for these reservations
|
||||
reservation_ids = [r.id for r in reservations]
|
||||
assignments = db.query(JobReservationUnit).filter(
|
||||
JobReservationUnit.reservation_id.in_(reservation_ids)
|
||||
).all() if reservation_ids else []
|
||||
|
||||
# Build a lookup: unit_id -> list of (start_date, end_date, reservation_name)
|
||||
unit_reservations = {}
|
||||
for res in reservations:
|
||||
res_assignments = [a for a in assignments if a.reservation_id == res.id]
|
||||
for assignment in res_assignments:
|
||||
unit_id = assignment.unit_id
|
||||
start_d = assignment.unit_start_date or res.start_date
|
||||
if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd):
|
||||
end_d = res.estimated_end_date or date(start_year + 5, 12, 31)
|
||||
else:
|
||||
end_d = assignment.unit_end_date or res.end_date or date(start_year + 5, 12, 31)
|
||||
|
||||
if unit_id not in unit_reservations:
|
||||
unit_reservations[unit_id] = []
|
||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||
|
||||
# Generate data for each of the 12 months
|
||||
months_data = []
|
||||
current_year = start_year
|
||||
current_month = start_month
|
||||
|
||||
for i in range(12):
|
||||
# Calculate this month's year and month
|
||||
m_year = start_year + ((start_month - 1 + i) // 12)
|
||||
m_month = ((start_month - 1 + i) % 12) + 1
|
||||
|
||||
first_day = date(m_year, m_month, 1)
|
||||
if m_month == 12:
|
||||
last_day = date(m_year, 12, 31)
|
||||
else:
|
||||
last_day = date(m_year, m_month + 1, 1) - timedelta(days=1)
|
||||
|
||||
days_data = {}
|
||||
current_day = first_day
|
||||
|
||||
while current_day <= last_day:
|
||||
available = 0
|
||||
reserved = 0
|
||||
expired = 0
|
||||
expiring_soon = 0
|
||||
needs_cal = 0
|
||||
cal_expiring_on_day = 0
|
||||
cal_expired_on_day = 0
|
||||
|
||||
for unit in units:
|
||||
cal_status = get_calibration_status(unit, current_day, warning_days)
|
||||
|
||||
if unit.last_calibrated:
|
||||
unit_expiry = unit.last_calibrated + timedelta(days=365)
|
||||
if unit_expiry == current_day:
|
||||
cal_expiring_on_day += 1
|
||||
elif unit_expiry == current_day - timedelta(days=1):
|
||||
cal_expired_on_day += 1
|
||||
|
||||
if cal_status == "expired":
|
||||
expired += 1
|
||||
continue
|
||||
if cal_status == "needs_calibration":
|
||||
needs_cal += 1
|
||||
continue
|
||||
|
||||
is_reserved = False
|
||||
if unit.id in unit_reservations:
|
||||
for start_d, end_d, _ in unit_reservations[unit.id]:
|
||||
if start_d <= current_day <= end_d:
|
||||
is_reserved = True
|
||||
break
|
||||
|
||||
if is_reserved:
|
||||
reserved += 1
|
||||
else:
|
||||
available += 1
|
||||
|
||||
if cal_status == "expiring_soon":
|
||||
expiring_soon += 1
|
||||
|
||||
days_data[current_day.day] = {
|
||||
"available": available,
|
||||
"reserved": reserved,
|
||||
"expired": expired,
|
||||
"expiring_soon": expiring_soon,
|
||||
"needs_calibration": needs_cal,
|
||||
"cal_expiring_on_day": cal_expiring_on_day,
|
||||
"cal_expired_on_day": cal_expired_on_day
|
||||
}
|
||||
|
||||
current_day += timedelta(days=1)
|
||||
|
||||
months_data.append({
|
||||
"year": m_year,
|
||||
"month": m_month,
|
||||
"name": first_day.strftime("%B"),
|
||||
"short_name": first_day.strftime("%b"),
|
||||
"year_short": first_day.strftime("%y"),
|
||||
"days": days_data,
|
||||
"first_weekday": first_day.weekday(),
|
||||
"num_days": last_day.day
|
||||
})
|
||||
|
||||
return {
|
||||
"start_year": start_year,
|
||||
"start_month": start_month,
|
||||
"device_type": device_type,
|
||||
"months": months_data,
|
||||
"total_units": len(units)
|
||||
}
|
||||
|
||||
|
||||
def check_calibration_conflicts(
|
||||
db: Session,
|
||||
reservation_id: str
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Check if any units assigned to a reservation will have their
|
||||
calibration expire during the reservation period.
|
||||
|
||||
Returns list of conflicts with unit info and expiry date.
|
||||
"""
|
||||
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
|
||||
if not reservation:
|
||||
return []
|
||||
|
||||
# Get assigned units
|
||||
assigned = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=reservation_id
|
||||
).all()
|
||||
|
||||
conflicts = []
|
||||
for assignment in assigned:
|
||||
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
if not unit or not unit.last_calibrated:
|
||||
continue
|
||||
|
||||
expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||
|
||||
# Check if expiry falls within reservation period
|
||||
if reservation.start_date < expiry_date <= reservation.end_date:
|
||||
conflicts.append({
|
||||
"unit_id": unit.id,
|
||||
"last_calibrated": unit.last_calibrated.isoformat(),
|
||||
"expiry_date": expiry_date.isoformat(),
|
||||
"reservation_name": reservation.name,
|
||||
"days_into_job": (expiry_date - reservation.start_date).days
|
||||
})
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
def get_available_units_for_period(
|
||||
db: Session,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
device_type: str = "seismograph",
|
||||
exclude_reservation_id: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get units that are available for the entire specified period.
|
||||
|
||||
A unit is available if:
|
||||
- Not retired
|
||||
- Calibration is valid through the end date
|
||||
- Not assigned to any other reservation that overlaps the period
|
||||
"""
|
||||
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||
warning_days = prefs.calibration_warning_days if prefs else 30
|
||||
|
||||
units = db.query(RosterUnit).filter(
|
||||
RosterUnit.device_type == device_type,
|
||||
RosterUnit.retired == False
|
||||
).all()
|
||||
|
||||
# Get reservations that overlap with this period
|
||||
overlapping_reservations = db.query(JobReservation).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.start_date <= end_date,
|
||||
JobReservation.end_date >= start_date
|
||||
)
|
||||
|
||||
if exclude_reservation_id:
|
||||
overlapping_reservations = overlapping_reservations.filter(
|
||||
JobReservation.id != exclude_reservation_id
|
||||
)
|
||||
|
||||
overlapping_reservations = overlapping_reservations.all()
|
||||
|
||||
# Get all units assigned to overlapping reservations
|
||||
reserved_unit_ids = set()
|
||||
for res in overlapping_reservations:
|
||||
assigned = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=res.id
|
||||
).all()
|
||||
for a in assigned:
|
||||
reserved_unit_ids.add(a.unit_id)
|
||||
|
||||
available_units = []
|
||||
for unit in units:
|
||||
# Check if already reserved
|
||||
if unit.id in reserved_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)
|
||||
|
||||
available_units.append({
|
||||
"id": unit.id,
|
||||
"last_calibrated": unit.last_calibrated.isoformat(),
|
||||
"expiry_date": expiry_date.isoformat(),
|
||||
"calibration_status": cal_status,
|
||||
"deployed": unit.deployed,
|
||||
"note": unit.note or ""
|
||||
})
|
||||
|
||||
return available_units
|
||||
@@ -70,6 +70,10 @@ async def sync_slm_status_to_emitters() -> Dict[str, Any]:
|
||||
# Convert to naive UTC for consistency with existing code
|
||||
if last_seen.tzinfo:
|
||||
last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
elif is_reachable:
|
||||
# Device is reachable but no last_success yet (first poll or just started)
|
||||
# Use current time so it shows as OK, not Missing
|
||||
last_seen = datetime.utcnow()
|
||||
else:
|
||||
last_seen = None
|
||||
|
||||
|
||||
@@ -25,26 +25,28 @@ services:
|
||||
start_period: 40s
|
||||
|
||||
# --- TERRA-VIEW DEVELOPMENT ---
|
||||
# terra-view-dev:
|
||||
# build: .
|
||||
# container_name: terra-view-dev
|
||||
# ports:
|
||||
# - "1001:8001"
|
||||
# volumes:
|
||||
# - ./data-dev:/app/data
|
||||
# environment:
|
||||
# - PYTHONUNBUFFERED=1
|
||||
# - ENVIRONMENT=development
|
||||
# - SLMM_BASE_URL=http://slmm:8100
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - slmm
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
# interval: 30s
|
||||
# timeout: 10s
|
||||
# retries: 3
|
||||
# start_period: 40s
|
||||
terra-view-dev:
|
||||
build: .
|
||||
container_name: terra-view-dev
|
||||
ports:
|
||||
- "1001:8001"
|
||||
volumes:
|
||||
- ./data-dev:/app/data
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- ENVIRONMENT=development
|
||||
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- slmm
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# --- SLMM (Sound Level Meter Manager) ---
|
||||
slmm:
|
||||
|
||||
@@ -151,6 +151,13 @@
|
||||
Projects
|
||||
</a>
|
||||
|
||||
<a href="/fleet-calendar" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/fleet-calendar') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<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
|
||||
</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 %}">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
|
||||
811
templates/fleet_calendar.html
Normal file
811
templates/fleet_calendar.html
Normal file
@@ -0,0 +1,811 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Fleet Calendar - Terra-View{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Calendar grid layout */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.calendar-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.calendar-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.calendar-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Month card */
|
||||
.month-card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.dark .month-card {
|
||||
background: rgb(30 41 59);
|
||||
}
|
||||
|
||||
/* Day grid */
|
||||
.day-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.dark .day-header {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Day cell */
|
||||
.day-cell {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.day-cell:hover {
|
||||
transform: scale(1.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
ring: 2px;
|
||||
ring-color: #f48b1c;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Default neutral day style */
|
||||
.day-cell.neutral {
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .day-cell.neutral {
|
||||
background-color: rgba(55, 65, 81, 0.5);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Status indicator dot */
|
||||
.day-cell .status-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.day-cell .status-dot.expired {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
/* Reservation mode colors - applied dynamically */
|
||||
.day-cell.reservation-available {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.dark .day-cell.reservation-available {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.day-cell.reservation-partial {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.dark .day-cell.reservation-partial {
|
||||
background-color: rgba(245, 158, 11, 0.2);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.day-cell.reservation-unavailable {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.dark .day-cell.reservation-unavailable {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Legacy status colors (kept for day detail) */
|
||||
.day-cell.some-reserved {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.dark .day-cell.some-reserved {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.day-cell.empty {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.day-cell.empty:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Reservation bar */
|
||||
.reservation-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.reservation-bar:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Slide-over panel */
|
||||
.slide-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
height: 100vh;
|
||||
background: white;
|
||||
box-shadow: -4px 0 15px rgba(0,0,0,0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 50;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dark .slide-panel {
|
||||
background: rgb(30 41 59);
|
||||
}
|
||||
|
||||
.slide-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.panel-backdrop.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 shadow">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Units</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ calendar_data.total_units }}</p>
|
||||
</div>
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 shadow">
|
||||
<p class="text-sm text-green-600 dark:text-green-400">Available Today</p>
|
||||
<p class="text-2xl font-bold text-green-700 dark:text-green-300" id="available-today">--</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 shadow">
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400">Reserved Today</p>
|
||||
<p class="text-2xl font-bold text-blue-700 dark:text-blue-300" id="reserved-today">--</p>
|
||||
</div>
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 shadow">
|
||||
<p class="text-sm text-yellow-600 dark:text-yellow-400">Expiring Soon</p>
|
||||
<p class="text-2xl font-bold text-yellow-700 dark:text-yellow-300" id="expiring-today">--</p>
|
||||
</div>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 shadow">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">Cal. Expired</p>
|
||||
<p class="text-2xl font-bold text-red-700 dark:text-red-300" id="expired-today">--</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Device Type Toggle -->
|
||||
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1">
|
||||
<a href="/fleet-calendar?year={{ start_year }}&month={{ start_month }}&device_type=seismograph"
|
||||
class="px-4 py-2 rounded-md text-sm font-medium transition-colors {% if device_type == 'seismograph' %}bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow{% else %}text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||
Seismographs
|
||||
</a>
|
||||
<a href="/fleet-calendar?year={{ start_year }}&month={{ start_month }}&device_type=slm"
|
||||
class="px-4 py-2 rounded-md text-sm font-medium transition-colors {% if device_type == 'slm' %}bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow{% else %}text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||
SLMs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="hidden md:flex items-center gap-4 text-sm" id="main-legend">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-gray-200 dark:bg-gray-600 relative">
|
||||
<span class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
Cal expires
|
||||
</span>
|
||||
</div>
|
||||
<!-- Reservation mode legend (hidden by default) -->
|
||||
<div class="hidden md:hidden items-center gap-4 text-sm" id="reservation-legend">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-green-200 dark:bg-green-700"></span>
|
||||
Available
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-yellow-200 dark:bg-yellow-700"></span>
|
||||
Partial
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-red-200 dark:bg-red-700"></span>
|
||||
Unavailable
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="openReservationModal()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
||||
<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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
New Reservation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div class="calendar-grid mb-8">
|
||||
{% for month_data in calendar_data.months %}
|
||||
<div class="month-card">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2 text-center">
|
||||
{{ month_data.short_name }} '{{ month_data.year_short }}
|
||||
</h3>
|
||||
<div class="day-grid">
|
||||
<!-- Day headers -->
|
||||
<div class="day-header">S</div>
|
||||
<div class="day-header">M</div>
|
||||
<div class="day-header">T</div>
|
||||
<div class="day-header">W</div>
|
||||
<div class="day-header">T</div>
|
||||
<div class="day-header">F</div>
|
||||
<div class="day-header">S</div>
|
||||
|
||||
<!-- Empty cells for alignment (first_weekday is 0=Mon, we need 0=Sun) -->
|
||||
{% set first_day_offset = (month_data.first_weekday + 1) % 7 %}
|
||||
{% for i in range(first_day_offset) %}
|
||||
<div class="day-cell empty"></div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Day cells -->
|
||||
{% for day_num in range(1, month_data.num_days + 1) %}
|
||||
{% set day_data = month_data.days[day_num] %}
|
||||
{% set date_str = '%04d-%02d-%02d'|format(month_data.year, month_data.month, day_num) %}
|
||||
{% set is_today = date_str == today %}
|
||||
{% set has_cal_expiring = day_data.cal_expiring_on_day is defined and day_data.cal_expiring_on_day > 0 %}
|
||||
<div class="day-cell neutral {% if is_today %}today ring-2 ring-seismo-orange{% endif %}"
|
||||
data-date="{{ date_str }}"
|
||||
data-available="{{ day_data.available }}"
|
||||
onclick="openDayPanel('{{ date_str }}')"
|
||||
title="Available: {{ day_data.available }}, Reserved: {{ day_data.reserved }}{% if has_cal_expiring %}, Cal expires: {{ day_data.cal_expiring_on_day }}{% endif %}">
|
||||
{{ day_num }}
|
||||
{% if has_cal_expiring %}
|
||||
<span class="status-dot expired"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Month Navigation (centered below calendar) -->
|
||||
<div class="flex items-center justify-center gap-3 mb-8">
|
||||
<a href="/fleet-calendar?year={{ prev_year }}&month={{ prev_month }}&device_type={{ device_type }}"
|
||||
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
title="Previous month">
|
||||
<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>
|
||||
</a>
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
||||
{{ calendar_data.months[0].name }} {{ calendar_data.months[0].year }} - {{ calendar_data.months[11].name }} {{ calendar_data.months[11].year }}
|
||||
</span>
|
||||
<a href="/fleet-calendar?year={{ next_year }}&month={{ next_month }}&device_type={{ device_type }}"
|
||||
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
title="Next month">
|
||||
<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="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/fleet-calendar?device_type={{ device_type }}"
|
||||
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
||||
Today
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Active Reservations -->
|
||||
<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="load"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-gray-500">Loading reservations...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Day Detail Slide Panel -->
|
||||
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
|
||||
<div id="day-panel" class="slide-panel">
|
||||
<div class="p-6" id="day-panel-content">
|
||||
<!-- Content loaded via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reservation Modal -->
|
||||
<div id="reservation-modal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/50" onclick="closeReservationModal()"></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 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>
|
||||
<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>
|
||||
|
||||
<form id="reservation-form" onsubmit="submitReservation(event)">
|
||||
<!-- Name -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reservation Name *</label>
|
||||
<input type="text" name="name" required
|
||||
placeholder="e.g., Job A - March 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>
|
||||
|
||||
<!-- Project (optional) -->
|
||||
<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 name="project_id"
|
||||
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>
|
||||
|
||||
<!-- Date Range -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date *</label>
|
||||
<input type="date" name="start_date" required
|
||||
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 class="mb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input type="checkbox" name="end_date_tbd" id="end_date_tbd"
|
||||
onchange="toggleEndDateTBD()"
|
||||
class="w-4 h-4 text-blue-600 focus:ring-blue-500 rounded border-gray-300 dark:border-gray-600">
|
||||
<span class="text-gray-600 dark:text-gray-400">TBD / Ongoing</span>
|
||||
</label>
|
||||
</div>
|
||||
<input type="date" name="end_date" id="end_date_input"
|
||||
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>
|
||||
|
||||
<!-- Estimated End Date (shown when TBD is checked) -->
|
||||
<div id="estimated-end-section" class="mb-4 hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Estimated End Date <span class="text-gray-400 font-normal">(for planning)</span>
|
||||
</label>
|
||||
<input type="date" name="estimated_end_date"
|
||||
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">
|
||||
<p class="text-xs text-gray-500 mt-1">Used for calendar visualization only. Can be updated later.</p>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Type -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assignment Type *</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="assignment_type" value="quantity" checked
|
||||
onchange="toggleAssignmentType(this.value)"
|
||||
class="text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-gray-700 dark:text-gray-300">Reserve quantity</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="assignment_type" value="specific"
|
||||
onchange="toggleAssignmentType(this.value)"
|
||||
class="text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-gray-700 dark:text-gray-300">Pick specific units</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quantity (shown for quantity type) -->
|
||||
<div id="quantity-section" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Units Needed</label>
|
||||
<input type="number" name="quantity_needed" min="1" value="1"
|
||||
onchange="updateCalendarAvailability()"
|
||||
oninput="updateCalendarAvailability()"
|
||||
class="w-32 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">
|
||||
<p class="text-xs text-gray-500 mt-1">Calendar will highlight availability based on quantity</p>
|
||||
</div>
|
||||
|
||||
<!-- Specific Units (shown for specific type) -->
|
||||
<div id="specific-section" class="mb-4 hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Units</label>
|
||||
<div id="available-units-list" class="max-h-48 overflow-y-auto border border-gray-300 dark:border-gray-600 rounded-lg p-2">
|
||||
<p class="text-gray-500 text-sm">Select dates first to see available units</p>
|
||||
</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">
|
||||
{% for color in ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] %}
|
||||
<label class="cursor-pointer">
|
||||
<input type="radio" name="color" value="{{ color }}" {% if loop.first %}checked{% endif %} class="sr-only peer">
|
||||
<span class="block w-8 h-8 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>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label>
|
||||
<textarea name="notes" rows="2"
|
||||
placeholder="Optional notes about this reservation"
|
||||
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>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="closeReservationModal()"
|
||||
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"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Create Reservation
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const deviceType = '{{ device_type }}';
|
||||
const startYear = {{ start_year }};
|
||||
const startMonth = {{ start_month }};
|
||||
|
||||
// Load today's stats
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTodayStats();
|
||||
});
|
||||
|
||||
async function loadTodayStats() {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const response = await fetch(`/api/fleet-calendar/day/${today}?device_type=${deviceType}`);
|
||||
// Just update the stats from the day data response headers or a separate call
|
||||
// For now, calculate from calendar data
|
||||
const todayData = findTodayInCalendar();
|
||||
if (todayData) {
|
||||
document.getElementById('available-today').textContent = todayData.available;
|
||||
document.getElementById('reserved-today').textContent = todayData.reserved;
|
||||
document.getElementById('expiring-today').textContent = todayData.expiring_soon;
|
||||
document.getElementById('expired-today').textContent = todayData.expired;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading today stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function findTodayInCalendar() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = today.getMonth() + 1;
|
||||
const day = today.getDate();
|
||||
const calendarData = {{ calendar_data | tojson }};
|
||||
// months is now an array, find the matching month
|
||||
const monthData = calendarData.months.find(m => m.year === year && m.month === month);
|
||||
if (monthData && monthData.days[day]) {
|
||||
return monthData.days[day];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function openDayPanel(dateStr) {
|
||||
const panel = document.getElementById('day-panel');
|
||||
const backdrop = document.getElementById('panel-backdrop');
|
||||
const content = document.getElementById('day-panel-content');
|
||||
|
||||
// Load content via HTMX
|
||||
htmx.ajax('GET', `/api/fleet-calendar/day/${dateStr}?device_type=${deviceType}`, {
|
||||
target: '#day-panel-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
|
||||
panel.classList.add('open');
|
||||
backdrop.classList.add('open');
|
||||
}
|
||||
|
||||
function closeDayPanel() {
|
||||
const panel = document.getElementById('day-panel');
|
||||
const backdrop = document.getElementById('panel-backdrop');
|
||||
panel.classList.remove('open');
|
||||
backdrop.classList.remove('open');
|
||||
}
|
||||
|
||||
let reservationModeActive = false;
|
||||
|
||||
function openReservationModal() {
|
||||
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 closeReservationModal() {
|
||||
document.getElementById('reservation-modal').classList.add('hidden');
|
||||
document.getElementById('reservation-form').reset();
|
||||
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();
|
||||
}
|
||||
|
||||
function resetCalendarColors() {
|
||||
document.querySelectorAll('.day-cell:not(.empty)').forEach(cell => {
|
||||
cell.classList.remove('reservation-available', 'reservation-partial', 'reservation-unavailable');
|
||||
cell.classList.add('neutral');
|
||||
});
|
||||
}
|
||||
|
||||
function updateCalendarAvailability() {
|
||||
if (!reservationModeActive) return;
|
||||
|
||||
const quantityInput = document.querySelector('input[name="quantity_needed"]');
|
||||
const quantity = parseInt(quantityInput?.value) || 1;
|
||||
|
||||
// Update each day cell based on available units vs quantity needed
|
||||
document.querySelectorAll('.day-cell:not(.empty)').forEach(cell => {
|
||||
const available = parseInt(cell.dataset.available) || 0;
|
||||
|
||||
// Remove all status classes
|
||||
cell.classList.remove('neutral', 'reservation-available', 'reservation-partial', 'reservation-unavailable');
|
||||
|
||||
if (available >= quantity) {
|
||||
cell.classList.add('reservation-available');
|
||||
} else if (available > 0) {
|
||||
cell.classList.add('reservation-partial');
|
||||
} else {
|
||||
cell.classList.add('reservation-unavailable');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEndDateTBD() {
|
||||
const checkbox = document.getElementById('end_date_tbd');
|
||||
const endDateInput = document.getElementById('end_date_input');
|
||||
const estimatedSection = document.getElementById('estimated-end-section');
|
||||
|
||||
if (checkbox.checked) {
|
||||
endDateInput.disabled = true;
|
||||
endDateInput.value = '';
|
||||
endDateInput.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
estimatedSection.classList.remove('hidden');
|
||||
} else {
|
||||
endDateInput.disabled = false;
|
||||
endDateInput.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
estimatedSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAssignmentType(type) {
|
||||
const quantitySection = document.getElementById('quantity-section');
|
||||
const specificSection = document.getElementById('specific-section');
|
||||
|
||||
if (type === 'quantity') {
|
||||
quantitySection.classList.remove('hidden');
|
||||
specificSection.classList.add('hidden');
|
||||
} else {
|
||||
quantitySection.classList.add('hidden');
|
||||
specificSection.classList.remove('hidden');
|
||||
// Load available units based on selected dates
|
||||
loadAvailableUnits();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableUnits() {
|
||||
const startDate = document.querySelector('input[name="start_date"]').value;
|
||||
const endDate = document.querySelector('input[name="end_date"]').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
document.getElementById('available-units-list').innerHTML =
|
||||
'<p class="text-gray-500 text-sm">Select dates first to see available units</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/fleet-calendar/availability?start_date=${startDate}&end_date=${endDate}&device_type=${deviceType}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.available_units.length === 0) {
|
||||
document.getElementById('available-units-list').innerHTML =
|
||||
'<p class="text-gray-500 text-sm">No units available for this period</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const unit of data.available_units) {
|
||||
html += `
|
||||
<label class="flex items-center gap-2 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer">
|
||||
<input type="checkbox" name="unit_ids" value="${unit.id}"
|
||||
class="text-blue-600 focus:ring-blue-500 rounded">
|
||||
<span class="text-gray-900 dark:text-white font-medium">${unit.id}</span>
|
||||
<span class="text-gray-500 text-sm">Cal: ${unit.last_calibrated || 'N/A'}</span>
|
||||
${unit.calibration_status === 'expiring_soon' ?
|
||||
'<span class="text-yellow-600 text-xs">Expiring soon</span>' : ''}
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
document.getElementById('available-units-list').innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('Error loading available units:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for date changes to reload available units
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const startInput = document.querySelector('input[name="start_date"]');
|
||||
const endInput = document.querySelector('input[name="end_date"]');
|
||||
|
||||
if (startInput && endInput) {
|
||||
startInput.addEventListener('change', function() {
|
||||
if (document.querySelector('input[name="assignment_type"]:checked').value === 'specific') {
|
||||
loadAvailableUnits();
|
||||
}
|
||||
});
|
||||
endInput.addEventListener('change', function() {
|
||||
if (document.querySelector('input[name="assignment_type"]:checked').value === 'specific') {
|
||||
loadAvailableUnits();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function submitReservation(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
const endDateTbd = formData.get('end_date_tbd') === 'on';
|
||||
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
project_id: formData.get('project_id') || null,
|
||||
start_date: formData.get('start_date'),
|
||||
end_date: endDateTbd ? null : formData.get('end_date'),
|
||||
end_date_tbd: endDateTbd,
|
||||
estimated_end_date: endDateTbd ? (formData.get('estimated_end_date') || null) : null,
|
||||
assignment_type: formData.get('assignment_type'),
|
||||
device_type: deviceType,
|
||||
color: formData.get('color'),
|
||||
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;
|
||||
}
|
||||
|
||||
if (data.assignment_type === 'quantity') {
|
||||
data.quantity_needed = parseInt(formData.get('quantity_needed'));
|
||||
} else {
|
||||
data.unit_ids = formData.getAll('unit_ids');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/fleet-calendar/reservations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
closeReservationModal();
|
||||
// Reload the page to refresh calendar
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error creating reservation: ' + (result.detail || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error creating reservation');
|
||||
}
|
||||
}
|
||||
|
||||
// Close panels on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDayPanel();
|
||||
closeReservationModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -115,10 +115,10 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if unit.next_calibration_due %}
|
||||
{% if unit.last_calibrated %}
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
|
||||
<span class="font-medium">{{ unit.last_calibrated }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
|
||||
40
templates/partials/fleet_calendar/available_units.html
Normal file
40
templates/partials/fleet_calendar/available_units.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!-- Available Units for Assignment -->
|
||||
{% if units %}
|
||||
<div class="space-y-1">
|
||||
{% for unit in units %}
|
||||
<label class="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer">
|
||||
<input type="checkbox" name="unit_ids" value="{{ unit.id }}"
|
||||
class="w-4 h-4 text-blue-600 focus:ring-blue-500 rounded border-gray-300 dark:border-gray-600">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ unit.id }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 flex-1">
|
||||
{% if unit.last_calibrated %}
|
||||
Cal: {{ unit.last_calibrated }}
|
||||
{% else %}
|
||||
No cal date
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if unit.calibration_status == 'expiring_soon' %}
|
||||
<span class="text-xs px-2 py-0.5 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
|
||||
Expiring soon
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if unit.deployed %}
|
||||
<span class="text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full">
|
||||
Deployed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-full">
|
||||
Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm py-4 text-center">
|
||||
No units available for this date range.
|
||||
{% if start_date and end_date %}
|
||||
<br><span class="text-xs">All units are either reserved, have expired calibrations, or are retired.</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
186
templates/partials/fleet_calendar/day_detail.html
Normal file
186
templates/partials/fleet_calendar/day_detail.html
Normal file
@@ -0,0 +1,186 @@
|
||||
<!-- Day Detail Panel Content -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ date_display }}</h2>
|
||||
<button onclick="closeDayPanel()" 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>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-6">
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-green-700 dark:text-green-300">{{ day_data.counts.available }}</p>
|
||||
<p class="text-xs text-green-600 dark:text-green-400">Available</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-blue-700 dark:text-blue-300">{{ day_data.counts.reserved }}</p>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400">Reserved</p>
|
||||
</div>
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-yellow-700 dark:text-yellow-300">{{ day_data.counts.expiring_soon }}</p>
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400">Expiring Soon</p>
|
||||
</div>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-red-700 dark:text-red-300">{{ day_data.counts.expired }}</p>
|
||||
<p class="text-xs text-red-600 dark:text-red-400">Cal. Expired</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calibration Expiring TODAY - Important alert -->
|
||||
{% if day_data.cal_expiring_today %}
|
||||
<div class="mb-6 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<h3 class="text-sm font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Calibration Expires Today ({{ day_data.cal_expiring_today|length }})
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
{% for unit in day_data.cal_expiring_today %}
|
||||
<div class="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-red-600 dark:text-red-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
<span class="text-red-500 text-xs">
|
||||
Last cal: {{ unit.last_calibrated }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Reservations on this date -->
|
||||
{% if day_data.reservations %}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Reservations</h3>
|
||||
{% for res in day_data.reservations %}
|
||||
<div class="reservation-bar mb-2" style="background-color: {{ res.color }}20; border-left: 4px solid {{ res.color }};">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ res.name }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ res.start_date }} - {{ res.end_date }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{% if res.assignment_type == 'quantity' %}
|
||||
{{ res.assigned_count }}/{{ res.quantity_needed or '?' }}
|
||||
{% else %}
|
||||
{{ res.assigned_count }} units
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Available Units -->
|
||||
{% if day_data.available_units %}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Available Units ({{ day_data.available_units|length }})
|
||||
</h3>
|
||||
<div class="max-h-48 overflow-y-auto space-y-1">
|
||||
{% for unit in day_data.available_units %}
|
||||
<div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700/50 rounded text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{% if unit.last_calibrated %}
|
||||
Cal: {{ unit.last_calibrated }}
|
||||
{% else %}
|
||||
No cal date
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Reserved Units -->
|
||||
{% if day_data.reserved_units %}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Reserved Units ({{ day_data.reserved_units|length }})
|
||||
</h3>
|
||||
<div class="max-h-48 overflow-y-auto space-y-1">
|
||||
{% for unit in day_data.reserved_units %}
|
||||
<div class="flex items-center justify-between p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
<span class="text-blue-600 dark:text-blue-400 text-xs">
|
||||
{{ unit.reservation_name }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Calibration Expired -->
|
||||
{% if day_data.expired_units %}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-3">
|
||||
Calibration Expired ({{ day_data.expired_units|length }})
|
||||
</h3>
|
||||
<div class="max-h-48 overflow-y-auto space-y-1">
|
||||
{% for unit in day_data.expired_units %}
|
||||
<div class="flex items-center justify-between p-2 bg-red-50 dark:bg-red-900/20 rounded text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-red-600 dark:text-red-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
<span class="text-red-500 text-xs">
|
||||
Expired: {{ unit.expiry_date }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Needs Calibration -->
|
||||
{% if day_data.needs_calibration_units %}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3">
|
||||
Needs Calibration Date ({{ day_data.needs_calibration_units|length }})
|
||||
</h3>
|
||||
<div class="max-h-32 overflow-y-auto space-y-1">
|
||||
{% for unit in day_data.needs_calibration_units %}
|
||||
<div class="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700/50 rounded text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-gray-600 dark:text-gray-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
<span class="text-gray-400 text-xs">No cal date set</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Expiring Soon (informational) -->
|
||||
{% if day_data.expiring_soon_units %}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-yellow-600 dark:text-yellow-400 mb-3">
|
||||
Calibration Expiring Soon ({{ day_data.expiring_soon_units|length }})
|
||||
</h3>
|
||||
<div class="max-h-32 overflow-y-auto space-y-1">
|
||||
{% for unit in day_data.expiring_soon_units %}
|
||||
<div class="flex items-center justify-between p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-yellow-700 dark:text-yellow-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
<span class="text-yellow-600 text-xs">
|
||||
Expires: {{ unit.expiry_date }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
103
templates/partials/fleet_calendar/reservations_list.html
Normal file
103
templates/partials/fleet_calendar/reservations_list.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!-- Reservations List -->
|
||||
{% if reservations %}
|
||||
<div class="space-y-3">
|
||||
{% 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"
|
||||
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>
|
||||
{% 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>
|
||||
{% 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 '?' }}
|
||||
{% 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');
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
{% 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>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -108,10 +108,10 @@
|
||||
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if unit.next_calibration_due %}
|
||||
{% if unit.last_calibrated %}
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
|
||||
<span class="font-medium">{{ unit.last_calibrated }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
|
||||
@@ -1,13 +1,92 @@
|
||||
{% if units %}
|
||||
{% if units is defined %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
|
||||
{% set next_order = 'desc' if (sort == 'id' and order == 'asc') else 'asc' %}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
|
||||
hx-get="/api/seismo-dashboard/units?sort=id&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
|
||||
hx-target="#seismo-units-list"
|
||||
hx-swap="innerHTML">
|
||||
<span class="flex items-center gap-1">
|
||||
Unit ID
|
||||
{% if sort == 'id' %}
|
||||
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
</th>
|
||||
{% set next_order = 'desc' if (sort == 'status' and order == 'asc') else 'asc' %}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
|
||||
hx-get="/api/seismo-dashboard/units?sort=status&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
|
||||
hx-target="#seismo-units-list"
|
||||
hx-swap="innerHTML">
|
||||
<span class="flex items-center gap-1">
|
||||
Status
|
||||
{% if sort == 'status' %}
|
||||
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
</th>
|
||||
{% set next_order = 'desc' if (sort == 'modem' and order == 'asc') else 'asc' %}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
|
||||
hx-get="/api/seismo-dashboard/units?sort=modem&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
|
||||
hx-target="#seismo-units-list"
|
||||
hx-swap="innerHTML">
|
||||
<span class="flex items-center gap-1">
|
||||
Modem
|
||||
{% if sort == 'modem' %}
|
||||
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
</th>
|
||||
{% set next_order = 'desc' if (sort == 'location' and order == 'asc') else 'asc' %}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
|
||||
hx-get="/api/seismo-dashboard/units?sort=location&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
|
||||
hx-target="#seismo-units-list"
|
||||
hx-swap="innerHTML">
|
||||
<span class="flex items-center gap-1">
|
||||
Location
|
||||
{% if sort == 'location' %}
|
||||
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
</th>
|
||||
{% set next_order = 'desc' if (sort == 'last_calibrated' and order == 'asc') else 'asc' %}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
|
||||
hx-get="/api/seismo-dashboard/units?sort=last_calibrated&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
|
||||
hx-target="#seismo-units-list"
|
||||
hx-swap="innerHTML">
|
||||
<span class="flex items-center gap-1">
|
||||
Last Calibrated
|
||||
{% if sort == 'last_calibrated' %}
|
||||
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
</th>
|
||||
{% set next_order = 'desc' if (sort == 'notes' and order == 'asc') else 'asc' %}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
|
||||
hx-get="/api/seismo-dashboard/units?sort=notes&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
|
||||
hx-target="#seismo-units-list"
|
||||
hx-swap="innerHTML">
|
||||
<span class="flex items-center gap-1">
|
||||
Notes
|
||||
{% if sort == 'notes' %}
|
||||
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -54,6 +133,27 @@
|
||||
<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>
|
||||
@@ -72,9 +172,12 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if search %}
|
||||
{% if search or status or modem %}
|
||||
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Found {{ units|length }} seismograph(s) matching "{{ search }}"
|
||||
Found {{ units|length }} seismograph(s)
|
||||
{% if search %} matching "{{ search }}"{% endif %}
|
||||
{% if status %} ({{ status }}){% endif %}
|
||||
{% if modem %} ({{ 'with modem' if modem == 'with' else 'without modem' }}){% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -145,16 +145,12 @@
|
||||
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
|
||||
<input type="date" name="last_calibrated"
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
|
||||
<input type="date" name="last_calibrated" id="addLastCalibrated"
|
||||
class="w-full px-4 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-seismo-orange">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Next calibration due date will be calculated automatically</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
|
||||
<input type="date" name="next_calibration_due"
|
||||
class="w-full px-4 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-seismo-orange">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Typically 1 year after last calibration</p>
|
||||
</div>
|
||||
<input type="hidden" name="next_calibration_due" id="addNextCalibrationDue">
|
||||
<div id="modemPairingField" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
{% set picker_id = "-add-seismo" %}
|
||||
@@ -325,15 +321,12 @@
|
||||
<div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
|
||||
<input type="date" name="last_calibrated" id="editLastCalibrated"
|
||||
class="w-full px-4 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-seismo-orange">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Next calibration due date will be calculated automatically</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
|
||||
<input type="date" name="next_calibration_due" id="editNextCalibrationDue"
|
||||
class="w-full px-4 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-seismo-orange">
|
||||
</div>
|
||||
<input type="hidden" name="next_calibration_due" id="editNextCalibrationDue">
|
||||
<div id="editModemPairingField" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
{% set picker_id = "-edit-seismo" %}
|
||||
@@ -598,6 +591,58 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Calibration interval in days (default 365, will be loaded from preferences)
|
||||
let calibrationIntervalDays = 365;
|
||||
|
||||
// Load calibration interval from preferences
|
||||
async function loadCalibrationInterval() {
|
||||
try {
|
||||
const response = await fetch('/api/settings/preferences');
|
||||
if (response.ok) {
|
||||
const prefs = await response.json();
|
||||
calibrationIntervalDays = prefs.calibration_interval_days || 365;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load calibration interval:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next calibration due date from last calibrated date
|
||||
function calculateNextCalibrationDue(lastCalibratedStr) {
|
||||
if (!lastCalibratedStr) return '';
|
||||
const lastCalibrated = new Date(lastCalibratedStr);
|
||||
const nextDue = new Date(lastCalibrated);
|
||||
nextDue.setDate(nextDue.getDate() + calibrationIntervalDays);
|
||||
return nextDue.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Setup auto-calculation for calibration fields
|
||||
function setupCalibrationAutoCalc() {
|
||||
// Add form
|
||||
const addLastCal = document.getElementById('addLastCalibrated');
|
||||
const addNextCal = document.getElementById('addNextCalibrationDue');
|
||||
if (addLastCal && addNextCal) {
|
||||
addLastCal.addEventListener('change', function() {
|
||||
addNextCal.value = calculateNextCalibrationDue(this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Edit form
|
||||
const editLastCal = document.getElementById('editLastCalibrated');
|
||||
const editNextCal = document.getElementById('editNextCalibrationDue');
|
||||
if (editLastCal && editNextCal) {
|
||||
editLastCal.addEventListener('change', function() {
|
||||
editNextCal.value = calculateNextCalibrationDue(this.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCalibrationInterval();
|
||||
setupCalibrationAutoCalc();
|
||||
});
|
||||
|
||||
// Add Unit Modal
|
||||
function openAddUnitModal() {
|
||||
document.getElementById('addUnitModal').classList.remove('hidden');
|
||||
@@ -891,8 +936,11 @@
|
||||
document.getElementById('editRetiredCheckbox').checked = unit.retired;
|
||||
|
||||
// Seismograph fields
|
||||
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
||||
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
||||
document.getElementById('editLastCalibrated').value = unit.last_calibrated || '';
|
||||
// Calculate next calibration due from last calibrated
|
||||
document.getElementById('editNextCalibrationDue').value = unit.last_calibrated
|
||||
? calculateNextCalibrationDue(unit.last_calibrated)
|
||||
: '';
|
||||
|
||||
// Populate modem picker for seismograph (uses -edit-seismo suffix)
|
||||
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');
|
||||
|
||||
@@ -27,25 +27,50 @@
|
||||
|
||||
<!-- Seismograph List -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
|
||||
<div class="mb-4 flex flex-col gap-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="seismo-search"
|
||||
placeholder="Search seismographs..."
|
||||
class="w-full sm:w-64 px-4 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 focus:border-transparent"
|
||||
hx-get="/api/seismo-dashboard/units"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#seismo-units-list"
|
||||
hx-include="[name='search']"
|
||||
name="search"
|
||||
/>
|
||||
<svg class="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<!-- Search Box -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="seismo-search"
|
||||
placeholder="Search seismographs..."
|
||||
class="w-full sm:w-64 px-4 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 focus:border-transparent"
|
||||
name="search"
|
||||
/>
|
||||
<svg class="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Filter:</span>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select id="seismo-status-filter" name="status"
|
||||
class="px-3 py-1.5 text-sm 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 focus:border-transparent">
|
||||
<option value="">All Status</option>
|
||||
<option value="deployed">Deployed</option>
|
||||
<option value="benched">Benched</option>
|
||||
</select>
|
||||
|
||||
<!-- Modem Filter -->
|
||||
<select id="seismo-modem-filter" name="modem"
|
||||
class="px-3 py-1.5 text-sm 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 focus:border-transparent">
|
||||
<option value="">All Modems</option>
|
||||
<option value="with">With Modem</option>
|
||||
<option value="without">Without Modem</option>
|
||||
</select>
|
||||
|
||||
<!-- Clear Filters Button -->
|
||||
<button id="seismo-clear-filters" type="button"
|
||||
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,17 +84,53 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Clear search input on escape key
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('seismo-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
this.value = '';
|
||||
htmx.trigger(this, 'keyup');
|
||||
}
|
||||
});
|
||||
const statusFilter = document.getElementById('seismo-status-filter');
|
||||
const modemFilter = document.getElementById('seismo-modem-filter');
|
||||
const clearBtn = document.getElementById('seismo-clear-filters');
|
||||
const unitsList = document.getElementById('seismo-units-list');
|
||||
|
||||
// Build URL with current filter values
|
||||
function buildUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (searchInput.value) params.set('search', searchInput.value);
|
||||
if (statusFilter.value) params.set('status', statusFilter.value);
|
||||
if (modemFilter.value) params.set('modem', modemFilter.value);
|
||||
return '/api/seismo-dashboard/units' + (params.toString() ? '?' + params.toString() : '');
|
||||
}
|
||||
|
||||
// Trigger HTMX refresh
|
||||
function refreshList() {
|
||||
htmx.ajax('GET', buildUrl(), {target: '#seismo-units-list', swap: 'innerHTML'});
|
||||
}
|
||||
|
||||
// Search input with debounce
|
||||
let debounceTimer;
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(refreshList, 300);
|
||||
});
|
||||
|
||||
// Clear search on escape
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
this.value = '';
|
||||
refreshList();
|
||||
}
|
||||
});
|
||||
|
||||
// Filter changes
|
||||
statusFilter.addEventListener('change', refreshList);
|
||||
modemFilter.addEventListener('change', refreshList);
|
||||
|
||||
// Clear all filters
|
||||
clearBtn.addEventListener('click', function() {
|
||||
searchInput.value = '';
|
||||
statusFilter.value = '';
|
||||
modemFilter.value = '';
|
||||
refreshList();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -144,12 +144,12 @@
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Seismograph Information</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Date of Last Calibration</label>
|
||||
<p id="viewLastCalibrated" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
|
||||
<p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||
<p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium inline-flex items-center gap-2">--</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
||||
@@ -378,15 +378,12 @@
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Seismograph Information</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
|
||||
<input type="date" name="last_calibrated" id="lastCalibrated"
|
||||
class="w-full px-4 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-seismo-orange">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Next calibration due date will be calculated automatically</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
|
||||
<input type="date" name="next_calibration_due" id="nextCalibrationDue"
|
||||
class="w-full px-4 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-seismo-orange">
|
||||
</div>
|
||||
<input type="hidden" name="next_calibration_due" id="nextCalibrationDue">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
<div class="flex gap-2">
|
||||
@@ -589,6 +586,42 @@ let currentSnapshot = null;
|
||||
let unitMap = null;
|
||||
let mapMarker = null;
|
||||
|
||||
// Calibration interval in days (default 365, will be loaded from preferences)
|
||||
let calibrationIntervalDays = 365;
|
||||
|
||||
// Load calibration interval from preferences
|
||||
async function loadCalibrationInterval() {
|
||||
try {
|
||||
const response = await fetch('/api/settings/preferences');
|
||||
if (response.ok) {
|
||||
const prefs = await response.json();
|
||||
calibrationIntervalDays = prefs.calibration_interval_days || 365;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load calibration interval:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next calibration due date from last calibrated date
|
||||
function calculateNextCalibrationDue(lastCalibratedStr) {
|
||||
if (!lastCalibratedStr) return '';
|
||||
const lastCalibrated = new Date(lastCalibratedStr);
|
||||
const nextDue = new Date(lastCalibrated);
|
||||
nextDue.setDate(nextDue.getDate() + calibrationIntervalDays);
|
||||
return nextDue.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Setup auto-calculation for calibration fields
|
||||
function setupCalibrationAutoCalc() {
|
||||
const lastCal = document.getElementById('lastCalibrated');
|
||||
const nextCal = document.getElementById('nextCalibrationDue');
|
||||
if (lastCal && nextCal) {
|
||||
lastCal.addEventListener('change', function() {
|
||||
nextCal.value = calculateNextCalibrationDue(this.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch project display name (combines project_number, client_name, name)
|
||||
async function fetchProjectDisplay(projectId) {
|
||||
if (!projectId) return '';
|
||||
@@ -819,7 +852,28 @@ function populateViewMode() {
|
||||
|
||||
// Seismograph fields
|
||||
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
|
||||
|
||||
// Calculate next calibration due and show with status indicator
|
||||
const nextCalDueEl = document.getElementById('viewNextCalibrationDue');
|
||||
if (currentUnit.last_calibrated) {
|
||||
const nextDue = calculateNextCalibrationDue(currentUnit.last_calibrated);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const daysUntil = Math.floor((new Date(nextDue) - new Date(today)) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let dotColor = 'bg-green-500';
|
||||
let tooltip = `Calibration valid (${daysUntil} days remaining)`;
|
||||
if (daysUntil < 0) {
|
||||
dotColor = 'bg-red-500';
|
||||
tooltip = `Calibration expired ${-daysUntil} days ago`;
|
||||
} else if (daysUntil <= 14) {
|
||||
dotColor = 'bg-yellow-500';
|
||||
tooltip = `Calibration expires in ${daysUntil} days`;
|
||||
}
|
||||
|
||||
nextCalDueEl.innerHTML = `<span class="w-2 h-2 rounded-full ${dotColor}" title="${tooltip}"></span>${nextDue}`;
|
||||
} else {
|
||||
nextCalDueEl.textContent = '--';
|
||||
}
|
||||
|
||||
// Deployed with modem - show as clickable link
|
||||
const modemLink = document.getElementById('viewDeployedWithModemLink');
|
||||
@@ -960,7 +1014,10 @@ function populateEditForm() {
|
||||
|
||||
// Seismograph fields
|
||||
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
||||
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
|
||||
// Calculate next calibration due from last calibrated
|
||||
document.getElementById('nextCalibrationDue').value = currentUnit.last_calibrated
|
||||
? calculateNextCalibrationDue(currentUnit.last_calibrated)
|
||||
: '';
|
||||
|
||||
// Populate modem picker for seismograph (uses -detail-seismo suffix)
|
||||
const modemPickerValue = document.getElementById('modem-picker-value-detail-seismo');
|
||||
@@ -1535,6 +1592,8 @@ async function pingModem() {
|
||||
}
|
||||
|
||||
// Load data when page loads
|
||||
loadCalibrationInterval();
|
||||
setupCalibrationAutoCalc();
|
||||
loadUnitData().then(() => {
|
||||
loadPhotos();
|
||||
loadUnitHistory();
|
||||
|
||||
Reference in New Issue
Block a user