feat(sfm): editable UnitAssignment date windows (backdate deployments)

Operators couldn't change a unit's assigned_at / assigned_until after
creating the assignment, so a unit physically deployed in December 2025
but only recorded in terra-view today would show "deployed today" and
all its real events would be invisible on the project's location page.

Backend:
- PATCH /api/projects/{project_id}/assignments/{assignment_id}
  Accepts JSON body with optional assigned_at, assigned_until, notes.
  - assigned_at is required (cannot be cleared)
  - assigned_until can be null to mark active / indefinite
  - assigned_until must be after assigned_at
  - rejects overlaps with other assignments of the same unit at the
    same location (different units overlapping is fine — that's a
    legitimate swap window)
  - assignment.status flips to "active" when assigned_until is cleared,
    "completed" when set
  - 404 if the assignment doesn't belong to {project_id} (security)

Frontend (vibration_location_detail.html):
- Pencil icon next to each row in the "Seismographs deployed at this
  location" card. Click to open a modal with datetime-local inputs for
  From + Until (blank = active) and a Notes textarea. Save reloads the
  Events tab so KPI tiles and the event table reflect the new window.
- Helper line under the assignment list explains the workflow:
  "Click the pencil to backdate a deployment so historical events get
  attributed to this location."

Verified end-to-end against real data: backdating BE11529's assignment
on a vibration location from 2026-04-14 to 2025-12-01 surfaced 10
additional events (24 -> 34) that were previously invisible.

Validation suite (all returning correct HTTP codes):
  - assigned_until < assigned_at -> 400
  - cross-project assignment_id -> 404
  - assigned_at cleared -> 400
  - notes-only update -> 200

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:30:32 +00:00
parent df771a87de
commit 09db988a35
2 changed files with 275 additions and 4 deletions
+147 -4
View File
@@ -220,6 +220,66 @@
<span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
</div>
<div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-3">
<span class="inline-block w-4 text-center"></span>
Click the pencil to backdate a deployment so historical events get attributed to this location.
</p>
</div>
<!-- Edit-assignment modal -->
<div id="assignment-edit-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 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">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Edit Deployment Window</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<span id="ae-unit-label" class="font-mono text-seismo-orange"></span>
</p>
</div>
<button onclick="closeAssignmentEditModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="assignment-edit-form" class="p-6 space-y-4">
<input type="hidden" id="ae-assignment-id">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned From</label>
<input type="datetime-local" id="ae-assigned-at" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Assigned Until
<span class="text-xs text-gray-500 ml-1">(leave blank if still active)</span>
</label>
<input type="datetime-local" id="ae-assigned-until"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="ae-notes" rows="2" placeholder="Optional — e.g. 'backdated to reflect physical install date'"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
</div>
<div id="ae-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeAssignmentEditModal()"
class="px-4 py-2 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 type="submit" id="ae-submit-btn"
class="px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Save
</button>
</div>
</form>
</div>
</div>
<!-- Filters -->
@@ -504,17 +564,100 @@ function renderAssignmentsUsed(assignments) {
const badge = isActive
? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
: '';
return `<div class="py-2 flex items-center justify-between">
<div>
const editAttr = encodeURIComponent(JSON.stringify({
id: a.assignment_id,
unit_id: a.unit_id,
assigned_at: a.assigned_at,
assigned_until: a.assigned_until,
}));
return `<div class="py-2 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a>
${badge}
<span class="ml-3 text-sm text-gray-600 dark:text-gray-400">${start}${end}</span>
<span class="text-sm text-gray-600 dark:text-gray-400">${start}${end}</span>
<button type="button"
onclick="openAssignmentEditModal('${editAttr}')"
title="Edit deployment dates"
class="text-gray-400 hover:text-seismo-orange transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
</div>
<span class="text-sm text-gray-700 dark:text-gray-300">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
</div>`;
}).join('');
}
// ── Assignment-edit modal ───────────────────────────────────────────────────
function _isoToInputValue(iso) {
// Convert "2026-04-14T02:19:27" (or "2026-04-14 02:19:27") to "2026-04-14T02:19" for datetime-local input.
if (!iso) return '';
const cleaned = iso.replace(' ', 'T');
return cleaned.slice(0, 16);
}
function openAssignmentEditModal(encodedJson) {
const data = JSON.parse(decodeURIComponent(encodedJson));
document.getElementById('ae-assignment-id').value = data.id;
document.getElementById('ae-unit-label').textContent = data.unit_id;
document.getElementById('ae-assigned-at').value = _isoToInputValue(data.assigned_at);
document.getElementById('ae-assigned-until').value = _isoToInputValue(data.assigned_until);
document.getElementById('ae-notes').value = '';
document.getElementById('ae-error').classList.add('hidden');
document.getElementById('assignment-edit-modal').classList.remove('hidden');
}
function closeAssignmentEditModal() {
document.getElementById('assignment-edit-modal').classList.add('hidden');
}
document.getElementById('assignment-edit-form').addEventListener('submit', async function(e) {
e.preventDefault();
const errEl = document.getElementById('ae-error');
errEl.classList.add('hidden');
const assignmentId = document.getElementById('ae-assignment-id').value;
const assignedAt = document.getElementById('ae-assigned-at').value;
const assignedUntil = document.getElementById('ae-assigned-until').value;
const notes = document.getElementById('ae-notes').value.trim();
if (!assignedAt) {
errEl.textContent = 'Assigned From is required.';
errEl.classList.remove('hidden');
return;
}
const payload = { assigned_at: assignedAt };
payload.assigned_until = assignedUntil || null;
if (notes) payload.notes = notes;
const btn = document.getElementById('ae-submit-btn');
btn.disabled = true; btn.textContent = 'Saving…';
try {
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
closeAssignmentEditModal();
await loadLocationEvents(); // Refresh stats + table with new window.
} catch (err) {
errEl.textContent = err.message || 'Failed to update assignment.';
errEl.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Save';
}
});
document.getElementById('assignment-edit-modal').addEventListener('click', function(e) {
if (e.target === this) closeAssignmentEditModal();
});
function renderEventTable(events, total, container) {
if (!events || events.length === 0) {
const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden');