v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes #54
@@ -723,6 +723,19 @@ async def assign_unit_to_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(
|
||||
id=location_id,
|
||||
@@ -748,23 +761,55 @@ async def assign_unit_to_location(
|
||||
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)
|
||||
existing_assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.assigned_until == None,
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_assignment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
|
||||
)
|
||||
|
||||
# Create new assignment
|
||||
# Parse dates.
|
||||
assigned_at_str = form_data.get("assigned_at")
|
||||
assigned_until_str = form_data.get("assigned_until")
|
||||
assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None
|
||||
try:
|
||||
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.",
|
||||
)
|
||||
|
||||
# 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(
|
||||
status_code=400,
|
||||
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."
|
||||
),
|
||||
)
|
||||
|
||||
assignment = UnitAssignment(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -772,8 +817,9 @@ async def assign_unit_to_location(
|
||||
location_id=location_id,
|
||||
project_id=project_id,
|
||||
device_type=unit.device_type,
|
||||
assigned_at=assigned_at,
|
||||
assigned_until=assigned_until,
|
||||
status="active",
|
||||
status="active" if assigned_until is None else "completed",
|
||||
notes=form_data.get("notes"),
|
||||
)
|
||||
|
||||
|
||||
+388
-4
@@ -281,11 +281,16 @@
|
||||
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
||||
unit_assignments + unit_history + SFM event overlay) -->
|
||||
<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>
|
||||
<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
|
||||
</button>
|
||||
<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">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gantt chart — visual timeline of all deployments. Click
|
||||
@@ -302,6 +307,119 @@
|
||||
</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">×</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">×</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) -->
|
||||
<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">
|
||||
@@ -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>`
|
||||
: '';
|
||||
|
||||
// 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)}">
|
||||
<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>
|
||||
@@ -2153,6 +2281,7 @@ function _dtRenderAssignment(e) {
|
||||
<div class="flex items-center gap-2">
|
||||
${mergeableBadge}
|
||||
${activeBadge}
|
||||
${actionButtons}
|
||||
</div>
|
||||
</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>';
|
||||
}
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────
|
||||
function clearUnitEventFilters() {
|
||||
document.getElementById('ue-filter-bucket').value = 'all';
|
||||
|
||||
Reference in New Issue
Block a user