From b3ec249c5e0b073adb57664bca7ae216712b876a Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 18 Mar 2026 22:15:46 +0000 Subject: [PATCH] feat: add location names to reservation slots and promote-to-project - Each monitoring location slot can now have a named location (e.g. "North Gate") - Location names and slot order are persisted and restored in the planner - Location names display in the expanded reservation card view - Added "Promote to Project" button that converts a reservation into a tracked project with monitoring locations and unit assignments pre-filled Requires DB migration on prod: ALTER TABLE job_reservation_units ADD COLUMN location_name TEXT; ALTER TABLE job_reservation_units ADD COLUMN slot_index INTEGER; --- backend/models.py | 4 + backend/routers/fleet_calendar.py | 134 ++++++++++++++++-- templates/fleet_calendar.html | 103 +++++++++++++- .../fleet_calendar/reservations_list.html | 16 ++- 4 files changed, 236 insertions(+), 21 deletions(-) diff --git a/backend/models.py b/backend/models.py index a626d8b..f436542 100644 --- a/backend/models.py +++ b/backend/models.py @@ -518,3 +518,7 @@ class JobReservationUnit(Base): # Power requirements for this deployment slot power_type = Column(String, nullable=True) # "ac" | "solar" | None + + # Location identity + location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance" + slot_index = Column(Integer, nullable=True) # Order within reservation (0-based) diff --git a/backend/routers/fleet_calendar.py b/backend/routers/fleet_calendar.py index 7603a03..9626d7a 100644 --- a/backend/routers/fleet_calendar.py +++ b/backend/routers/fleet_calendar.py @@ -19,7 +19,7 @@ import logging from backend.database import get_db from backend.models import ( RosterUnit, JobReservation, JobReservationUnit, - UserPreferences, Project + UserPreferences, Project, MonitoringLocation, UnitAssignment ) from backend.templates_config import templates from backend.services.fleet_calendar_service import ( @@ -221,12 +221,13 @@ async def get_reservation( reservation_id=reservation_id ).all() - unit_ids = [a.unit_id for a in assignments] + # Sort assignments by slot_index so order is preserved + assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999)) + unit_ids = [a.unit_id for a in assignments_sorted] units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else [] units_by_id = {u.id: u for u in units} - # Build power_type and notes lookup from assignments - power_type_map = {a.unit_id: a.power_type for a in assignments} - notes_map = {a.unit_id: a.notes for a in assignments} + # Build per-unit lookups from assignments + assignment_map = {a.unit_id: a for a in assignments_sorted} return { "id": reservation.id, @@ -246,8 +247,10 @@ async def get_reservation( "id": uid, "last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None, "deployed": units_by_id[uid].deployed if uid in units_by_id else False, - "power_type": power_type_map.get(uid), - "notes": notes_map.get(uid) + "power_type": assignment_map[uid].power_type, + "notes": assignment_map[uid].notes, + "location_name": assignment_map[uid].location_name, + "slot_index": assignment_map[uid].slot_index, } for uid in unit_ids ] @@ -343,9 +346,12 @@ async def assign_units_to_reservation( data = await request.json() unit_ids = data.get("unit_ids", []) - # Optional per-unit power types: {"BE17354": "ac", "BE9441": "solar"} + # Optional per-unit dicts keyed by unit_id power_types = data.get("power_types", {}) location_notes = data.get("location_notes", {}) + location_names = data.get("location_names", {}) + # slot_indices: {"BE17354": 0, "BE9441": 1, ...} + slot_indices = data.get("slot_indices", {}) # Verify units exist (allow empty list to clear all assignments) if unit_ids: @@ -388,7 +394,9 @@ async def assign_units_to_reservation( unit_id=unit_id, assignment_source="filled" if reservation.assignment_type == "quantity" else "specific", power_type=power_types.get(unit_id), - notes=location_notes.get(unit_id) + notes=location_notes.get(unit_id), + location_name=location_names.get(unit_id), + slot_index=slot_indices.get(unit_id), ) db.add(assignment) @@ -534,8 +542,9 @@ async def get_reservations_list( ).all() assigned_count = len(assignments) - # Enrich assignments with unit details - unit_ids = [a.unit_id for a in assignments] + # Enrich assignments with unit details, sorted by slot_index + assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999)) + unit_ids = [a.unit_id for a in assignments_sorted] units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else [] units_by_id = {u.id: u for u in units} assigned_units = [ @@ -543,10 +552,12 @@ async def get_reservations_list( "id": a.unit_id, "power_type": a.power_type, "notes": a.notes, + "location_name": a.location_name, + "slot_index": a.slot_index, "deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False, "last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None, } - for a in assignments + for a in assignments_sorted ] # Check for calibration conflicts @@ -680,3 +691,102 @@ async def get_month_partial( "today": date.today().isoformat() } ) + + +# ============================================================================ +# Promote Reservation to Project +# ============================================================================ + +@router.post("/api/fleet-calendar/reservations/{reservation_id}/promote-to-project", response_class=JSONResponse) +async def promote_reservation_to_project( + reservation_id: str, + request: Request, + db: Session = Depends(get_db) +): + """ + Promote a job reservation to a full project in the projects DB. + Creates: Project + MonitoringLocations + UnitAssignments. + """ + reservation = db.query(JobReservation).filter_by(id=reservation_id).first() + if not reservation: + raise HTTPException(status_code=404, detail="Reservation not found") + + data = await request.json() + project_number = data.get("project_number") or None + client_name = data.get("client_name") or None + + # Map device_type to project_type_id + if reservation.device_type == "slm": + project_type_id = "sound_monitoring" + location_type = "sound" + else: + project_type_id = "vibration_monitoring" + location_type = "vibration" + + # Check for duplicate project name + existing = db.query(Project).filter_by(name=reservation.name).first() + if existing: + raise HTTPException(status_code=409, detail=f"A project named '{reservation.name}' already exists.") + + # Create the project + project_id = str(uuid.uuid4()) + project = Project( + id=project_id, + name=reservation.name, + project_number=project_number, + client_name=client_name, + project_type_id=project_type_id, + status="active", + start_date=reservation.start_date, + end_date=reservation.end_date, + description=reservation.notes, + ) + db.add(project) + db.flush() + + # Load assignments sorted by slot_index + assignments = db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).all() + assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999)) + + locations_created = 0 + units_assigned = 0 + + for i, assignment in enumerate(assignments_sorted): + loc_num = str(i + 1).zfill(3) + loc_name = assignment.location_name or f"Location {i + 1}" + + location = MonitoringLocation( + id=str(uuid.uuid4()), + project_id=project_id, + location_type=location_type, + name=loc_name, + description=assignment.notes, + ) + db.add(location) + db.flush() + locations_created += 1 + + if assignment.unit_id: + unit_assignment = UnitAssignment( + id=str(uuid.uuid4()), + unit_id=assignment.unit_id, + location_id=location.id, + project_id=project_id, + device_type=reservation.device_type or "seismograph", + status="active", + notes=f"Power: {assignment.power_type}" if assignment.power_type else None, + ) + db.add(unit_assignment) + units_assigned += 1 + + db.commit() + + logger.info(f"Promoted reservation '{reservation.name}' to project {project_id}") + + return { + "success": True, + "project_id": project_id, + "project_name": reservation.name, + "locations_created": locations_created, + "units_assigned": units_assigned, + } diff --git a/templates/fleet_calendar.html b/templates/fleet_calendar.html index a4e3beb..8be32d7 100644 --- a/templates/fleet_calendar.html +++ b/templates/fleet_calendar.html @@ -626,6 +626,37 @@ + + +