update main to v0.10.0 #48
@@ -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,
|
||||
|
||||
@@ -220,6 +220,66 @@
|
||||
<span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
<div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
<span class="inline-block w-4 text-center">✎</span>
|
||||
Click the pencil to backdate a deployment so historical events get attributed to this location.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit-assignment modal -->
|
||||
<div id="assignment-edit-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Edit Deployment Window</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span id="ae-unit-label" class="font-mono text-seismo-orange">—</span>
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="closeAssignmentEditModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="assignment-edit-form" class="p-6 space-y-4">
|
||||
<input type="hidden" id="ae-assignment-id">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned From</label>
|
||||
<input type="datetime-local" id="ae-assigned-at" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Assigned Until
|
||||
<span class="text-xs text-gray-500 ml-1">(leave blank if still active)</span>
|
||||
</label>
|
||||
<input type="datetime-local" id="ae-assigned-until"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="ae-notes" rows="2" placeholder="Optional — e.g. 'backdated to reflect physical install date'"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="ae-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick="closeAssignmentEditModal()"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="ae-submit-btn"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -504,17 +564,100 @@ function renderAssignmentsUsed(assignments) {
|
||||
const badge = isActive
|
||||
? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||
: '';
|
||||
return `<div class="py-2 flex items-center justify-between">
|
||||
<div>
|
||||
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 `<div class="py-2 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a>
|
||||
${badge}
|
||||
<span class="ml-3 text-sm text-gray-600 dark:text-gray-400">${start} → ${end}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${start} → ${end}</span>
|
||||
<button type="button"
|
||||
onclick="openAssignmentEditModal('${editAttr}')"
|
||||
title="Edit deployment dates"
|
||||
class="text-gray-400 hover:text-seismo-orange transition-colors p-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
||||
</div>`;
|
||||
}).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');
|
||||
|
||||
Reference in New Issue
Block a user