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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user