Compare commits
3 Commits
295f9637b3
...
17c988c1ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 17c988c1ee | |||
| d297412d8a | |||
| 52dd6c3e32 |
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Migration: add `sort_order` column to `monitoring_locations` and seed
|
||||||
|
existing rows.
|
||||||
|
|
||||||
|
Lets operators reorder location cards via drag-and-drop on the project
|
||||||
|
detail page. Lower sort_order renders first; ties fall back to name.
|
||||||
|
|
||||||
|
Seed strategy: for each existing project, assign sort_order = 0, 1, 2, …
|
||||||
|
to its locations in their current alphabetical-by-name order. After
|
||||||
|
this migration, the visible card order on every existing project will
|
||||||
|
be unchanged.
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.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_column = False
|
||||||
|
if not _has_column(cur, "monitoring_locations", "sort_order"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN sort_order INTEGER DEFAULT 0")
|
||||||
|
added_column = True
|
||||||
|
print(" Added column: monitoring_locations.sort_order")
|
||||||
|
|
||||||
|
# Seed: for each project, set sort_order to its alphabetical index.
|
||||||
|
# Re-runs are harmless — operator-edited orderings can be re-seeded by
|
||||||
|
# passing FORCE_RESEED=1, but the default behavior leaves existing
|
||||||
|
# nonzero sort_order values alone so we don't clobber user choices.
|
||||||
|
force_reseed = os.environ.get("FORCE_RESEED") == "1"
|
||||||
|
if added_column or force_reseed:
|
||||||
|
cur.execute("SELECT DISTINCT project_id FROM monitoring_locations")
|
||||||
|
projects = [r[0] for r in cur.fetchall()]
|
||||||
|
seeded = 0
|
||||||
|
for project_id in projects:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM monitoring_locations WHERE project_id = ? ORDER BY name",
|
||||||
|
(project_id,),
|
||||||
|
)
|
||||||
|
for idx, (loc_id,) in enumerate(cur.fetchall()):
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE monitoring_locations SET sort_order = ? WHERE id = ?",
|
||||||
|
(idx, loc_id),
|
||||||
|
)
|
||||||
|
seeded += 1
|
||||||
|
print(f" Seeded sort_order for {seeded} location(s) across {len(projects)} project(s).")
|
||||||
|
else:
|
||||||
|
print(" monitoring_locations.sort_order already present — leaving existing values alone.")
|
||||||
|
print(" (Set FORCE_RESEED=1 to re-seed by alphabetical order.)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running migration: add sort_order to monitoring_locations")
|
||||||
|
migrate_database()
|
||||||
|
print("Done.")
|
||||||
@@ -243,6 +243,12 @@ class MonitoringLocation(Base):
|
|||||||
removed_at = Column(DateTime, nullable=True)
|
removed_at = Column(DateTime, nullable=True)
|
||||||
removal_reason = Column(Text, nullable=True)
|
removal_reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Display order within the project's location list. Operators can
|
||||||
|
# drag-and-drop to reorder cards on the project detail page. Lower
|
||||||
|
# values render first; ties fall back to name (alphabetical). Seeded
|
||||||
|
# to alphabetical-index on migration; new locations get max+1.
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,26 @@ async def get_project_locations(
|
|||||||
if location_type:
|
if location_type:
|
||||||
query = query.filter_by(location_type=location_type)
|
query = query.filter_by(location_type=location_type)
|
||||||
|
|
||||||
locations = query.order_by(MonitoringLocation.name).all()
|
# Order by operator-set sort_order, then name as a stable tie-breaker.
|
||||||
|
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||||
|
|
||||||
|
# For vibration locations, fan out event counts via SFM concurrently
|
||||||
|
# so the card layout can show "{N} events" instead of "Sessions: 0"
|
||||||
|
# (sessions don't really exist for the watcher-forward pipeline).
|
||||||
|
# Sound locations skip this and keep showing session counts.
|
||||||
|
event_counts: dict[str, int] = {}
|
||||||
|
vibration_locations = [l for l in locations if l.location_type == "vibration"]
|
||||||
|
if vibration_locations:
|
||||||
|
import asyncio
|
||||||
|
from backend.services.sfm_events import events_for_location
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(events_for_location(db, l.id, limit=1) for l in vibration_locations),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
for loc, res in zip(vibration_locations, results):
|
||||||
|
if isinstance(res, Exception):
|
||||||
|
continue # leave event_counts[loc.id] unset → template falls back
|
||||||
|
event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0
|
||||||
|
|
||||||
# Enrich with assignment info, splitting active vs removed.
|
# Enrich with assignment info, splitting active vs removed.
|
||||||
active_data: list = []
|
active_data: list = []
|
||||||
@@ -183,6 +202,8 @@ async def get_project_locations(
|
|||||||
"assigned_unit": assigned_unit,
|
"assigned_unit": assigned_unit,
|
||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
}
|
}
|
||||||
|
if location.id in event_counts:
|
||||||
|
item["event_count"] = event_counts[location.id]
|
||||||
if location.removed_at is None:
|
if location.removed_at is None:
|
||||||
active_data.append(item)
|
active_data.append(item)
|
||||||
else:
|
else:
|
||||||
@@ -224,7 +245,7 @@ async def get_project_locations_json(
|
|||||||
if not include_removed:
|
if not include_removed:
|
||||||
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
||||||
|
|
||||||
locations = query.order_by(MonitoringLocation.name).all()
|
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -256,6 +277,13 @@ async def create_location(
|
|||||||
|
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
|
|
||||||
|
# Compute next sort_order so new locations land at the END of the
|
||||||
|
# project's list rather than getting interleaved alphabetically.
|
||||||
|
from sqlalchemy import func
|
||||||
|
max_sort = db.query(func.max(MonitoringLocation.sort_order))\
|
||||||
|
.filter_by(project_id=project_id).scalar()
|
||||||
|
next_sort_order = (max_sort or 0) + 1 if max_sort is not None else 0
|
||||||
|
|
||||||
location = MonitoringLocation(
|
location = MonitoringLocation(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -265,6 +293,7 @@ async def create_location(
|
|||||||
coordinates=form_data.get("coordinates"),
|
coordinates=form_data.get("coordinates"),
|
||||||
address=form_data.get("address"),
|
address=form_data.get("address"),
|
||||||
location_metadata=form_data.get("location_metadata"), # JSON string
|
location_metadata=form_data.get("location_metadata"), # JSON string
|
||||||
|
sort_order=next_sort_order,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(location)
|
db.add(location)
|
||||||
@@ -356,6 +385,57 @@ async def delete_location(
|
|||||||
return {"success": True, "message": "Location deleted successfully"}
|
return {"success": True, "message": "Location deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/locations/reorder")
|
||||||
|
async def reorder_locations(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Persist a new sort order for a project's monitoring locations.
|
||||||
|
|
||||||
|
Body JSON: { "location_ids": [uuid, uuid, ...] }
|
||||||
|
The list MUST contain location ids in the desired display order.
|
||||||
|
Locations not included in the list keep their current sort_order
|
||||||
|
(useful for the "active locations only — leave removed alone"
|
||||||
|
drag-and-drop UX).
|
||||||
|
|
||||||
|
Updates `sort_order` to the index of each id in the list. Ties
|
||||||
|
between included and excluded locations fall back to the existing
|
||||||
|
sort_order.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
ids = payload.get("location_ids") or []
|
||||||
|
if not isinstance(ids, list) or len(ids) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="location_ids must be a non-empty list")
|
||||||
|
|
||||||
|
# Fetch only the locations being reordered and validate ownership.
|
||||||
|
locations = db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.project_id == project_id,
|
||||||
|
MonitoringLocation.id.in_(ids),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
found_ids = {l.id for l in locations}
|
||||||
|
missing = [i for i in ids if i not in found_ids]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Some locations not found in this project: {missing[:3]}…",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply 0-indexed sort_order matching the operator's chosen order.
|
||||||
|
by_id = {l.id: l for l in locations}
|
||||||
|
for idx, loc_id in enumerate(ids):
|
||||||
|
by_id[loc_id].sort_order = idx
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "reordered": len(ids)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/locations/{location_id}/remove")
|
@router.post("/locations/{location_id}/remove")
|
||||||
async def remove_location(
|
async def remove_location(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
<!-- Project Locations List — split into Active + Removed sections.
|
<!-- Project Locations List — Active + Removed sections.
|
||||||
Active locations get the full card with Assign/Edit/Delete/Remove
|
|
||||||
actions. Removed locations get a greyed-out card with a
|
Card layout:
|
||||||
Removed-on date, optional reason, and a Restore button. -->
|
[drag handle] [location info] [unit pill] [⋮ menu]
|
||||||
|
(name link, description, address, sessions/events, coords)
|
||||||
|
|
||||||
|
Active cards are draggable to reorder. Drop reorders the DOM
|
||||||
|
immediately and posts the new order to /api/projects/{p}/locations/reorder.
|
||||||
|
|
||||||
|
Removed cards are NOT reorderable (their order is historical) but
|
||||||
|
show a Restore button.
|
||||||
|
|
||||||
|
The three-dot menu replaces the inline Unassign/Edit/Remove/Delete
|
||||||
|
pill buttons. Click ⋮ to open; click outside closes.
|
||||||
|
-->
|
||||||
|
|
||||||
{% if not active_locations and not removed_locations %}
|
{% if not active_locations and not removed_locations %}
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
@@ -12,19 +23,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
{# ─── Active locations ─── #}
|
{# ─── Active locations (draggable) ─── #}
|
||||||
{% if active_locations %}
|
{% if active_locations %}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3" id="active-locations-list" data-project-id="{{ project.id }}">
|
||||||
{% for item in active_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 location-card"
|
||||||
|
draggable="true"
|
||||||
|
data-location-id="{{ item.location.id }}"
|
||||||
|
data-location-type="{{ item.location.location_type or 'sound' }}"
|
||||||
|
data-location-name="{{ item.location.name | e }}"
|
||||||
|
data-coordinates="{{ item.location.coordinates or '' }}"
|
||||||
|
ondragstart="onLocationDragStart(event)"
|
||||||
|
ondragover="onLocationDragOver(event)"
|
||||||
|
ondragleave="onLocationDragLeave(event)"
|
||||||
|
ondrop="onLocationDrop(event)"
|
||||||
|
ondragend="onLocationDragEnd(event)">
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<!-- Drag handle + info -->
|
||||||
|
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||||
|
<div class="shrink-0 pt-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-grab active:cursor-grabbing select-none"
|
||||||
|
title="Drag to reorder">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M7 4a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 9a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 14a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||||
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
||||||
{{ item.location.name }}
|
{{ item.location.name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
{% if item.location.description %}
|
{% if item.location.description %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -34,47 +62,71 @@
|
|||||||
{% if item.location.coordinates %}
|
{% if item.location.coordinates %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
|
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
|
||||||
|
<span><strong class="text-gray-700 dark:text-gray-300">{{ "{:,}".format(item.event_count) }}</strong> event{{ '' if item.event_count == 1 else 's' }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Sessions: {{ item.session_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.assignment and item.assigned_unit %}
|
||||||
|
<span>Assigned: <a href="/unit/{{ item.assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy font-mono">{{ item.assigned_unit.id }}</a></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="italic text-gray-400 dark:text-gray-500">No active assignment</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- Right column: small assign/unassign pill + 3-dot menu -->
|
||||||
{% if item.assignment %}
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<button onclick="unassignUnit('{{ item.assignment.id }}')"
|
{% if not item.assignment %}
|
||||||
class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
<!-- Primary action: visible because the unassigned card
|
||||||
Unassign
|
is most likely getting clicked on right after creation -->
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')"
|
<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">
|
class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
||||||
Assign
|
Assign
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Three-dot kebab menu -->
|
||||||
|
<div class="relative inline-block location-menu-wrapper">
|
||||||
|
<button onclick="toggleLocationMenu(event, this)"
|
||||||
|
class="p-1.5 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="More actions">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="location-menu hidden absolute right-0 mt-1 w-40 z-30 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||||
|
{% if item.assignment %}
|
||||||
|
<button onclick="unassignUnit('{{ item.assignment.id }}'); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Unassign
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
||||||
onclick="openEditLocationModal(this)"
|
onclick="openEditLocationModal(this); closeAllLocationMenus()"
|
||||||
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button data-loc-id="{{ item.location.id }}"
|
<button data-loc-id="{{ item.location.id }}"
|
||||||
data-loc-name="{{ item.location.name | e }}"
|
data-loc-name="{{ item.location.name | e }}"
|
||||||
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName)"
|
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName); closeAllLocationMenus()"
|
||||||
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"
|
class="w-full text-left px-3 py-1.5 text-sm text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20"
|
||||||
title="Mark as no longer actively monitored — preserves historical events">
|
title="Mark as no longer monitored — preserves events">
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
<button onclick="deleteLocation('{{ item.location.id }}')"
|
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||||
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="deleteLocation('{{ item.location.id }}'); closeAllLocationMenus()"
|
||||||
title="Permanently delete — only available if there's no history">
|
class="w-full text-left px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
title="Permanently delete — only allowed if no history">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
|
||||||
<span>Sessions: {{ item.session_count }}</span>
|
|
||||||
{% if item.assignment and item.assigned_unit %}
|
|
||||||
<span>Assigned: {{ item.assigned_unit.id }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span>No active assignment</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -135,7 +187,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
|
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
|
||||||
|
<span>{{ "{:,}".format(item.event_count) }} historical event{{ '' if item.event_count == 1 else 's' }}</span>
|
||||||
|
{% else %}
|
||||||
<span>Historical sessions: {{ item.session_count }}</span>
|
<span>Historical sessions: {{ item.session_count }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -144,3 +200,103 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Drag-and-drop + menu handlers, scoped to this partial (re-defined
|
||||||
|
on every htmx swap, which is harmless — function declarations
|
||||||
|
overwrite). -->
|
||||||
|
<script>
|
||||||
|
let _dragSrcCard = null;
|
||||||
|
|
||||||
|
function onLocationDragStart(e) {
|
||||||
|
_dragSrcCard = e.currentTarget;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
// Required for Firefox to start the drag.
|
||||||
|
e.dataTransfer.setData('text/plain', _dragSrcCard.dataset.locationId);
|
||||||
|
e.currentTarget.classList.add('opacity-40');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragOver(e) {
|
||||||
|
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
e.currentTarget.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragLeave(e) {
|
||||||
|
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
|
||||||
|
|
||||||
|
const list = document.getElementById('active-locations-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
// Drop AFTER the target by default; if mouse is in top half, drop BEFORE.
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const dropBefore = (e.clientY - rect.top) < rect.height / 2;
|
||||||
|
if (dropBefore) {
|
||||||
|
list.insertBefore(_dragSrcCard, e.currentTarget);
|
||||||
|
} else {
|
||||||
|
list.insertBefore(_dragSrcCard, e.currentTarget.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistLocationOrder(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragEnd(e) {
|
||||||
|
e.currentTarget.classList.remove('opacity-40');
|
||||||
|
document.querySelectorAll('.location-card').forEach(c => {
|
||||||
|
c.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
});
|
||||||
|
_dragSrcCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _persistLocationOrder(list) {
|
||||||
|
const projectId = list.dataset.projectId;
|
||||||
|
const ids = Array.from(list.querySelectorAll('.location-card'))
|
||||||
|
.map(c => c.dataset.locationId);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/locations/reorder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ location_ids: ids }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({ detail: 'HTTP ' + r.status }));
|
||||||
|
throw new Error(err.detail || 'reorder failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save new order:', err);
|
||||||
|
if (typeof showToast === 'function') showToast('Failed to save new order: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Three-dot menu ─────────────────────────────────────────────────
|
||||||
|
function toggleLocationMenu(e, btn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = btn.parentElement.querySelector('.location-menu');
|
||||||
|
const wasOpen = !menu.classList.contains('hidden');
|
||||||
|
closeAllLocationMenus();
|
||||||
|
if (!wasOpen) {
|
||||||
|
menu.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllLocationMenus() {
|
||||||
|
document.querySelectorAll('.location-menu').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menus on outside click (only register once globally).
|
||||||
|
if (!window._locationMenuOutsideClickRegistered) {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.location-menu-wrapper')) closeAllLocationMenus();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeAllLocationMenus();
|
||||||
|
});
|
||||||
|
window._locationMenuOutsideClickRegistered = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -78,22 +78,124 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<!-- Location Map — replaces the old Upcoming Actions panel for the
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3>
|
overview. Operators get a quick visual of where their locations
|
||||||
|
sit relative to each other. Pins clickable → scroll to + flash
|
||||||
|
the matching card. Locations without coordinates land in a
|
||||||
|
"missing coords" hint below the map.
|
||||||
|
For projects with scheduled monitoring activity, the full
|
||||||
|
Upcoming Actions list is still available on the Schedules tab. -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
|
||||||
{% if upcoming_actions %}
|
{% if upcoming_actions %}
|
||||||
<div class="space-y-3">
|
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||||||
{% for action in upcoming_actions %}
|
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
|
</a>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
|
|
||||||
{% if action.description %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div id="project-location-map" class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
|
style="height: 320px; background: rgba(0,0,0,0.05);"></div>
|
||||||
|
<div id="project-location-map-empty" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
|
||||||
|
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
|
||||||
|
</div>
|
||||||
|
<div id="project-location-map-missing" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Build location data from server-side render. Skip removed
|
||||||
|
// locations (their pins would clutter the active operations view)
|
||||||
|
// and skip ones without parseable coordinates.
|
||||||
|
const locationsRaw = [
|
||||||
|
{% for loc in locations %}
|
||||||
|
{% if not loc.removed_at %}
|
||||||
|
{
|
||||||
|
id: {{ loc.id | tojson }},
|
||||||
|
name: {{ loc.name | tojson }},
|
||||||
|
coords: {{ loc.coordinates | tojson if loc.coordinates else 'null' }},
|
||||||
|
}{% if not loop.last %},{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
];
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No scheduled actions.</p>
|
function parseCoords(s) {
|
||||||
{% endif %}
|
if (!s) return null;
|
||||||
</div>
|
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
|
||||||
</div>
|
if (parts.length !== 2 || parts.some(isNaN)) return null;
|
||||||
|
const [lat, lon] = parts;
|
||||||
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
|
||||||
|
const withCoords = [];
|
||||||
|
const withoutCoords = [];
|
||||||
|
for (const loc of locationsRaw) {
|
||||||
|
const xy = parseCoords(loc.coords);
|
||||||
|
if (xy) withCoords.push({ ...loc, latlon: xy });
|
||||||
|
else withoutCoords.push(loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMsg = document.getElementById('project-location-map-empty');
|
||||||
|
const missingMsg = document.getElementById('project-location-map-missing');
|
||||||
|
const mapEl = document.getElementById('project-location-map');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
if (withCoords.length === 0) {
|
||||||
|
// Hide the map block and show a hint. Don't init Leaflet at all.
|
||||||
|
mapEl.classList.add('hidden');
|
||||||
|
emptyMsg.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise Leaflet. `L` is loaded globally by base.html.
|
||||||
|
const map = L.map(mapEl, { scrollWheelZoom: false });
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap',
|
||||||
|
maxZoom: 18,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markers = [];
|
||||||
|
const bounds = [];
|
||||||
|
withCoords.forEach(loc => {
|
||||||
|
const marker = L.circleMarker(loc.latlon, {
|
||||||
|
radius: 8,
|
||||||
|
fillColor: '#f48b1c',
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
}).addTo(map);
|
||||||
|
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
|
||||||
|
marker.on('click', () => _flashLocationCard(loc.id));
|
||||||
|
markers.push(marker);
|
||||||
|
bounds.push(loc.latlon);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bounds.length === 1) {
|
||||||
|
map.setView(bounds[0], 14);
|
||||||
|
} else {
|
||||||
|
map.fitBounds(bounds, { padding: [20, 20] });
|
||||||
|
}
|
||||||
|
// Without this the map renders into a 0×0 area when the partial
|
||||||
|
// first lands via htmx (container size not yet stable).
|
||||||
|
setTimeout(() => map.invalidateSize(), 100);
|
||||||
|
|
||||||
|
if (withoutCoords.length > 0) {
|
||||||
|
const names = withoutCoords.map(l => l.name).join(', ');
|
||||||
|
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
|
||||||
|
missingMsg.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Briefly highlight the matching card to confirm the click.
|
||||||
|
function _flashLocationCard(locId) {
|
||||||
|
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
card.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user