feat(locations): soft-remove monitoring locations without destroying history

When a client drops a location from scope mid-project (e.g. the office
half of a museum+office monitoring job), operators couldn't previously
mark it as no-longer-active without either deleting it (which would
orphan historical events) or leaving it in the active list looking
deployable.  Now there's a proper middle ground.

Data model
- MonitoringLocation gets two new nullable columns:
  - removed_at      — NULL means active; set means soft-removed
  - removal_reason  — optional operator note
  Migration: backend/migrate_add_location_removed.py (idempotent)

Endpoints
- POST /api/projects/{p}/locations/{l}/remove
    Body: { effective_date?: ISO-datetime, reason?: str }
    Side effects (cascade):
      1. Closes active UnitAssignment rows at this location
         (assigned_until = effective_date, status = "completed")
      2. Cancels pending ScheduledActions at this location
      3. Marks location.removed_at = effective_date
    Returns counts of assignments closed + actions cancelled.
- POST /api/projects/{p}/locations/{l}/restore
    Clears removed_at + removal_reason.  Does NOT auto-reopen
    assignments — operator creates new ones if resuming monitoring.

Active-surface filters
- locations-json defaults to active-only; pass include_removed=true
  for historical / reporting views.  Schedule modal dropdowns now
  exclude removed locations automatically.
- Metadata-backfill fuzzy matcher excludes removed locations from
  proposed targets (don't want backfill creating new assignments at
  decommissioned locations).
- Vibration-summary per_location rollup includes removed locations
  (so historical event totals stay accurate) but tags each with
  removed_at so the UI can show a badge.

UI
- Project detail page's Monitoring Locations section now splits into:
    Active locations (full card with Assign / Edit / Remove / Delete)
    Removed locations (collapsed <details>, greyed cards, Restore button,
                       shows removal date + reason)
- New per-card "Remove" button → opens confirmation modal explaining
  the cascade, with optional effective-date (defaults to now,
  backdateable) and reason fields.
- Unit detail's SFM Events attribution cell shows a small "removed"
  badge next to historical attributions whose location is no longer
  active.  Same pattern in vibration_summary's top-locations list.
- Soft-removal indicator surfaced through the events_for_unit
  attribution payload as location_removed_at.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:22:40 +00:00
