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:
@@ -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,23 +761,55 @@ 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_(
|
|
||||||
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
|
|
||||||
assigned_until_str = form_data.get("assigned_until")
|
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(
|
assignment = UnitAssignment(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -772,8 +817,9 @@ async def assign_unit_to_location(
|
|||||||
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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+388
-4
@@ -281,11 +281,16 @@
|
|||||||
<!-- 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>
|
||||||
<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">
|
<div class="flex items-center gap-2">
|
||||||
↻ Refresh
|
<button onclick="openAddAssignmentModal()" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||||
</button>
|
+ 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>
|
</div>
|
||||||
|
|
||||||
<!-- Gantt chart — visual timeline of all deployments. Click
|
<!-- Gantt chart — visual timeline of all deployments. Click
|
||||||
@@ -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">×</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) -->
|
<!-- 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';
|
||||||
|
|||||||
Reference in New Issue
Block a user