feat(unit-detail): editable deployment timeline

Each assignment row in the timeline now gets an inline edit (pencil)
that opens a modal with `assigned_at`, `assigned_until`, and notes.
Save calls the existing `PATCH /api/projects/{pid}/assignments/{aid}`;
delete (for misclicks) calls the existing `DELETE`.  Open-ended
checkbox clears `assigned_until` and the endpoint flips status back
to "active".

Adds an "+ Add deployment record" button at the top of the timeline
for backfilling historical windows when orphan events sit outside any
assignment.  Modal: project → location → assigned_at → assigned_until
(optional open-ended) → notes.

Backend: the `/locations/{loc}/assign` endpoint now accepts an
`assigned_at` form field and a closed-window assignment.  The previous
blanket "location already has an active assignment" check is replaced
with same-location overlap detection — closed historical windows that
don't overlap an existing assignment are accepted (which is exactly
the backfill case).

After any save/delete the timeline reloads and the SFM-events list
re-fetches so previously-orphaned events flip to "attributed" when
their timestamp now falls inside an assignment window.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 06:32:11 +00:00
parent 6d37bd759e
commit 472c25372d
2 changed files with 451 additions and 21 deletions
+59 -13
View File
@@ -723,6 +723,19 @@ async def assign_unit_to_location(
): ):
""" """
Assign a unit to a monitoring location. Assign a unit to a monitoring location.
Accepts form fields:
- unit_id — required
- assigned_at — optional ISO datetime; defaults to now. Set this
when backfilling a historical deployment whose
events landed in the orphan bucket.
- assigned_until — optional ISO datetime; absent = open-ended /
active.
- notes — optional free text
Refuses only when the *new window would overlap* an existing active
open-ended assignment at the same location. Closed historical windows
that don't overlap are allowed (and required for orphan-event backfill).
""" """
location = db.query(MonitoringLocation).filter_by( location = db.query(MonitoringLocation).filter_by(
id=location_id, id=location_id,
@@ -748,32 +761,65 @@ async def assign_unit_to_location(
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'", detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
) )
# Check if location already has an active assignment (active = assigned_until IS NULL) # Parse dates.
existing_assignment = db.query(UnitAssignment).filter( assigned_at_str = form_data.get("assigned_at")
and_( assigned_until_str = form_data.get("assigned_until")
UnitAssignment.location_id == location_id, try:
UnitAssignment.assigned_until == None, assigned_at = (
datetime.fromisoformat(assigned_at_str)
if assigned_at_str else datetime.utcnow()
)
except (TypeError, ValueError):
raise HTTPException(
status_code=400, detail=f"Invalid assigned_at: {assigned_at_str!r}"
)
try:
assigned_until = (
datetime.fromisoformat(assigned_until_str)
if assigned_until_str else None
)
except (TypeError, ValueError):
raise HTTPException(
status_code=400, detail=f"Invalid assigned_until: {assigned_until_str!r}"
)
if assigned_until is not None and assigned_until <= assigned_at:
raise HTTPException(
status_code=400, detail="assigned_until must be after assigned_at.",
) )
).first()
if existing_assignment: # Reject only if the new window overlaps an existing assignment at the
# SAME location. Closed historical windows that sit before the current
# active assignment are fine — that's the backfill case.
new_end_for_overlap = assigned_until or datetime.utcnow()
existing = db.query(UnitAssignment).filter(
UnitAssignment.location_id == location_id
).all()
for other in existing:
other_start = other.assigned_at
other_end = other.assigned_until or datetime.utcnow()
if assigned_at < other_end and new_end_for_overlap > other_start:
other_window = (
f"{other.assigned_at:%Y-%m-%d}"
+ (f"{other.assigned_until:%Y-%m-%d}" if other.assigned_until else " → present")
)
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.", detail=(
f"New window overlaps an existing assignment at this "
f"location ({other.unit_id} {other_window}). Use swap or "
f"edit that record instead."
),
) )
# Create new assignment
assigned_until_str = form_data.get("assigned_until")
assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None
assignment = UnitAssignment( assignment = UnitAssignment(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
unit_id=unit_id, unit_id=unit_id,
location_id=location_id, location_id=location_id,
project_id=project_id, project_id=project_id,
device_type=unit.device_type, device_type=unit.device_type,
assigned_at=assigned_at,
assigned_until=assigned_until, assigned_until=assigned_until,
status="active", status="active" if assigned_until is None else "completed",
notes=form_data.get("notes"), notes=form_data.get("notes"),
) )
+385 -1
View File
@@ -281,12 +281,17 @@
<!-- Deployment Timeline (Phase 4 unified view — derived from <!-- Deployment Timeline (Phase 4 unified view — derived from
unit_assignments + unit_history + SFM event overlay) --> unit_assignments + unit_history + SFM event overlay) -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6"> <div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4 flex-wrap gap-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
<div class="flex items-center gap-2">
<button onclick="openAddAssignmentModal()" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
+ Add deployment record
</button>
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"> <button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
↻ Refresh ↻ Refresh
</button> </button>
</div> </div>
</div>
<!-- Gantt chart — visual timeline of all deployments. Click <!-- Gantt chart — visual timeline of all deployments. Click
a bar to jump to its row in the list below. --> a bar to jump to its row in the list below. -->
@@ -302,6 +307,119 @@
</div> </div>
</div> </div>
<!-- Edit-assignment modal -->
<div id="editAssignmentModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Edit deployment record</h3>
<button onclick="closeEditAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">&times;</button>
</div>
<div class="p-5 space-y-4">
<div class="text-sm text-gray-500 dark:text-gray-400">
<span id="editAssignmentLocation"></span>
<span class="text-xs">·</span>
<span id="editAssignmentProject" class="text-xs"></span>
</div>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
<input id="editAssignedAt" type="datetime-local" step="60"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
<div class="mt-1 flex items-center gap-2">
<input id="editAssignedUntil" type="datetime-local" step="60"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<input id="editAssignedUntilOpen" type="checkbox" onchange="_toggleEditOpenEnded()">
open-ended
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Check "open-ended" to mark this assignment active (no end date).</p>
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
<textarea id="editAssignmentNotes" rows="2"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
</label>
<div id="editAssignmentError" class="hidden text-sm text-red-600"></div>
</div>
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex items-center justify-between gap-2">
<button onclick="deleteAssignmentFromModal()" class="px-3 py-2 text-sm rounded-lg border border-red-300 text-red-700 dark:border-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20">
Delete
</button>
<div class="flex gap-2">
<button onclick="closeEditAssignmentModal()" class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button id="editAssignmentSaveBtn" onclick="saveEditAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
Save
</button>
</div>
</div>
</div>
</div>
<!-- Add-historical-assignment modal -->
<div id="addAssignmentModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add deployment record</h3>
<button onclick="closeAddAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">&times;</button>
</div>
<div class="p-5 space-y-4">
<p class="text-xs text-gray-500 dark:text-gray-400">
Create a deployment record for this unit — usually to backfill a historical window so orphan events get attributed.
</p>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</span>
<select id="addAssignmentProject" onchange="_addAssignmentProjectChanged()"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="">Loading…</option>
</select>
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Location</span>
<select id="addAssignmentLocation"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white" disabled>
<option value="">Pick a project first</option>
</select>
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
<input id="addAssignedAt" type="datetime-local" step="60"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
<div class="mt-1 flex items-center gap-2">
<input id="addAssignedUntil" type="datetime-local" step="60"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<input id="addAssignedUntilOpen" type="checkbox" onchange="_toggleAddOpenEnded()">
open-ended
</label>
</div>
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
<textarea id="addAssignmentNotes" rows="2"
placeholder="e.g. Backfilled to attribute orphan events 2026-03-16 — 2026-03-25"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
</label>
<div id="addAssignmentError" class="hidden text-sm text-red-600"></div>
</div>
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex justify-end gap-2">
<button onclick="closeAddAssignmentModal()" class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button id="addAssignmentSaveBtn" onclick="saveAddAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
Create
</button>
</div>
</div>
</div>
<!-- SFM Events (seismographs only) --> <!-- SFM Events (seismographs only) -->
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden"> <div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
@@ -2141,6 +2259,16 @@ function _dtRenderAssignment(e) {
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>` ? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
: ''; : '';
// Edit/delete actions live on the right of the date row. Only shown
// for assignment entries with a real assignment_id (synthesized legacy
// entries without one are read-only).
const actionButtons = e.assignment_id
? `<button type="button" onclick='openEditAssignmentModal(${JSON.stringify(e.assignment_id)})'
class="text-xs text-gray-500 hover:text-seismo-orange p-1 rounded" title="Edit dates / notes">
✏️
</button>`
: '';
return `<div class="flex gap-3 transition-shadow rounded-lg" data-assignment-row="${_dtEsc(e.assignment_id)}"> return `<div class="flex gap-3 transition-shadow rounded-lg" data-assignment-row="${_dtEsc(e.assignment_id)}">
<div class="flex flex-col items-center pt-1"> <div class="flex flex-col items-center pt-1">
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span> <span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
@@ -2153,6 +2281,7 @@ function _dtRenderAssignment(e) {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
${mergeableBadge} ${mergeableBadge}
${activeBadge} ${activeBadge}
${actionButtons}
</div> </div>
</div> </div>
<div class="mt-1">${locLink}</div> <div class="mt-1">${locLink}</div>
@@ -2455,6 +2584,261 @@ function renderDeploymentTimeline(entries, container, mergeGroups) {
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>'; container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
} }
// ── Deployment-timeline editor ──────────────────────────────────────────────
// Edit / delete an existing UnitAssignment row, or create a historical one
// (backfill orphan-event windows). All three operations hit endpoints that
// already exist on the project-locations router; after a save we just
// reload the timeline + events.
let _editAssignmentCtx = null; // { assignment_id, project_id, location_name, project_name }
function _iso_to_local_input(iso) {
// The PATCH/POST endpoints accept "YYYY-MM-DDTHH:MM[:SS]" strings
// (datetime.fromisoformat). datetime-local inputs emit the same shape
// without timezone. We just strip the trailing "Z" if present and
// truncate to minutes.
if (!iso) return '';
let s = String(iso).replace('Z', '');
// Slice down to YYYY-MM-DDTHH:MM (16 chars).
return s.slice(0, 16);
}
function openEditAssignmentModal(assignmentId) {
const entry = (_dtCurrentTimeline.entries || []).find(
e => e.kind === 'assignment' && e.assignment_id === assignmentId
);
if (!entry) {
alert('Could not find this assignment in the loaded timeline.');
return;
}
_editAssignmentCtx = {
assignment_id: entry.assignment_id,
project_id: entry.project_id,
location_name: entry.location_name || 'unnamed location',
project_name: entry.project_name || '',
};
document.getElementById('editAssignmentLocation').textContent = _editAssignmentCtx.location_name;
document.getElementById('editAssignmentProject').textContent = _editAssignmentCtx.project_name;
document.getElementById('editAssignedAt').value = _iso_to_local_input(entry.starts_at);
const endsAtInput = document.getElementById('editAssignedUntil');
const openCheckbox = document.getElementById('editAssignedUntilOpen');
if (entry.is_active) {
endsAtInput.value = '';
endsAtInput.disabled = true;
openCheckbox.checked = true;
} else {
endsAtInput.value = _iso_to_local_input(entry.ends_at);
endsAtInput.disabled = false;
openCheckbox.checked = false;
}
document.getElementById('editAssignmentNotes').value = entry.notes || '';
document.getElementById('editAssignmentError').classList.add('hidden');
document.getElementById('editAssignmentModal').classList.remove('hidden');
}
function closeEditAssignmentModal() {
document.getElementById('editAssignmentModal').classList.add('hidden');
_editAssignmentCtx = null;
}
function _toggleEditOpenEnded() {
const open = document.getElementById('editAssignedUntilOpen').checked;
const input = document.getElementById('editAssignedUntil');
input.disabled = open;
if (open) input.value = '';
}
async function saveEditAssignment() {
if (!_editAssignmentCtx) return;
const err = document.getElementById('editAssignmentError');
err.classList.add('hidden');
const assignedAt = document.getElementById('editAssignedAt').value;
if (!assignedAt) {
err.textContent = 'Assigned-at is required.';
err.classList.remove('hidden');
return;
}
const open = document.getElementById('editAssignedUntilOpen').checked;
const assignedUntil = open ? null : (document.getElementById('editAssignedUntil').value || null);
const notes = document.getElementById('editAssignmentNotes').value;
const btn = document.getElementById('editAssignmentSaveBtn');
btn.disabled = true; btn.textContent = 'Saving…';
try {
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
const r = await fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
assigned_at: assignedAt,
assigned_until: assignedUntil,
notes: notes,
}),
});
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
closeEditAssignmentModal();
await loadDeploymentTimeline();
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
loadUnitEvents();
}
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Save';
}
}
async function deleteAssignmentFromModal() {
if (!_editAssignmentCtx) return;
if (!confirm('Hard-delete this deployment record?\n\nThe assignment row and its history audit entry are removed. Use this only for misclicks — to end a real deployment, edit the "assigned until" date instead.')) return;
const err = document.getElementById('editAssignmentError');
err.classList.add('hidden');
try {
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
const r = await fetch(url, { method: 'DELETE' });
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
closeEditAssignmentModal();
await loadDeploymentTimeline();
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
loadUnitEvents();
}
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
}
}
// ── Add-historical-assignment modal ─────────────────────────────────────────
let _addAssignmentProjectsCache = null;
async function openAddAssignmentModal() {
if (!currentUnit) return;
document.getElementById('addAssignmentError').classList.add('hidden');
document.getElementById('addAssignedAt').value = '';
document.getElementById('addAssignedUntil').value = '';
document.getElementById('addAssignedUntilOpen').checked = false;
document.getElementById('addAssignedUntil').disabled = false;
document.getElementById('addAssignmentNotes').value = '';
const locSel = document.getElementById('addAssignmentLocation');
locSel.disabled = true;
locSel.innerHTML = '<option value="">Pick a project first</option>';
const projSel = document.getElementById('addAssignmentProject');
projSel.innerHTML = '<option value="">Loading…</option>';
document.getElementById('addAssignmentModal').classList.remove('hidden');
try {
if (!_addAssignmentProjectsCache) {
const r = await fetch('/api/projects/search-json?limit=50');
if (!r.ok) throw new Error('HTTP ' + r.status);
_addAssignmentProjectsCache = await r.json();
}
projSel.innerHTML = '<option value="">— pick project —</option>'
+ _addAssignmentProjectsCache.map(p =>
`<option value="${_dtEsc(p.id)}">${_dtEsc(p.name)}${p.project_number ? ' (' + _dtEsc(p.project_number) + ')' : ''}</option>`
).join('');
} catch (e) {
projSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
}
}
function closeAddAssignmentModal() {
document.getElementById('addAssignmentModal').classList.add('hidden');
}
function _toggleAddOpenEnded() {
const open = document.getElementById('addAssignedUntilOpen').checked;
const input = document.getElementById('addAssignedUntil');
input.disabled = open;
if (open) input.value = '';
}
async function _addAssignmentProjectChanged() {
const projectId = document.getElementById('addAssignmentProject').value;
const locSel = document.getElementById('addAssignmentLocation');
if (!projectId) {
locSel.disabled = true;
locSel.innerHTML = '<option value="">Pick a project first</option>';
return;
}
locSel.disabled = true;
locSel.innerHTML = '<option value="">Loading locations…</option>';
// Match the device type to the location_type filter.
const wantType = (currentUnit && currentUnit.device_type === 'slm') ? 'sound' : 'vibration';
try {
const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/locations-json?location_type=${wantType}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const locs = await r.json();
if (!locs.length) {
locSel.innerHTML = '<option value="">No matching locations in this project</option>';
return;
}
locSel.disabled = false;
locSel.innerHTML = '<option value="">— pick location —</option>'
+ locs.map(l => `<option value="${_dtEsc(l.id)}">${_dtEsc(l.name)}</option>`).join('');
} catch (e) {
locSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
}
}
async function saveAddAssignment() {
if (!currentUnit) return;
const err = document.getElementById('addAssignmentError');
err.classList.add('hidden');
const projectId = document.getElementById('addAssignmentProject').value;
const locationId = document.getElementById('addAssignmentLocation').value;
const assignedAt = document.getElementById('addAssignedAt').value;
const open = document.getElementById('addAssignedUntilOpen').checked;
const assignedUntil = open ? '' : document.getElementById('addAssignedUntil').value;
const notes = document.getElementById('addAssignmentNotes').value;
if (!projectId) { err.textContent = 'Pick a project.'; err.classList.remove('hidden'); return; }
if (!locationId) { err.textContent = 'Pick a location.'; err.classList.remove('hidden'); return; }
if (!assignedAt) { err.textContent = 'Assigned-at is required.'; err.classList.remove('hidden'); return; }
const btn = document.getElementById('addAssignmentSaveBtn');
btn.disabled = true; btn.textContent = 'Creating…';
try {
const fd = new FormData();
fd.append('unit_id', currentUnit.id);
fd.append('assigned_at', assignedAt);
if (assignedUntil) fd.append('assigned_until', assignedUntil);
if (notes) fd.append('notes', notes);
const url = `/api/projects/${encodeURIComponent(projectId)}/locations/${encodeURIComponent(locationId)}/assign`;
const r = await fetch(url, { method: 'POST', body: fd });
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
closeAddAssignmentModal();
await loadDeploymentTimeline();
if (typeof loadUnitEvents === 'function' && currentUnit.device_type === 'seismograph') {
loadUnitEvents();
}
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Create';
}
}
// ── SFM Events section ────────────────────────────────────────────────────── // ── SFM Events section ──────────────────────────────────────────────────────
function clearUnitEventFilters() { function clearUnitEventFilters() {
document.getElementById('ue-filter-bucket').value = 'all'; document.getElementById('ue-filter-bucket').value = 'all';