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:
@@ -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.")
|
||||
@@ -235,6 +235,14 @@ class MonitoringLocation(Base):
|
||||
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
||||
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)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -361,6 +361,9 @@ def locations_search(
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == project_id)
|
||||
.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()
|
||||
)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from backend.models import (
|
||||
MonitoringSession,
|
||||
DataFile,
|
||||
UnitHistory,
|
||||
ScheduledAction,
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
from backend.utils.timezone import local_to_utc
|
||||
@@ -138,7 +139,7 @@ async def get_project_locations(
|
||||
):
|
||||
"""
|
||||
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()
|
||||
if not project:
|
||||
@@ -152,10 +153,14 @@ async def get_project_locations(
|
||||
|
||||
locations = query.order_by(MonitoringLocation.name).all()
|
||||
|
||||
# Enrich with assignment info
|
||||
locations_data = []
|
||||
# Enrich with assignment info, splitting active vs removed.
|
||||
active_data: list = []
|
||||
removed_data: list = []
|
||||
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(
|
||||
and_(
|
||||
UnitAssignment.location_id == location.id,
|
||||
@@ -172,17 +177,23 @@ async def get_project_locations(
|
||||
location_id=location.id
|
||||
).count()
|
||||
|
||||
locations_data.append({
|
||||
item = {
|
||||
"location": location,
|
||||
"assignment": assignment,
|
||||
"assigned_unit": assigned_unit,
|
||||
"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", {
|
||||
"request": request,
|
||||
"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,
|
||||
db: Session = Depends(get_db),
|
||||
location_type: Optional[str] = Query(None),
|
||||
include_removed: bool = Query(False),
|
||||
):
|
||||
"""
|
||||
Get all monitoring locations for a project as JSON.
|
||||
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()
|
||||
if not project:
|
||||
@@ -205,6 +221,9 @@ async def get_project_locations_json(
|
||||
if 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()
|
||||
|
||||
return [
|
||||
@@ -215,6 +234,8 @@ async def get_project_locations_json(
|
||||
"description": loc.description,
|
||||
"address": loc.address,
|
||||
"coordinates": loc.coordinates,
|
||||
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
|
||||
"removal_reason": loc.removal_reason,
|
||||
}
|
||||
for loc in locations
|
||||
]
|
||||
@@ -335,6 +356,165 @@ async def delete_location(
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@@ -321,6 +321,11 @@ async def events_for_unit(
|
||||
"assignment_id": a.id,
|
||||
"location_id": a.location_id,
|
||||
"location_name": loc.name if loc else None,
|
||||
# Soft-removal indicator so the UI can render a "(removed)"
|
||||
# badge next to historical attributions whose location is no
|
||||
# longer actively monitored.
|
||||
"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),
|
||||
@@ -515,6 +520,10 @@ async def vibration_summary_for_project(
|
||||
"event_count": ec,
|
||||
"peak_pvs": ev_peak,
|
||||
"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)
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
<!-- Project Locations List -->
|
||||
{% if locations %}
|
||||
<!-- Project Locations List — split into Active + Removed sections.
|
||||
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">
|
||||
{% 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="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -24,11 +38,13 @@
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{% 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
|
||||
</button>
|
||||
{% 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
|
||||
</button>
|
||||
{% 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">
|
||||
Edit
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -54,11 +77,66 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{# ─── Removed locations (collapsed by default) ─── #}
|
||||
{% if removed_locations %}
|
||||
<details class="mt-6 group" {% if not active_locations %}open{% endif %}>
|
||||
<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">
|
||||
<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 %}
|
||||
|
||||
@@ -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">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
📍 {{ 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 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>
|
||||
|
||||
@@ -778,6 +778,61 @@
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -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
|
||||
function openAssignModal(locationId, locationType) {
|
||||
const safeType = locationType || 'sound';
|
||||
|
||||
@@ -2286,12 +2286,17 @@ function _ueAttrCell(ev) {
|
||||
if (a) {
|
||||
const projLabel = _ueEsc(a.project_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)}"
|
||||
onclick="event.stopPropagation()"
|
||||
class="text-seismo-orange hover:text-seismo-navy"
|
||||
title="${projLabel} → ${locLabel}">
|
||||
📍 ${locLabel}
|
||||
</a>
|
||||
</a>${removedBadge}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
||||
}
|
||||
const n = ev.nearest_assignment;
|
||||
|
||||
Reference in New Issue
Block a user