2 Commits

6 changed files with 369 additions and 61 deletions

View File

@@ -355,8 +355,11 @@ async def nrl_detail_page(
).first()
assigned_unit = None
assigned_modem = None
if assignment:
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if assigned_unit and assigned_unit.deployed_with_modem_id:
assigned_modem = db.query(RosterUnit).filter_by(id=assigned_unit.deployed_with_modem_id).first()
# Get session count
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
@@ -393,6 +396,7 @@ async def nrl_detail_page(
"location": location,
"assignment": assignment,
"assigned_unit": assigned_unit,
"assigned_modem": assigned_modem,
"session_count": session_count,
"file_count": file_count,
"active_session": active_session,

View File

@@ -353,18 +353,18 @@ 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
# 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.status == "active",
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}). Unassign first.",
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
)
# Create new assignment
@@ -433,10 +433,120 @@ async def unassign_unit(
return {"success": True, "message": "Unit unassigned successfully"}
@router.post("/locations/{location_id}/swap")
async def swap_unit_on_location(
project_id: str,
location_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Swap the unit assigned to a vibration monitoring location.
Ends the current active assignment (if any), creates a new one,
and optionally updates modem pairing on the seismograph.
Works for first-time assignments too (no current assignment = just create).
"""
location = db.query(MonitoringLocation).filter_by(
id=location_id,
project_id=project_id,
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
form_data = await request.form()
unit_id = form_data.get("unit_id")
modem_id = form_data.get("modem_id") or None
notes = form_data.get("notes") or None
if not unit_id:
raise HTTPException(status_code=400, detail="unit_id is required")
# Validate new unit
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
if unit.device_type != expected_device_type:
raise HTTPException(
status_code=400,
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
)
# End current active assignment if one exists (active = assigned_until IS NULL)
current = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.assigned_until == None,
)
).first()
if current:
current.assigned_until = datetime.utcnow()
current.status = "completed"
# Create new assignment
new_assignment = UnitAssignment(
id=str(uuid.uuid4()),
unit_id=unit_id,
location_id=location_id,
project_id=project_id,
device_type=unit.device_type,
assigned_until=None,
status="active",
notes=notes,
)
db.add(new_assignment)
# Update modem pairing on the seismograph if modem provided
if modem_id:
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
unit.deployed_with_modem_id = modem_id
modem.deployed_with_unit_id = unit_id
else:
# Clear modem pairing if not provided
unit.deployed_with_modem_id = None
db.commit()
return JSONResponse({
"success": True,
"assignment_id": new_assignment.id,
"message": f"Unit '{unit_id}' assigned to '{location.name}'" + (f" with modem '{modem_id}'" if modem_id else ""),
})
# ============================================================================
# Available Units for Assignment
# ============================================================================
@router.get("/available-modems", response_class=JSONResponse)
async def get_available_modems(
project_id: str,
db: Session = Depends(get_db),
):
"""
Get all deployed, non-retired modems for the modem assignment dropdown.
"""
modems = db.query(RosterUnit).filter(
and_(
RosterUnit.device_type == "modem",
RosterUnit.deployed == True,
RosterUnit.retired == False,
)
).order_by(RosterUnit.id).all()
return [
{
"id": m.id,
"hardware_model": m.hardware_model,
"ip_address": m.ip_address,
}
for m in modems
]
@router.get("/available-units", response_class=JSONResponse)
async def get_available_units(
project_id: str,

View File

@@ -20,7 +20,7 @@ import json
import logging
import io
from backend.utils.timezone import utc_to_local, format_local_datetime
from backend.utils.timezone import utc_to_local, format_local_datetime, local_to_utc
from backend.database import get_db
from fastapi import UploadFile, File
@@ -1800,6 +1800,23 @@ async def delete_session(
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"}
def _derive_period_type(dt: datetime) -> str:
is_weekend = dt.weekday() >= 5
is_night = dt.hour >= 22 or dt.hour < 7
if is_weekend:
return "weekend_night" if is_night else "weekend_day"
return "weekday_night" if is_night else "weekday_day"
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
day_abbr = dt.strftime("%a")
date_str = f"{dt.month}/{dt.day}"
period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "")
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
return "".join(parts)
@router.patch("/{project_id}/sessions/{session_id}")
async def patch_session(
project_id: str,
@@ -1807,13 +1824,53 @@ async def patch_session(
data: dict,
db: Session = Depends(get_db),
):
"""Update session_label and/or period_type on a monitoring session."""
"""Update session fields: started_at, stopped_at, session_label, period_type."""
session = db.query(MonitoringSession).filter_by(id=session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id:
raise HTTPException(status_code=403, detail="Session does not belong to this project")
times_changed = False
if "started_at" in data and data["started_at"]:
try:
local_dt = datetime.fromisoformat(data["started_at"])
session.started_at = local_to_utc(local_dt)
times_changed = True
except ValueError:
raise HTTPException(status_code=400, detail="Invalid started_at datetime format")
if "stopped_at" in data:
if data["stopped_at"]:
try:
local_dt = datetime.fromisoformat(data["stopped_at"])
session.stopped_at = local_to_utc(local_dt)
times_changed = True
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stopped_at datetime format")
else:
session.stopped_at = None
times_changed = True
if times_changed and session.started_at and session.stopped_at:
delta = session.stopped_at - session.started_at
session.duration_seconds = max(0, int(delta.total_seconds()))
elif times_changed and not session.stopped_at:
session.duration_seconds = None
# Re-derive period_type and session_label from new started_at unless explicitly provided
if times_changed and session.started_at and "period_type" not in data:
local_start = utc_to_local(session.started_at)
session.period_type = _derive_period_type(local_start)
if times_changed and session.started_at and "session_label" not in data:
from backend.models import MonitoringLocation
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
location_name = location.name if location else ""
local_start = utc_to_local(session.started_at)
session.session_label = _build_session_label(local_start, location_name, session.period_type or "")
if "session_label" in data:
session.session_label = str(data["session_label"]).strip() or None
if "period_type" in data:

View File

@@ -56,6 +56,15 @@
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ session.status or 'unknown' }}
</span>
<!-- Edit Session Times -->
<button onclick="event.stopPropagation(); openEditSessionModal('{{ session.id }}', '{{ session.started_at|local_datetime if session.started_at else '' }}', '{{ session.stopped_at|local_datetime if session.stopped_at else '' }}')"
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Edit session times">
<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>
Edit
</button>
<!-- Download All Files in Session -->
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
@@ -326,4 +335,74 @@ async function deleteSession(sessionId) {
alert(`Error deleting session: ${error.message}`);
}
}
function openEditSessionModal(sessionId, startedAt, stoppedAt) {
document.getElementById('editSessionId').value = sessionId;
// local_datetime filter returns "YYYY-MM-DD HH:MM" — convert to "YYYY-MM-DDTHH:MM" for datetime-local input
document.getElementById('editStartedAt').value = startedAt ? startedAt.replace(' ', 'T') : '';
document.getElementById('editStoppedAt').value = stoppedAt ? stoppedAt.replace(' ', 'T') : '';
document.getElementById('editSessionModal').classList.remove('hidden');
}
function closeEditSessionModal() {
document.getElementById('editSessionModal').classList.add('hidden');
}
async function saveSessionTimes() {
const sessionId = document.getElementById('editSessionId').value;
const startedAt = document.getElementById('editStartedAt').value;
const stoppedAt = document.getElementById('editStoppedAt').value;
try {
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
started_at: startedAt || null,
stopped_at: stoppedAt || null,
})
});
if (response.ok) {
closeEditSessionModal();
window.location.reload();
} else {
const data = await response.json();
alert(`Failed to update session: ${data.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error updating session: ${error.message}`);
}
}
</script>
<!-- Edit Session Times Modal -->
<div id="editSessionModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Session Times</h3>
<input type="hidden" id="editSessionId">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
<input type="datetime-local" id="editStartedAt"
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 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stop Time</label>
<input type="datetime-local" id="editStoppedAt"
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 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">Times are in your local timezone. The session label and period type will be updated automatically.</p>
<div class="flex justify-end gap-3 mt-5">
<button onclick="closeEditSessionModal()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Cancel
</button>
<button onclick="saveSessionTimes()"
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
Save
</button>
</div>
</div>
</div>

View File

@@ -838,6 +838,7 @@ async function loadProjectDetails() {
// Update tab labels and visibility based on project type
const isSoundProject = projectTypeId === 'sound_monitoring';
const isVibrationProject = projectTypeId === 'vibration_monitoring';
if (isSoundProject) {
document.getElementById('locations-tab-label').textContent = 'NRLs';
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
@@ -848,9 +849,9 @@ async function loadProjectDetails() {
const isRemote = mode === 'remote';
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
// Schedules and Assigned Units are remote-only (manual projects collect data by hand)
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
// Schedules and Assigned Units: hidden for vibration; for sound, only show if remote
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
// FTP browser within Data Files tab
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);

View File

@@ -37,7 +37,7 @@
{{ location.name }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Monitoring Location {{ project.name }}
Monitoring Location &bull; {{ project.name }}
</p>
</div>
<div class="flex gap-2">
@@ -116,20 +116,36 @@
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
{% if assigned_unit %}
<div class="space-y-4">
<!-- Seismograph row -->
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Seismograph</div>
<div class="text-lg font-medium text-gray-900 dark:text-white">
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ assigned_unit.id }}
</a>
</div>
{% if assigned_unit.unit_type %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ assigned_unit.unit_type }}</div>
{% endif %}
</div>
{% if assigned_unit.device_type %}
<!-- Modem row -->
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Modem</div>
{% if assigned_modem %}
<div class="text-lg font-medium text-gray-900 dark:text-white">
<a href="/unit/{{ assigned_modem.id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ assigned_modem.id }}
</a>
</div>
{% if assigned_modem.hardware_model or assigned_modem.ip_address %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ assigned_modem.hardware_model or '' }}{% if assigned_modem.hardware_model and assigned_modem.ip_address %} &bull; {% endif %}{{ assigned_modem.ip_address or '' }}
</div>
{% endif %}
{% else %}
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No modem paired</div>
{% endif %}
</div>
{% endif %}
{% if assignment %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
@@ -142,10 +158,14 @@
</div>
{% endif %}
{% endif %}
<div class="pt-2">
<div class="pt-2 flex gap-2 flex-wrap">
<button onclick="openSwapModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors text-sm">
Swap Unit / Modem
</button>
<button onclick="unassignUnit('{{ assignment.id }}')"
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
Unassign Unit
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors text-sm">
Unassign
</button>
</div>
</div>
@@ -155,7 +175,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
<button onclick="openAssignModal()"
<button onclick="openSwapModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Assign a Unit
</button>
@@ -214,47 +234,55 @@
</div>
</div>
<!-- Assign Unit Modal -->
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<!-- Assign / Swap Modal -->
<div id="swap-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-2xl max-h-[90vh] overflow-y-auto m-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p>
<h2 id="swap-modal-title" class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a seismograph and optionally a modem for this location</p>
</div>
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<button onclick="closeSwapModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<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="assign-form" class="p-6 space-y-4">
<form id="swap-form" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
<select id="assign-unit-id" name="unit_id"
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Seismograph <span class="text-red-500">*</span></label>
<select id="swap-unit-id" name="unit_id"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
<option value="">Loading units...</option>
</select>
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Modem <span class="text-xs text-gray-400">(optional)</span></label>
<select id="swap-modem-id" name="modem_id"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">No modem</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="assign-notes" name="notes" rows="2"
<textarea id="swap-notes" name="notes" rows="2"
class="w-full px-4 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="assign-error" class="hidden text-sm text-red-600"></div>
<div id="swap-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeAssignModal()"
<button type="button" onclick="closeSwapModal()"
class="px-6 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"
<button type="submit" id="swap-submit-btn"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Assign Unit
Assign
</button>
</div>
</form>
@@ -264,6 +292,7 @@
<script>
const projectId = "{{ project_id }}";
const locationId = "{{ location_id }}";
const hasAssignment = {{ 'true' if assigned_unit else 'false' }};
// Tab switching
function switchTab(tabName) {
@@ -314,60 +343,89 @@ document.getElementById('location-settings-form').addEventListener('submit', asy
}
});
// Assign modal
function openAssignModal() {
document.getElementById('assign-modal').classList.remove('hidden');
loadAvailableUnits();
// Swap / Assign modal
async function openSwapModal() {
document.getElementById('swap-modal').classList.remove('hidden');
document.getElementById('swap-modal-title').textContent = hasAssignment ? 'Swap Unit / Modem' : 'Assign Unit';
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
document.getElementById('swap-error').classList.add('hidden');
document.getElementById('swap-notes').value = '';
await Promise.all([loadSwapUnits(), loadSwapModems()]);
}
function closeAssignModal() {
document.getElementById('assign-modal').classList.add('hidden');
function closeSwapModal() {
document.getElementById('swap-modal').classList.add('hidden');
}
async function loadAvailableUnits() {
async function loadSwapUnits() {
try {
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
if (!response.ok) throw new Error('Failed to load available units');
if (!response.ok) throw new Error('Failed to load units');
const data = await response.json();
const select = document.getElementById('assign-unit-id');
select.innerHTML = '<option value="">Select a unit</option>';
const select = document.getElementById('swap-unit-id');
select.innerHTML = '<option value="">Select a seismograph</option>';
if (!data.length) {
document.getElementById('assign-empty').classList.remove('hidden');
return;
document.getElementById('swap-units-empty').classList.remove('hidden');
} else {
document.getElementById('swap-units-empty').classList.add('hidden');
}
data.forEach(unit => {
const option = document.createElement('option');
option.value = unit.id;
option.textContent = `${unit.id}${unit.model || unit.device_type}`;
option.textContent = unit.id + (unit.model ? ` \u2022 ${unit.model}` : '') + (unit.location ? ` \u2014 ${unit.location}` : '');
select.appendChild(option);
});
} catch (err) {
const errorEl = document.getElementById('assign-error');
errorEl.textContent = err.message || 'Failed to load units.';
errorEl.classList.remove('hidden');
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
document.getElementById('swap-error').classList.remove('hidden');
}
}
document.getElementById('assign-form').addEventListener('submit', async function(e) {
async function loadSwapModems() {
try {
const response = await fetch(`/api/projects/${projectId}/available-modems`);
if (!response.ok) throw new Error('Failed to load modems');
const data = await response.json();
const select = document.getElementById('swap-modem-id');
select.innerHTML = '<option value="">No modem</option>';
data.forEach(modem => {
const option = document.createElement('option');
option.value = modem.id;
let label = modem.id;
if (modem.hardware_model) label += ` \u2022 ${modem.hardware_model}`;
if (modem.ip_address) label += ` \u2014 ${modem.ip_address}`;
option.textContent = label;
select.appendChild(option);
});
} catch (err) {
// Modem list failure is non-fatal — just leave blank
console.warn('Failed to load modems:', err);
}
}
document.getElementById('swap-form').addEventListener('submit', async function(e) {
e.preventDefault();
const unitId = document.getElementById('assign-unit-id').value;
const notes = document.getElementById('assign-notes').value.trim();
const unitId = document.getElementById('swap-unit-id').value;
const modemId = document.getElementById('swap-modem-id').value;
const notes = document.getElementById('swap-notes').value.trim();
if (!unitId) {
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
document.getElementById('assign-error').classList.remove('hidden');
document.getElementById('swap-error').textContent = 'Please select a seismograph.';
document.getElementById('swap-error').classList.remove('hidden');
return;
}
try {
const formData = new FormData();
formData.append('unit_id', unitId);
formData.append('notes', notes);
if (modemId) formData.append('modem_id', modemId);
if (notes) formData.append('notes', notes);
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/swap`, {
method: 'POST',
body: formData
});
@@ -379,9 +437,8 @@ document.getElementById('assign-form').addEventListener('submit', async function
window.location.reload();
} catch (err) {
const errorEl = document.getElementById('assign-error');
errorEl.textContent = err.message || 'Failed to assign unit.';
errorEl.classList.remove('hidden');
document.getElementById('swap-error').textContent = err.message || 'Failed to assign unit.';
document.getElementById('swap-error').classList.remove('hidden');
}
});
@@ -405,11 +462,11 @@ async function unassignUnit(assignmentId) {
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAssignModal();
if (e.key === 'Escape') closeSwapModal();
});
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeAssignModal();
document.getElementById('swap-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeSwapModal();
});
</script>
{% endblock %}