diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 7fda2eb..d62d6ff 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -830,6 +830,92 @@ async def update_assignment( } +@router.delete("/assignments/{assignment_id}") +async def delete_assignment( + project_id: str, + assignment_id: str, + db: Session = Depends(get_db), +): + """ + Hard-delete an assignment record. + + Use case: operator clicked Assign by mistake (or 8 times in a row) and + wants the bogus records gone — not just closed with an `assigned_until` + timestamp. The standard close-via-unassign path is for legitimate + deployments that ended; this is for mis-clicks that never actually + happened. + + Safety: + - Refuses if any MonitoringSession exists for the same (unit, location) + within this assignment's window — that suggests the deployment was + real, and the operator should use unassign instead. + - Refuses if the assignment is the ONLY active assignment for a unit + currently shown as deployed AND a recording session is in progress. + + Audit: + - Records UnitHistory `assignment_deleted` so the unit's deployment + timeline shows the deletion happened (even though the row itself + is gone). + """ + assignment = db.query(UnitAssignment).filter_by( + id=assignment_id, + project_id=project_id, + ).first() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + # Safety: is there a real recording history for this (unit, location) + # within the assignment's time window? If so, this isn't a mis-click — + # the operator should close it via unassign, not delete it. + window_start = assignment.assigned_at + window_end = assignment.assigned_until or datetime.utcnow() + real_sessions = db.query(MonitoringSession).filter( + and_( + MonitoringSession.location_id == assignment.location_id, + MonitoringSession.unit_id == assignment.unit_id, + MonitoringSession.start_time >= window_start, + MonitoringSession.start_time <= window_end, + ) + ).count() + + if real_sessions > 0: + raise HTTPException( + status_code=400, + detail=( + f"Cannot delete this assignment — {real_sessions} monitoring " + f"session(s) were recorded under it. Use Unassign to close " + f"the window instead, which preserves the audit trail." + ), + ) + + # Resolve location name for audit log before deletion. + location = db.query(MonitoringLocation).filter_by( + id=assignment.location_id + ).first() + location_label = location.name if location else assignment.location_id + + _record_assignment_history( + db, + unit_id=assignment.unit_id, + change_type="assignment_deleted", + old_value=f"{location_label} ({assignment.assigned_at:%Y-%m-%d} → " + f"{assignment.assigned_until and assignment.assigned_until.strftime('%Y-%m-%d') or 'active'})", + new_value="deleted", + notes=( + "Assignment row removed — created in error or accidental duplicate." + ), + ) + + db.delete(assignment) + db.commit() + + return { + "success": True, + "message": "Assignment deleted.", + } + + @router.post("/locations/{location_id}/swap") async def swap_unit_on_location( project_id: str, diff --git a/templates/vibration_location_detail.html b/templates/vibration_location_detail.html index 452447b..b8754bb 100644 --- a/templates/vibration_location_detail.html +++ b/templates/vibration_location_detail.html @@ -583,6 +583,14 @@ function renderAssignmentsUsed(assignments) { + ${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'} `; @@ -608,6 +616,33 @@ function openAssignmentEditModal(encodedJson) { document.getElementById('assignment-edit-modal').classList.remove('hidden'); } +async function deleteAssignment(assignmentId, unitId, windowLabel) { + // For mis-clicks / accidental duplicate assignments. Backend refuses + // if there's a real recording session inside the window — those should + // go through Edit or Unassign instead. + const msg = `Delete this assignment?\n\n` + + `Unit: ${unitId}\n` + + `Window: ${windowLabel}\n\n` + + `This is for assignments created in error. Events that fell ` + + `in this window will become unattributed. The unit's deployment ` + + `history will log the deletion for audit.\n\n` + + `If the unit actually was deployed here, use Edit or Unassign instead.`; + if (!confirm(msg)) return; + + try { + const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, { + method: 'DELETE', + }); + if (!r.ok) { + const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status})); + throw new Error(err.detail || 'HTTP ' + r.status); + } + await loadLocationEvents(); // Refresh stats + table without this assignment. + } catch (err) { + alert(err.message || 'Failed to delete assignment.'); + } +} + function closeAssignmentEditModal() { document.getElementById('assignment-edit-modal').classList.add('hidden'); }