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');
}