52dd6c3e32
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>
303 lines
17 KiB
HTML
303 lines
17 KiB
HTML
<!-- 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">
|
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
</svg>
|
|
<p>No locations added yet</p>
|
|
</div>
|
|
{% else %}
|
|
|
|
{# ─── Active locations (draggable) ─── #}
|
|
{% if active_locations %}
|
|
<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 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">
|
|
<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>
|
|
</div>
|
|
|
|
<!-- 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); 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 %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# ─── Removed locations (collapsed by default) ─── #}
|
|
{% if removed_locations %}
|
|
<details class="mt-6 group" {% if not active_locations %}open{% endif %}>
|
|
<summary class="cursor-pointer text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 select-none list-none">
|
|
<span class="inline-flex items-center gap-2">
|
|
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
</svg>
|
|
Removed locations
|
|
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">{{ removed_locations | length }}</span>
|
|
</span>
|
|
<p class="ml-6 mt-1 text-xs text-gray-400 dark:text-gray-500">Historical only — events stay attributed, but no new assignments or schedules can be created here.</p>
|
|
</summary>
|
|
|
|
<div class="space-y-3 mt-3">
|
|
{% for item in removed_locations %}
|
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-slate-900/30 opacity-75">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
|
class="font-semibold text-gray-700 dark:text-gray-300 hover:text-seismo-orange truncate">
|
|
{{ item.location.name }}
|
|
</a>
|
|
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold">
|
|
Removed
|
|
</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ item.location.removed_at.strftime('%Y-%m-%d') if item.location.removed_at else '—' }}
|
|
</span>
|
|
</div>
|
|
{% if item.location.removal_reason %}
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">"{{ item.location.removal_reason }}"</p>
|
|
{% endif %}
|
|
{% if item.location.description %}
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
|
{% endif %}
|
|
{% if item.location.address %}
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<button data-loc-id="{{ item.location.id }}"
|
|
data-loc-name="{{ item.location.name | e }}"
|
|
onclick="restoreLocation(this.dataset.locId, this.dataset.locName)"
|
|
class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200"
|
|
title="Restore to active monitoring">
|
|
Restore
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
|
{% 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 %}
|
|
</div>
|
|
</details>
|
|
{% 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>
|