diff --git a/backend/migrate_add_estimated_units.py b/backend/migrate_add_estimated_units.py new file mode 100644 index 0000000..da86c54 --- /dev/null +++ b/backend/migrate_add_estimated_units.py @@ -0,0 +1,62 @@ +""" +Migration: Add estimated_units to job_reservations + +Adds column: +- job_reservations.estimated_units: Estimated number of units for the reservation (nullable integer) +""" + +import sqlite3 +import sys +from pathlib import Path + +# Default database path (matches production pattern) +DB_PATH = "./data/seismo_fleet.db" + + +def migrate(db_path: str): + """Run the migration.""" + print(f"Migrating database: {db_path}") + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Check if job_reservations table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'") + if not cursor.fetchone(): + print("job_reservations table does not exist. Skipping migration.") + return + + # Get existing columns in job_reservations + cursor.execute("PRAGMA table_info(job_reservations)") + existing_cols = {row[1] for row in cursor.fetchall()} + + # Add estimated_units column if it doesn't exist + if 'estimated_units' not in existing_cols: + print("Adding estimated_units column to job_reservations...") + cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER") + else: + print("estimated_units column already exists. Skipping.") + + conn.commit() + print("Migration completed successfully!") + + except Exception as e: + print(f"Migration failed: {e}") + conn.rollback() + raise + finally: + conn.close() + + +if __name__ == "__main__": + db_path = DB_PATH + + if len(sys.argv) > 1: + db_path = sys.argv[1] + + if not Path(db_path).exists(): + print(f"Database not found: {db_path}") + sys.exit(1) + + migrate(db_path) diff --git a/backend/models.py b/backend/models.py index f436542..273c0bc 100644 --- a/backend/models.py +++ b/backend/models.py @@ -480,6 +480,7 @@ class JobReservation(Base): # For quantity reservations device_type = Column(String, default="seismograph") # seismograph | slm quantity_needed = Column(Integer, nullable=True) # e.g., 8 units + estimated_units = Column(Integer, nullable=True) # Metadata notes = Column(Text, nullable=True) diff --git a/backend/routers/fleet_calendar.py b/backend/routers/fleet_calendar.py index 9626d7a..db4990e 100644 --- a/backend/routers/fleet_calendar.py +++ b/backend/routers/fleet_calendar.py @@ -61,9 +61,53 @@ async def fleet_calendar_page( # Get projects for the reservation form dropdown projects = db.query(Project).filter( - Project.status == "active" + Project.status.in_(["active", "upcoming", "on_hold"]) ).order_by(Project.name).all() + # Build a serializable list of items with dates for calendar bars + # Includes both tracked Projects (with dates) and Job Reservations (matching device_type) + project_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316'] + # Map calendar device_type to project_type_ids + device_type_to_project_types = { + "seismograph": ["vibration_monitoring", "combined"], + "slm": ["sound_monitoring", "combined"], + } + relevant_project_types = device_type_to_project_types.get(device_type, []) + + calendar_projects = [] + for i, p in enumerate(projects): + if p.start_date and p.project_type_id in relevant_project_types: + calendar_projects.append({ + "id": p.id, + "name": p.name, + "start_date": p.start_date.isoformat(), + "end_date": p.end_date.isoformat() if p.end_date else None, + "color": project_colors[i % len(project_colors)], + "confirmed": True, + }) + + # Add job reservations for this device_type as bars + from sqlalchemy import or_ as _or + cal_window_end = date(year + ((month + 10) // 12), ((month + 10) % 12) + 1, 1) + reservations_for_cal = db.query(JobReservation).filter( + JobReservation.device_type == device_type, + JobReservation.start_date <= cal_window_end, + _or( + JobReservation.end_date >= date(year, month, 1), + JobReservation.end_date == None, + ) + ).all() + for res in reservations_for_cal: + end = res.end_date or res.estimated_end_date + calendar_projects.append({ + "id": res.id, + "name": res.name, + "start_date": res.start_date.isoformat(), + "end_date": end.isoformat() if end else None, + "color": res.color, + "confirmed": bool(res.project_id), + }) + # Calculate prev/next month navigation prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1) next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1) @@ -81,6 +125,7 @@ async def fleet_calendar_page( "device_type": device_type, "calendar_data": calendar_data, "projects": projects, + "calendar_projects": calendar_projects, "today": today.isoformat() } ) @@ -178,6 +223,7 @@ async def create_reservation( assignment_type=data["assignment_type"], device_type=data.get("device_type", "seismograph"), quantity_needed=data.get("quantity_needed"), + estimated_units=data.get("estimated_units"), notes=data.get("notes"), color=data.get("color", "#3B82F6") ) @@ -240,6 +286,7 @@ async def get_reservation( "assignment_type": reservation.assignment_type, "device_type": reservation.device_type, "quantity_needed": reservation.quantity_needed, + "estimated_units": reservation.estimated_units, "notes": reservation.notes, "color": reservation.color, "assigned_units": [ @@ -287,6 +334,8 @@ async def update_reservation( reservation.assignment_type = data["assignment_type"] if "quantity_needed" in data: reservation.quantity_needed = data["quantity_needed"] + if "estimated_units" in data: + reservation.estimated_units = data["estimated_units"] if "notes" in data: reservation.notes = data["notes"] if "color" in data: @@ -525,8 +574,9 @@ async def get_reservations_list( else: end_date = date(end_year, end_month + 1, 1) - timedelta(days=1) - # Include TBD reservations that started before window end — show ALL device types + # Filter by device_type and date window reservations = db.query(JobReservation).filter( + JobReservation.device_type == device_type, JobReservation.start_date <= end_date, or_( JobReservation.end_date >= start_date, @@ -563,9 +613,11 @@ async def get_reservations_list( # Check for calibration conflicts conflicts = check_calibration_conflicts(db, res.id) + location_count = res.quantity_needed or assigned_count reservation_data.append({ "reservation": res, "assigned_count": assigned_count, + "location_count": location_count, "assigned_units": assigned_units, "has_conflicts": len(conflicts) > 0, "conflict_count": len(conflicts) @@ -601,13 +653,35 @@ async def get_planner_availability( raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id) else: - # No dates: return all non-retired units of this type + # No dates: return all non-retired units of this type, with current reservation info from backend.models import RosterUnit as RU from datetime import timedelta + today = date.today() all_units = db.query(RU).filter( RU.device_type == device_type, RU.retired == False ).all() + + # Build a map: unit_id -> list of active/upcoming reservations + active_assignments = db.query(JobReservationUnit).join( + JobReservation, JobReservationUnit.reservation_id == JobReservation.id + ).filter( + JobReservation.device_type == device_type, + JobReservation.end_date >= today + ).all() + unit_reservations = {} + for assignment in active_assignments: + res = db.query(JobReservation).filter(JobReservation.id == assignment.reservation_id).first() + if not res: + continue + unit_reservations.setdefault(assignment.unit_id, []).append({ + "reservation_id": res.id, + "reservation_name": res.name, + "start_date": res.start_date.isoformat() if res.start_date else None, + "end_date": res.end_date.isoformat() if res.end_date else None, + "color": res.color or "#3B82F6" + }) + units = [] for u in all_units: expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None @@ -618,7 +692,8 @@ async def get_planner_availability( "calibration_status": "needs_calibration" if not u.last_calibrated else "valid", "deployed": u.deployed, "out_for_calibration": u.out_for_calibration or False, - "note": u.note or "" + "note": u.note or "", + "reservations": unit_reservations.get(u.id, []) }) # Sort: benched first (easier to assign), then deployed, then by ID @@ -736,7 +811,7 @@ async def promote_reservation_to_project( project_number=project_number, client_name=client_name, project_type_id=project_type_id, - status="active", + status="upcoming", start_date=reservation.start_date, end_date=reservation.end_date, description=reservation.notes, diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 29e71cd..ee58eb5 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -373,11 +373,13 @@ async def get_projects_list( """ query = db.query(Project) - # Filter by status if provided; otherwise exclude soft-deleted projects - if status: + # Filter by status if provided; otherwise exclude archived/deleted from default view + if status == "all": + query = query.filter(Project.status != "deleted") + elif status: query = query.filter(Project.status == status) else: - query = query.filter(Project.status != "deleted") + query = query.filter(Project.status.notin_(["deleted", "archived", "completed"])) # Filter by project type if provided if project_type_id: @@ -438,6 +440,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)): """ # Count projects by status (exclude deleted) total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar() + upcoming_projects = db.query(func.count(Project.id)).filter_by(status="upcoming").scalar() active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar() on_hold_projects = db.query(func.count(Project.id)).filter_by(status="on_hold").scalar() completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar() @@ -459,6 +462,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)): "request": request, "total_projects": total_projects, "active_projects": active_projects, + "upcoming_projects": upcoming_projects, "on_hold_projects": on_hold_projects, "completed_projects": completed_projects, "total_locations": total_locations, diff --git a/templates/base.html b/templates/base.html index c4d391e..36bd6c0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -155,7 +155,7 @@ - Reservation Planner + Job Planner diff --git a/templates/fleet_calendar.html b/templates/fleet_calendar.html index 8be32d7..c36c72b 100644 --- a/templates/fleet_calendar.html +++ b/templates/fleet_calendar.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Fleet Calendar - Terra-View{% endblock %} +{% block title %}Job Planner - Terra-View{% endblock %} {% block extra_head %} {% endblock %} {% block content %}
-
-
-

Fleet Calendar

-

Plan unit assignments and track calibrations

-
-
+

Job Planner

+

Plan and manage field deployments

- +
- - - +
- +
- -
- - -
- - +
-

Project Reservations

+

Jobs

- +