parent fd37425f1c
commit d5a0163852
9 changed files with 531 additions and 38 deletions
+63
View File
@@ -0,0 +1,63 @@
"""
Migration: add `removed_at` + `removal_reason` columns to `monitoring_locations`.
Lets operators mark a location as no longer actively monitored without
deleting it (so historical events stay attributed correctly). Mirrors
the timestamp-based "closed state" pattern already used by
`unit_assignments.assigned_until`.
Behavior:
- `removed_at IS NULL` → location is active (default for all existing
rows after this migration)
- `removed_at` set → location is removed; historical events still
attribute to it but it's hidden from active
surfaces (assign dropdowns, calendar, etc.)
- `removal_reason` → optional operator note (e.g. "client dropped
from scope")
Idempotent — safe to re-run. Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
"""
import os
import sqlite3
DB_PATH = "./data/seismo_fleet.db"
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
cur.execute(f"PRAGMA table_info({table})")
return any(row[1] == column for row in cur.fetchall())
def migrate_database() -> None:
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
added = []
if not _has_column(cur, "monitoring_locations", "removed_at"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removed_at DATETIME")
added.append("removed_at")
if not _has_column(cur, "monitoring_locations", "removal_reason"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removal_reason TEXT")
added.append("removal_reason")
conn.commit()
conn.close()
if added:
print(f" Added columns to monitoring_locations: {', '.join(added)}")
else:
print(" monitoring_locations already has removed_at + removal_reason — nothing to do.")
if __name__ == "__main__":
print("Running migration: add removed_at + removal_reason to monitoring_locations")
migrate_database()
print("Done.")
+8
View File
@@ -235,6 +235,14 @@ class MonitoringLocation(Base):
# For vibration: {"ground_type": "bedrock", "depth": "10m"} # For vibration: {"ground_type": "bedrock", "depth": "10m"}
location_metadata = Column(Text, nullable=True) location_metadata = Column(Text, nullable=True)
# Soft-removal: NULL means active. When set, the location is hidden from
# active surfaces (assign dropdowns, calendar, scheduler, dashboard
# vibration summary) but historical events generated before this time
# still attribute to it. Mirrors the closed-state pattern used by
# UnitAssignment.assigned_until.
removed_at = Column(DateTime, nullable=True)
removal_reason = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+3
View File
@@ -361,6 +361,9 @@ def locations_search(
db.query(MonitoringLocation) db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == project_id) .filter(MonitoringLocation.project_id == project_id)
.filter(MonitoringLocation.location_type == "vibration") .filter(MonitoringLocation.location_type == "vibration")
# Don't propose creating assignments at removed locations — they
# were intentionally decommissioned and shouldn't be backfill targets.
.filter(MonitoringLocation.removed_at == None) # noqa: E711
.all() .all()
) )
+197 -17
View File
@@ -31,6 +31,7 @@ from backend.models import (
MonitoringSession, MonitoringSession,
DataFile, DataFile,
UnitHistory, UnitHistory,
ScheduledAction,
) )
from backend.templates_config import templates from backend.templates_config import templates
from backend.utils.timezone import local_to_utc from backend.utils.timezone import local_to_utc
@@ -138,7 +139,7 @@ async def get_project_locations(
): ):
""" """
Get all monitoring locations for a project. Get all monitoring locations for a project.
Returns HTML partial with location list. Returns HTML partial with location list, split into active + removed.
""" """
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
if not project: if not project:
@@ -152,10 +153,14 @@ async def get_project_locations(
locations = query.order_by(MonitoringLocation.name).all() locations = query.order_by(MonitoringLocation.name).all()
# Enrich with assignment info # Enrich with assignment info, splitting active vs removed.
locations_data = [] active_data: list = []
removed_data: list = []
for location in locations: for location in locations:
# Get active assignment (active = assigned_until IS NULL) # Get active assignment (active = assigned_until IS NULL). For
# removed locations this will normally be None because the
# /remove cascade closes them, but check anyway for resilience
# against legacy data.
assignment = db.query(UnitAssignment).filter( assignment = db.query(UnitAssignment).filter(
and_( and_(
UnitAssignment.location_id == location.id, UnitAssignment.location_id == location.id,
@@ -172,17 +177,23 @@ async def get_project_locations(
location_id=location.id location_id=location.id
).count() ).count()
locations_data.append({ item = {
"location": location, "location": location,
"assignment": assignment, "assignment": assignment,
"assigned_unit": assigned_unit, "assigned_unit": assigned_unit,
"session_count": session_count, "session_count": session_count,
}) }
if location.removed_at is None:
active_data.append(item)
else:
removed_data.append(item)
return templates.TemplateResponse("partials/projects/location_list.html", { return templates.TemplateResponse("partials/projects/location_list.html", {
"request": request, "request": request,
"project": project, "project": project,
"locations": locations_data, "locations": active_data, # back-compat alias
"active_locations": active_data,
"removed_locations": removed_data,
}) })
@@ -191,10 +202,15 @@ async def get_project_locations_json(
project_id: str, project_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
location_type: Optional[str] = Query(None), location_type: Optional[str] = Query(None),
include_removed: bool = Query(False),
): ):
""" """
Get all monitoring locations for a project as JSON. Get all monitoring locations for a project as JSON.
Used by the schedule modal to populate location dropdown. Used by the schedule modal to populate location dropdown.
Removed locations are filtered out by default (you can't schedule
a new action at a removed location). Pass `include_removed=true`
to get them too — useful for historical / reporting views.
""" """
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
if not project: if not project:
@@ -205,16 +221,21 @@ async def get_project_locations_json(
if location_type: if location_type:
query = query.filter_by(location_type=location_type) query = query.filter_by(location_type=location_type)
if not include_removed:
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
locations = query.order_by(MonitoringLocation.name).all() locations = query.order_by(MonitoringLocation.name).all()
return [ return [
{ {
"id": loc.id, "id": loc.id,
"name": loc.name, "name": loc.name,
"location_type": loc.location_type, "location_type": loc.location_type,
"description": loc.description, "description": loc.description,
"address": loc.address, "address": loc.address,
"coordinates": loc.coordinates, "coordinates": loc.coordinates,
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
"removal_reason": loc.removal_reason,
} }
for loc in locations for loc in locations
] ]
@@ -335,6 +356,165 @@ async def delete_location(
return {"success": True, "message": "Location deleted successfully"} return {"success": True, "message": "Location deleted successfully"}
@router.post("/locations/{location_id}/remove")
async def remove_location(
project_id: str,
location_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Soft-remove a monitoring location — mark it as no longer actively
monitored without destroying it.
Use case: a client drops a location from scope mid-project, but the
historical events recorded there should remain attributed. Deleting
would orphan those events; this preserves them.
Cascading side-effects:
1. All active UnitAssignment rows at this location are closed
(assigned_until = effective_date, status = "completed").
Units become available for other deployments.
2. All pending ScheduledAction rows at this location are cancelled
(execution_status = "cancelled").
3. Historical events stay attributed (attribution is window-based;
events with timestamp < effective_date still match the
now-closed assignment windows).
Accepts JSON body:
- effective_date: ISO datetime (optional, defaults to now)
- reason: operator note (optional)
"""
location = db.query(MonitoringLocation).filter_by(
id=location_id,
project_id=project_id,
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if location.removed_at is not None:
raise HTTPException(
status_code=400,
detail=f"Location is already removed (as of {location.removed_at.isoformat()}).",
)
# Body is optional — POST with no body is fine and means "remove now,
# no reason given."
try:
payload = await request.json()
except Exception:
payload = {}
# Effective date: accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or
# full ISO. Defaults to now if absent/empty.
raw_eff = payload.get("effective_date")
if raw_eff:
try:
effective_date = datetime.fromisoformat(raw_eff)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"Invalid effective_date: {raw_eff!r}",
)
else:
effective_date = datetime.utcnow()
reason = (payload.get("reason") or "").strip() or None
# 1. Close active assignments at this location.
active_assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.assigned_until == None, # noqa: E711 — SQL NULL
)
).all()
for a in active_assignments:
a.status = "completed"
a.assigned_until = effective_date
_record_assignment_history(
db,
unit_id=a.unit_id,
change_type="assignment_ended",
old_value=location.name,
new_value="location removed",
notes=f"Location '{location.name}' marked as removed"
+ (f"{reason}" if reason else ""),
)
# 2. Cancel pending scheduled actions at this location.
pending_actions = db.query(ScheduledAction).filter(
and_(
ScheduledAction.location_id == location_id,
ScheduledAction.execution_status == "pending",
ScheduledAction.scheduled_time >= effective_date,
)
).all()
for sa in pending_actions:
sa.execution_status = "cancelled"
sa.error_message = (
f"Cancelled: location '{location.name}' marked as removed"
+ (f"{reason}" if reason else "")
)
# 3. Mark the location itself as removed.
location.removed_at = effective_date
location.removal_reason = reason
location.updated_at = datetime.utcnow()
db.commit()
return {
"success": True,
"message": f"Location '{location.name}' marked as removed",
"effective_date": effective_date.isoformat(),
"assignments_closed": len(active_assignments),
"actions_cancelled": len(pending_actions),
}
@router.post("/locations/{location_id}/restore")
async def restore_location(
project_id: str,
location_id: str,
db: Session = Depends(get_db),
):
"""
Restore a previously-removed monitoring location to active.
Clears `removed_at` and `removal_reason`. Does NOT automatically
re-open the assignments or scheduled actions that were closed when
the location was removed — those stay closed and the operator can
create new ones if they want to resume monitoring.
"""
location = db.query(MonitoringLocation).filter_by(
id=location_id,
project_id=project_id,
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if location.removed_at is None:
raise HTTPException(
status_code=400,
detail="Location is already active.",
)
location.removed_at = None
location.removal_reason = None
location.updated_at = datetime.utcnow()
db.commit()
return {
"success": True,
"message": f"Location '{location.name}' restored to active",
}
# ============================================================================ # ============================================================================
# Unit Assignments # Unit Assignments
# ============================================================================ # ============================================================================
+16 -7
View File
@@ -318,13 +318,18 @@ async def events_for_unit(
loc = loc_map.get(a.location_id) loc = loc_map.get(a.location_id)
proj = proj_map.get(a.project_id) proj = proj_map.get(a.project_id)
return { return {
"assignment_id": a.id, "assignment_id": a.id,
"location_id": a.location_id, "location_id": a.location_id,
"location_name": loc.name if loc else None, "location_name": loc.name if loc else None,
"project_id": a.project_id, # Soft-removal indicator so the UI can render a "(removed)"
"project_name": proj.name if proj else None, # badge next to historical attributions whose location is no
"assigned_at": _iso_utc(a.assigned_at), # longer actively monitored.
"assigned_until": _iso_utc(a.assigned_until), "location_removed_at": (loc.removed_at.isoformat()
if loc and loc.removed_at else None),
"project_id": a.project_id,
"project_name": proj.name if proj else None,
"assigned_at": _iso_utc(a.assigned_at),
"assigned_until": _iso_utc(a.assigned_until),
} }
# 2. Fetch all events for this serial in one shot. # 2. Fetch all events for this serial in one shot.
@@ -515,6 +520,10 @@ async def vibration_summary_for_project(
"event_count": ec, "event_count": ec,
"peak_pvs": ev_peak, "peak_pvs": ev_peak,
"last_event": ev_last, "last_event": ev_last,
# Soft-removal state — UI can show a "(removed)" badge in the
# per-location list so operators see at a glance that a row's
# numbers are historical-only.
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
}) })
per_location.sort(key=lambda r: r["event_count"], reverse=True) per_location.sort(key=lambda r: r["event_count"], reverse=True)
+91 -13
View File
@@ -1,7 +1,21 @@
<!-- Project Locations List --> <!-- Project Locations List — split into Active + Removed sections.
{% if locations %} Active locations get the full card with Assign/Edit/Delete/Remove
actions. Removed locations get a greyed-out card with a
Removed-on date, optional reason, and a Restore button. -->
{% if not active_locations and not removed_locations %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p>No locations added yet</p>
</div>
{% else %}
{# ─── Active locations ─── #}
{% if active_locations %}
<div class="space-y-3"> <div class="space-y-3">
{% for item in locations %} {% for item in active_locations %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors"> <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -24,11 +38,13 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{% if item.assignment %} {% if item.assignment %}
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"> <button onclick="unassignUnit('{{ item.assignment.id }}')"
class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
Unassign Unassign
</button> </button>
{% else %} {% else %}
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')" class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy"> <button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')"
class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
Assign Assign
</button> </button>
{% endif %} {% endif %}
@@ -37,7 +53,14 @@
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"> class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
Edit Edit
</button> </button>
<button onclick="deleteLocation('{{ item.location.id }}')" class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"> <button onclick="openRemoveLocationModal('{{ item.location.id }}', {{ item.location.name | tojson }})"
class="text-xs px-3 py-1 rounded-full bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300 hover:bg-amber-100"
title="Mark as no longer actively monitored — preserves historical events">
Remove
</button>
<button onclick="deleteLocation('{{ item.location.id }}')"
class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
title="Permanently delete — only available if there's no history">
Delete Delete
</button> </button>
</div> </div>
@@ -54,11 +77,66 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% endif %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {# ─── Removed locations (collapsed by default) ─── #}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path> {% if removed_locations %}
</svg> <details class="mt-6 group" {% if not active_locations %}open{% endif %}>
<p>No locations added yet</p> <summary class="cursor-pointer text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 select-none list-none">
</div> <span class="inline-flex items-center gap-2">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" 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>
Removed locations
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">{{ removed_locations | length }}</span>
</span>
<p class="ml-6 mt-1 text-xs text-gray-400 dark:text-gray-500">Historical only — events stay attributed, but no new assignments or schedules can be created here.</p>
</summary>
<div class="space-y-3 mt-3">
{% for item in removed_locations %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-slate-900/30 opacity-75">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
class="font-semibold text-gray-700 dark:text-gray-300 hover:text-seismo-orange truncate">
{{ item.location.name }}
</a>
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold">
Removed
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ item.location.removed_at.strftime('%Y-%m-%d') if item.location.removed_at else '—' }}
</span>
</div>
{% if item.location.removal_reason %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">"{{ item.location.removal_reason }}"</p>
{% endif %}
{% if item.location.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
{% endif %}
{% if item.location.address %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
{% endif %}
</div>
<div class="flex items-center gap-2">
<button onclick="restoreLocation('{{ item.location.id }}', {{ item.location.name | tojson }})"
class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200"
title="Restore to active monitoring">
Restore
</button>
</div>
</div>
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
<span>Historical sessions: {{ item.session_count }}</span>
</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% endif %} {% endif %}
@@ -60,6 +60,10 @@
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors"> class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-sm font-medium text-gray-900 dark:text-white truncate"> <span class="text-sm font-medium text-gray-900 dark:text-white truncate">
📍 {{ loc.location_name }} 📍 {{ loc.location_name }}
{% if loc.removed_at %}
<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold align-middle"
title="Location no longer actively monitored — events shown are historical">removed</span>
{% endif %}
</span> </span>
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3"> <span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span> <span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
+143
View File
@@ -778,6 +778,61 @@
</div> </div>
</div> </div>
<!-- Remove Location Confirmation Modal —
Soft-removal: preserves historical events, closes active assignments,
cancels pending scheduled actions. Distinct from Delete (which is
permanent and only allowed when there's no history). -->
<div id="remove-location-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2a4 4 0 014-4h6m0 0l-3-3m3 3l-3 3M5 7h8a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove location</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Mark <span id="remove-location-name" class="font-semibold text-gray-900 dark:text-white"></span> as no longer actively monitored.
</p>
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-4 space-y-1 ml-4 list-disc">
<li>Closes any active unit assignment at this location</li>
<li>Cancels pending scheduled actions at this location</li>
<li>Historical events stay attributed (visible in reports + event lists)</li>
<li>Can be restored later if needed</li>
</ul>
<input type="hidden" id="remove-location-id">
<div class="mb-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Effective date</label>
<input type="datetime-local" id="remove-location-effective"
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Defaults to now. Backdate if the location was physically removed earlier.</p>
</div>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
<input type="text" id="remove-location-reason" maxlength="200"
placeholder="e.g. client dropped from scope"
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div id="remove-location-error" class="hidden text-sm text-red-600 mb-3"></div>
<div class="flex justify-end gap-2">
<button onclick="closeRemoveLocationModal()"
class="px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button onclick="confirmRemoveLocation()"
class="px-4 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
Remove
</button>
</div>
</div>
</div>
<!-- Delete Project Confirmation Modal --> <!-- Delete Project Confirmation Modal -->
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center"> <div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
@@ -1213,6 +1268,94 @@ async function deleteLocation(locationId) {
} }
} }
// ── Remove / Restore location ────────────────────────────────────────
// Soft-removal: marks a location as no longer actively monitored without
// destroying it. Historical events stay attributed; active assignments
// are auto-closed and pending scheduled actions are auto-cancelled.
function openRemoveLocationModal(locationId, locationName) {
document.getElementById('remove-location-id').value = locationId;
document.getElementById('remove-location-name').textContent = locationName;
document.getElementById('remove-location-reason').value = '';
// Default effective_date to "now" in local datetime-input format.
const now = new Date();
const tzOffsetMin = now.getTimezoneOffset();
const local = new Date(now.getTime() - tzOffsetMin * 60000);
document.getElementById('remove-location-effective').value =
local.toISOString().slice(0, 16);
document.getElementById('remove-location-error').classList.add('hidden');
document.getElementById('remove-location-modal').classList.remove('hidden');
}
function closeRemoveLocationModal() {
document.getElementById('remove-location-modal').classList.add('hidden');
}
async function confirmRemoveLocation() {
const locationId = document.getElementById('remove-location-id').value;
const reason = document.getElementById('remove-location-reason').value.trim();
const effective = document.getElementById('remove-location-effective').value;
const errBox = document.getElementById('remove-location-error');
errBox.classList.add('hidden');
try {
const response = await fetch(
`/api/projects/${projectId}/locations/${locationId}/remove`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reason: reason || null,
effective_date: effective || null,
}),
}
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to remove location');
}
const result = await response.json();
closeRemoveLocationModal();
refreshLocationLists();
refreshProjectDashboard();
// Lightweight feedback — the UI refresh already shows the location
// moving to the Removed section, but a toast confirms the cascade.
if (typeof showToast === 'function') {
const bits = [];
if (result.assignments_closed) bits.push(`${result.assignments_closed} assignment(s) closed`);
if (result.actions_cancelled) bits.push(`${result.actions_cancelled} action(s) cancelled`);
const tail = bits.length ? ` (${bits.join(', ')})` : '';
showToast(`Location removed${tail}`, 'success');
}
} catch (err) {
errBox.textContent = err.message || 'Failed to remove location.';
errBox.classList.remove('hidden');
}
}
async function restoreLocation(locationId, locationName) {
if (!confirm(`Restore "${locationName}" to active monitoring?\n\nNote: previously-closed assignments are NOT automatically re-opened — you'll need to re-assign units if you want to resume monitoring.`)) {
return;
}
try {
const response = await fetch(
`/api/projects/${projectId}/locations/${locationId}/restore`,
{ method: 'POST' }
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to restore location');
}
refreshLocationLists();
refreshProjectDashboard();
if (typeof showToast === 'function') {
showToast(`"${locationName}" restored to active`, 'success');
}
} catch (err) {
alert(err.message || 'Failed to restore location.');
}
}
// Assign modal functions // Assign modal functions
function openAssignModal(locationId, locationType) { function openAssignModal(locationId, locationType) {
const safeType = locationType || 'sound'; const safeType = locationType || 'sound';
+6 -1
View File
@@ -2286,12 +2286,17 @@ function _ueAttrCell(ev) {
if (a) { if (a) {
const projLabel = _ueEsc(a.project_name || '—'); const projLabel = _ueEsc(a.project_name || '—');
const locLabel = _ueEsc(a.location_name || '—'); const locLabel = _ueEsc(a.location_name || '—');
// If the attributed location has since been soft-removed, badge
// it so operators see at a glance this is historical attribution.
const removedBadge = a.location_removed_at
? '<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold" title="Location no longer actively monitored">removed</span>'
: '';
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}" return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
onclick="event.stopPropagation()" onclick="event.stopPropagation()"
class="text-seismo-orange hover:text-seismo-navy" class="text-seismo-orange hover:text-seismo-navy"
title="${projLabel} → ${locLabel}"> title="${projLabel} → ${locLabel}">
📍 ${locLabel} 📍 ${locLabel}
</a> </a>${removedBadge}
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`; <div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
} }
const n = ev.nearest_assignment; const n = ev.nearest_assignment;