diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index f0aa1ed..7fade31 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -453,6 +453,134 @@ async def unassign_unit( return {"success": True, "message": "Unit unassigned successfully"} +@router.patch("/assignments/{assignment_id}") +async def update_assignment( + project_id: str, + assignment_id: str, + request: Request, + db: Session = Depends(get_db), +): + """ + Update an assignment's date window and/or notes. + + Common use case: backdate a deployment so events emitted before the + operator created the assignment in terra-view (e.g. a unit that was + physically deployed in December but only recorded in the system today) + get correctly attributed to the location. + + Accepts JSON body with optional fields: + - assigned_at: ISO datetime (or empty string to leave unchanged) + - assigned_until: ISO datetime, or null/"" to mark indefinite (active) + - notes: string + + Sets `status` to "active" when assigned_until is cleared, "completed" + when it's set in the past. + """ + assignment = db.query(UnitAssignment).filter_by( + id=assignment_id, + project_id=project_id, + ).first() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + # Parse new values (None = unchanged, explicit None/"" for assigned_until = clear) + new_assigned_at = assignment.assigned_at + new_assigned_until = assignment.assigned_until + new_notes = assignment.notes + + if "assigned_at" in payload: + raw = payload["assigned_at"] + if raw is None or raw == "": + raise HTTPException( + status_code=400, + detail="assigned_at is required; cannot be cleared.", + ) + try: + # Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO. + new_assigned_at = datetime.fromisoformat(raw) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, + detail=f"Invalid assigned_at datetime: {raw!r}", + ) + + if "assigned_until" in payload: + raw = payload["assigned_until"] + if raw is None or raw == "": + new_assigned_until = None + else: + try: + new_assigned_until = datetime.fromisoformat(raw) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, + detail=f"Invalid assigned_until datetime: {raw!r}", + ) + + if "notes" in payload: + raw = payload["notes"] + new_notes = (raw or "").strip() or None + + # Validation: end must be after start if both set. + if new_assigned_until is not None and new_assigned_until <= new_assigned_at: + raise HTTPException( + status_code=400, + detail="assigned_until must be after assigned_at.", + ) + + # Sanity: reject creating an overlap with another assignment of the SAME + # unit at the SAME location. Different units at the same location can + # legitimately overlap during a swap window (rare but valid). + new_end_for_overlap = new_assigned_until or datetime.utcnow() + overlapping = ( + db.query(UnitAssignment) + .filter(UnitAssignment.location_id == assignment.location_id) + .filter(UnitAssignment.unit_id == assignment.unit_id) + .filter(UnitAssignment.id != assignment.id) + .all() + ) + for other in overlapping: + other_start = other.assigned_at + other_end = other.assigned_until or datetime.utcnow() + if new_assigned_at < other_end and new_end_for_overlap > other_start: + raise HTTPException( + status_code=400, + detail=( + f"This window overlaps with another assignment for the " + f"same unit ({other.assigned_at:%Y-%m-%d} → " + f"{other.assigned_until and other.assigned_until.strftime('%Y-%m-%d') or 'present'})." + ), + ) + + # Apply. + assignment.assigned_at = new_assigned_at + assignment.assigned_until = new_assigned_until + assignment.notes = new_notes + assignment.status = "completed" if new_assigned_until is not None else "active" + + db.commit() + db.refresh(assignment) + + return { + "success": True, + "assignment": { + "id": assignment.id, + "unit_id": assignment.unit_id, + "location_id": assignment.location_id, + "assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None, + "assigned_until": assignment.assigned_until.isoformat() if assignment.assigned_until else None, + "status": assignment.status, + "notes": assignment.notes, + }, + } + + @router.post("/locations/{location_id}/swap") async def swap_unit_on_location( project_id: str, diff --git a/templates/vibration_location_detail.html b/templates/vibration_location_detail.html index e0ff8f6..23d91aa 100644 --- a/templates/vibration_location_detail.html +++ b/templates/vibration_location_detail.html @@ -220,6 +220,66 @@
+

+ + Click the pencil to backdate a deployment so historical events get attributed to this location. +

+ + + + @@ -504,17 +564,100 @@ function renderAssignmentsUsed(assignments) { const badge = isActive ? 'active' : ''; - return `
-
+ const editAttr = encodeURIComponent(JSON.stringify({ + id: a.assignment_id, + unit_id: a.unit_id, + assigned_at: a.assigned_at, + assigned_until: a.assigned_until, + })); + return `
+
${esc(a.unit_id)} ${badge} - ${start} → ${end} + ${start} → ${end} +
- ${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'} + ${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}
`; }).join(''); } +// ── Assignment-edit modal ─────────────────────────────────────────────────── +function _isoToInputValue(iso) { + // Convert "2026-04-14T02:19:27" (or "2026-04-14 02:19:27") to "2026-04-14T02:19" for datetime-local input. + if (!iso) return ''; + const cleaned = iso.replace(' ', 'T'); + return cleaned.slice(0, 16); +} + +function openAssignmentEditModal(encodedJson) { + const data = JSON.parse(decodeURIComponent(encodedJson)); + document.getElementById('ae-assignment-id').value = data.id; + document.getElementById('ae-unit-label').textContent = data.unit_id; + document.getElementById('ae-assigned-at').value = _isoToInputValue(data.assigned_at); + document.getElementById('ae-assigned-until').value = _isoToInputValue(data.assigned_until); + document.getElementById('ae-notes').value = ''; + document.getElementById('ae-error').classList.add('hidden'); + document.getElementById('assignment-edit-modal').classList.remove('hidden'); +} + +function closeAssignmentEditModal() { + document.getElementById('assignment-edit-modal').classList.add('hidden'); +} + +document.getElementById('assignment-edit-form').addEventListener('submit', async function(e) { + e.preventDefault(); + const errEl = document.getElementById('ae-error'); + errEl.classList.add('hidden'); + + const assignmentId = document.getElementById('ae-assignment-id').value; + const assignedAt = document.getElementById('ae-assigned-at').value; + const assignedUntil = document.getElementById('ae-assigned-until').value; + const notes = document.getElementById('ae-notes').value.trim(); + + if (!assignedAt) { + errEl.textContent = 'Assigned From is required.'; + errEl.classList.remove('hidden'); + return; + } + + const payload = { assigned_at: assignedAt }; + payload.assigned_until = assignedUntil || null; + if (notes) payload.notes = notes; + + const btn = document.getElementById('ae-submit-btn'); + btn.disabled = true; btn.textContent = 'Saving…'; + try { + const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload), + }); + if (!r.ok) { + const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status})); + throw new Error(err.detail || 'HTTP ' + r.status); + } + closeAssignmentEditModal(); + await loadLocationEvents(); // Refresh stats + table with new window. + } catch (err) { + errEl.textContent = err.message || 'Failed to update assignment.'; + errEl.classList.remove('hidden'); + } finally { + btn.disabled = false; btn.textContent = 'Save'; + } +}); + +document.getElementById('assignment-edit-modal').addEventListener('click', function(e) { + if (e.target === this) closeAssignmentEditModal(); +}); + function renderEventTable(events, total, container) { if (!events || events.length === 0) { const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden');