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)
|
||||
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)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -151,7 +151,26 @@ async def get_project_locations(
|
||||
if 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.
|
||||
active_data: list = []
|
||||
@@ -183,6 +202,8 @@ async def get_project_locations(
|
||||
"assigned_unit": assigned_unit,
|
||||
"session_count": session_count,
|
||||
}
|
||||
if location.id in event_counts:
|
||||
item["event_count"] = event_counts[location.id]
|
||||
if location.removed_at is None:
|
||||
active_data.append(item)
|
||||
else:
|
||||
@@ -224,7 +245,7 @@ async def get_project_locations_json(
|
||||
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.sort_order, MonitoringLocation.name).all()
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -256,6 +277,13 @@ async def create_location(
|
||||
|
||||
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(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
@@ -265,6 +293,7 @@ async def create_location(
|
||||
coordinates=form_data.get("coordinates"),
|
||||
address=form_data.get("address"),
|
||||
location_metadata=form_data.get("location_metadata"), # JSON string
|
||||
sort_order=next_sort_order,
|
||||
)
|
||||
|
||||
db.add(location)
|
||||
@@ -356,6 +385,57 @@ async def delete_location(
|
||||
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")
|
||||
async def remove_location(
|
||||
project_id: str,
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
<!-- 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. -->
|
||||
<!-- Project Locations List — Active + Removed sections.
|
||||
|
||||
Card layout:
|
||||
[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 %}
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
@@ -12,19 +23,36 @@
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# ─── Active locations ─── #}
|
||||
{# ─── Active locations (draggable) ─── #}
|
||||
{% 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 %}
|
||||
<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">
|
||||
<!-- 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="flex items-center gap-2">
|
||||
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
||||
{{ item.location.name }}
|
||||
</a>
|
||||
</div>
|
||||
{% if item.location.description %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||
{% endif %}
|
||||
@@ -34,47 +62,71 @@
|
||||
{% if item.location.coordinates %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
||||
{% 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 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">
|
||||
Unassign
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Right column: small assign/unassign pill + 3-dot menu -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{% if not item.assignment %}
|
||||
<!-- Primary action: visible because the unassigned card
|
||||
is most likely getting clicked on right after creation -->
|
||||
<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 %}
|
||||
|
||||
<!-- 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 }}'
|
||||
onclick="openEditLocationModal(this)"
|
||||
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
onclick="openEditLocationModal(this); 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">
|
||||
Edit
|
||||
</button>
|
||||
<button data-loc-id="{{ item.location.id }}"
|
||||
data-loc-name="{{ item.location.name | e }}"
|
||||
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName)"
|
||||
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">
|
||||
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName); closeAllLocationMenus()"
|
||||
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 monitored — preserves 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">
|
||||
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||
<button onclick="deleteLocation('{{ item.location.id }}'); closeAllLocationMenus()"
|
||||
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
|
||||
</button>
|
||||
</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>
|
||||
{% endfor %}
|
||||
@@ -135,7 +187,11 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -144,3 +200,103 @@
|
||||
{% 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 class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3>
|
||||
<!-- Location Map — replaces the old Upcoming Actions panel for the
|
||||
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 %}
|
||||
<div class="space-y-3">
|
||||
{% for action in upcoming_actions %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
|
||||
<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>
|
||||
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||||
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
|
||||
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||||
</a>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No scheduled actions.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
];
|
||||
|
||||
function parseCoords(s) {
|
||||
if (!s) return null;
|
||||
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
|
||||
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