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:
2026-05-15 05:23:25 +00:00
parent 295f9637b3
commit 52dd6c3e32
4 changed files with 352 additions and 54 deletions
+208 -52
View File
@@ -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,69 +23,110 @@
</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">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<!-- 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">
<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>
{% 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 %}
{% 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>
{% 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 %}
{% if item.location.coordinates %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
{% endif %}
</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 %}
<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">
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">
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>
</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 %}
<!-- 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); 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); 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>
<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>
</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">
<span>Historical sessions: {{ item.session_count }}</span>
{% 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>