`
: '';
+ // 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
+ ? `
@@ -2153,6 +2281,7 @@ function _dtRenderAssignment(e) {
${mergeableBadge}
${activeBadge}
+ ${actionButtons}
${locLink}
@@ -2455,6 +2584,261 @@ function renderDeploymentTimeline(entries, container, mergeGroups) {
container.innerHTML = bannerHtml + '
' + html + '
';
}
+// ── 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 = '
Pick a project first ';
+
+ const projSel = document.getElementById('addAssignmentProject');
+ projSel.innerHTML = '
Loading… ';
+
+ 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 = '
— pick project — '
+ + _addAssignmentProjectsCache.map(p =>
+ `
${_dtEsc(p.name)}${p.project_number ? ' (' + _dtEsc(p.project_number) + ')' : ''} `
+ ).join('');
+ } catch (e) {
+ projSel.innerHTML = `
Load failed: ${_dtEsc(e.message)} `;
+ }
+}
+
+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 = '
Pick a project first ';
+ return;
+ }
+ locSel.disabled = true;
+ locSel.innerHTML = '
Loading locations… ';
+ // 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 = '
No matching locations in this project ';
+ return;
+ }
+ locSel.disabled = false;
+ locSel.innerHTML = '
— pick location — '
+ + locs.map(l => `
${_dtEsc(l.name)} `).join('');
+ } catch (e) {
+ locSel.innerHTML = `
Load failed: ${_dtEsc(e.message)} `;
+ }
+}
+
+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 ──────────────────────────────────────────────────────
function clearUnitEventFilters() {
document.getElementById('ue-filter-bucket').value = 'all';