feat(locations): soft-remove monitoring locations without destroying history
When a client drops a location from scope mid-project (e.g. the office
half of a museum+office monitoring job), operators couldn't previously
mark it as no-longer-active without either deleting it (which would
orphan historical events) or leaving it in the active list looking
deployable. Now there's a proper middle ground.
Data model
- MonitoringLocation gets two new nullable columns:
- removed_at — NULL means active; set means soft-removed
- removal_reason — optional operator note
Migration: backend/migrate_add_location_removed.py (idempotent)
Endpoints
- POST /api/projects/{p}/locations/{l}/remove
Body: { effective_date?: ISO-datetime, reason?: str }
Side effects (cascade):
1. Closes active UnitAssignment rows at this location
(assigned_until = effective_date, status = "completed")
2. Cancels pending ScheduledActions at this location
3. Marks location.removed_at = effective_date
Returns counts of assignments closed + actions cancelled.
- POST /api/projects/{p}/locations/{l}/restore
Clears removed_at + removal_reason. Does NOT auto-reopen
assignments — operator creates new ones if resuming monitoring.
Active-surface filters
- locations-json defaults to active-only; pass include_removed=true
for historical / reporting views. Schedule modal dropdowns now
exclude removed locations automatically.
- Metadata-backfill fuzzy matcher excludes removed locations from
proposed targets (don't want backfill creating new assignments at
decommissioned locations).
- Vibration-summary per_location rollup includes removed locations
(so historical event totals stay accurate) but tags each with
removed_at so the UI can show a badge.
UI
- Project detail page's Monitoring Locations section now splits into:
Active locations (full card with Assign / Edit / Remove / Delete)
Removed locations (collapsed <details>, greyed cards, Restore button,
shows removal date + reason)
- New per-card "Remove" button → opens confirmation modal explaining
the cascade, with optional effective-date (defaults to now,
backdateable) and reason fields.
- Unit detail's SFM Events attribution cell shows a small "removed"
badge next to historical attributions whose location is no longer
active. Same pattern in vibration_summary's top-locations list.
- Soft-removal indicator surfaced through the events_for_unit
attribution payload as location_removed_at.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -778,6 +778,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Location Confirmation Modal —
|
||||
Soft-removal: preserves historical events, closes active assignments,
|
||||
cancels pending scheduled actions. Distinct from Delete (which is
|
||||
permanent and only allowed when there's no history). -->
|
||||
<div id="remove-location-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
|
||||
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2a4 4 0 014-4h6m0 0l-3-3m3 3l-3 3M5 7h8a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove location</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Mark <span id="remove-location-name" class="font-semibold text-gray-900 dark:text-white">…</span> as no longer actively monitored.
|
||||
</p>
|
||||
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-4 space-y-1 ml-4 list-disc">
|
||||
<li>Closes any active unit assignment at this location</li>
|
||||
<li>Cancels pending scheduled actions at this location</li>
|
||||
<li>Historical events stay attributed (visible in reports + event lists)</li>
|
||||
<li>Can be restored later if needed</li>
|
||||
</ul>
|
||||
|
||||
<input type="hidden" id="remove-location-id">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Effective date</label>
|
||||
<input type="datetime-local" id="remove-location-effective"
|
||||
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Defaults to now. Backdate if the location was physically removed earlier.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
|
||||
<input type="text" id="remove-location-reason" maxlength="200"
|
||||
placeholder="e.g. client dropped from scope"
|
||||
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
|
||||
<div id="remove-location-error" class="hidden text-sm text-red-600 mb-3"></div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick="closeRemoveLocationModal()"
|
||||
class="px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button onclick="confirmRemoveLocation()"
|
||||
class="px-4 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Project Confirmation Modal -->
|
||||
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||
@@ -1213,6 +1268,94 @@ async function deleteLocation(locationId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remove / Restore location ────────────────────────────────────────
|
||||
// Soft-removal: marks a location as no longer actively monitored without
|
||||
// destroying it. Historical events stay attributed; active assignments
|
||||
// are auto-closed and pending scheduled actions are auto-cancelled.
|
||||
|
||||
function openRemoveLocationModal(locationId, locationName) {
|
||||
document.getElementById('remove-location-id').value = locationId;
|
||||
document.getElementById('remove-location-name').textContent = locationName;
|
||||
document.getElementById('remove-location-reason').value = '';
|
||||
// Default effective_date to "now" in local datetime-input format.
|
||||
const now = new Date();
|
||||
const tzOffsetMin = now.getTimezoneOffset();
|
||||
const local = new Date(now.getTime() - tzOffsetMin * 60000);
|
||||
document.getElementById('remove-location-effective').value =
|
||||
local.toISOString().slice(0, 16);
|
||||
document.getElementById('remove-location-error').classList.add('hidden');
|
||||
document.getElementById('remove-location-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeRemoveLocationModal() {
|
||||
document.getElementById('remove-location-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function confirmRemoveLocation() {
|
||||
const locationId = document.getElementById('remove-location-id').value;
|
||||
const reason = document.getElementById('remove-location-reason').value.trim();
|
||||
const effective = document.getElementById('remove-location-effective').value;
|
||||
const errBox = document.getElementById('remove-location-error');
|
||||
errBox.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/locations/${locationId}/remove`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason: reason || null,
|
||||
effective_date: effective || null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to remove location');
|
||||
}
|
||||
const result = await response.json();
|
||||
closeRemoveLocationModal();
|
||||
refreshLocationLists();
|
||||
refreshProjectDashboard();
|
||||
// Lightweight feedback — the UI refresh already shows the location
|
||||
// moving to the Removed section, but a toast confirms the cascade.
|
||||
if (typeof showToast === 'function') {
|
||||
const bits = [];
|
||||
if (result.assignments_closed) bits.push(`${result.assignments_closed} assignment(s) closed`);
|
||||
if (result.actions_cancelled) bits.push(`${result.actions_cancelled} action(s) cancelled`);
|
||||
const tail = bits.length ? ` (${bits.join(', ')})` : '';
|
||||
showToast(`Location removed${tail}`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
errBox.textContent = err.message || 'Failed to remove location.';
|
||||
errBox.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreLocation(locationId, locationName) {
|
||||
if (!confirm(`Restore "${locationName}" to active monitoring?\n\nNote: previously-closed assignments are NOT automatically re-opened — you'll need to re-assign units if you want to resume monitoring.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/locations/${locationId}/restore`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to restore location');
|
||||
}
|
||||
refreshLocationLists();
|
||||
refreshProjectDashboard();
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`"${locationName}" restored to active`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to restore location.');
|
||||
}
|
||||
}
|
||||
|
||||
// Assign modal functions
|
||||
function openAssignModal(locationId, locationType) {
|
||||
const safeType = locationType || 'sound';
|
||||
|
||||
Reference in New Issue
Block a user