feat(locations): drag-to-reorder + three-dot kebab menu on cards
Project location cards now reorderable via drag-and-drop, and the
four inline action buttons (Unassign/Edit/Remove/Delete) collapse into
a single three-dot kebab menu — much cleaner card layout, especially
for projects with many locations.
Data
- MonitoringLocation.sort_order: nullable Integer, default 0.
Migration `migrate_add_location_sort_order.py` adds the column and
seeds existing rows with sort_order = alphabetical index per project
(so the post-migration display order matches what operators see
today — no surprise reordering).
- get_project_locations + locations-json: ORDER BY sort_order, name.
- Location-create: assigns max(sort_order) + 1 so new locations land
at the END of the list rather than being interleaved alphabetically.
Reorder endpoint
- POST /api/projects/{p}/locations/reorder
Body: { location_ids: [uuid, uuid, ...] }
Validates: all ids belong to this project; raises 404 on missing.
Applies 0-indexed sort_order matching the provided order.
UI changes (templates/partials/projects/location_list.html)
- Active cards get a draggable="true" attribute + native HTML5
drag/drop handlers. Drop reorders the DOM immediately, then posts
the new order to the reorder endpoint. Drop-zone visual feedback
(orange ring on hover, opacity on source during drag).
- Six-dot drag handle icon on the left of each active card; whole
card body is the drag source but the handle is the visual cue.
- Right side: small Assign pill (only shown when unassigned) +
three-dot kebab menu containing Unassign/Edit/Remove/Delete.
Click ⋮ to toggle; click outside or Escape to close. Only one
menu open at a time.
- Removed locations are NOT draggable (their order is historical) and
keep their existing Restore button visible.
The card also shows "{N} events" instead of "Sessions: N" when the
location_type is vibration AND the backend passes event_count in
the payload — which lands in commit 2 of this redesign.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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,8 @@ 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()
|
||||
|
||||
# Enrich with assignment info, splitting active vs removed.
|
||||
active_data: list = []
|
||||
@@ -224,7 +225,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 +257,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 +273,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 +365,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>
|
||||
|
||||
Reference in New Issue
Block a user