v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes #54

Merged
serversdown merged 6 commits from dev into main 2026-05-20 11:44:48 -04:00
5 changed files with 818 additions and 0 deletions
Showing only changes of commit 44ab4d8427 - Show all commits
+8
View File
@@ -291,6 +291,14 @@ async def pending_deployments_page(request: Request):
return templates.TemplateResponse("admin/pending_deployments.html", {"request": request}) return templates.TemplateResponse("admin/pending_deployments.html", {"request": request})
@app.get("/tools/unit-swap", response_class=HTMLResponse)
async def unit_swap_page(request: Request):
"""Mobile-first wizard for swapping a vibration unit (and optionally its
modem) at a monitoring location. Pick project → location → incoming
unit → modem decision → confirm → optional photo of the new install."""
return templates.TemplateResponse("admin/unit_swap.html", {"request": request})
@app.get("/modems", response_class=HTMLResponse) @app.get("/modems", response_class=HTMLResponse)
async def modems_page(request: Request): async def modems_page(request: Request):
"""Field modems management dashboard""" """Field modems management dashboard"""
+88
View File
@@ -262,6 +262,83 @@ async def get_project_locations_json(
] ]
@router.get("/locations-with-assignments")
async def get_locations_with_assignments(
project_id: str,
db: Session = Depends(get_db),
location_type: Optional[str] = Query(None),
):
"""
Locations + their currently-active assignment + current unit + paired modem
in one call. Used by the Unit Swap tool's location picker so a field tech
can see what's deployed where without N+1 round-trips.
Empty locations come back with assignment/unit/modem all null.
Removed locations are always excluded — you don't swap onto a dead slot.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
query = db.query(MonitoringLocation).filter_by(project_id=project_id).filter(
MonitoringLocation.removed_at == None # noqa: E711
)
if location_type:
query = query.filter_by(location_type=location_type)
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
results = []
for loc in locations:
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == loc.id,
UnitAssignment.assigned_until == None, # noqa: E711
)
).first()
unit_payload = None
modem_payload = None
if assignment:
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if unit:
unit_payload = {
"id": unit.id,
"device_type": unit.device_type,
"unit_type": unit.unit_type,
"slm_model": unit.slm_model,
"deployed_with_modem_id": unit.deployed_with_modem_id,
}
if unit.deployed_with_modem_id:
modem = db.query(RosterUnit).filter_by(
id=unit.deployed_with_modem_id, device_type="modem"
).first()
if modem:
modem_payload = {
"id": modem.id,
"hardware_model": modem.hardware_model,
"ip_address": modem.ip_address,
"phone_number": modem.phone_number,
}
results.append({
"id": loc.id,
"name": loc.name,
"location_type": loc.location_type,
"description": loc.description,
"address": loc.address,
"coordinates": loc.coordinates,
"assignment": {
"id": assignment.id,
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
"notes": assignment.notes,
} if assignment else None,
"unit": unit_payload,
"modem": modem_payload,
})
return results
@router.post("/locations/create") @router.post("/locations/create")
async def create_location( async def create_location(
project_id: str, project_id: str,
@@ -1192,6 +1269,17 @@ async def swap_unit_on_location(
new_value=f"swapped out → {unit_id}", new_value=f"swapped out → {unit_id}",
notes=notes, notes=notes,
) )
# Clear the outgoing unit's modem pairing so the bidirectional
# deployed_with_modem_id / deployed_with_unit_id back-reference
# doesn't orphan onto the unit that just left the field.
old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first()
if old_unit and old_unit.deployed_with_modem_id:
old_modem = db.query(RosterUnit).filter_by(
id=old_unit.deployed_with_modem_id, device_type="modem"
).first()
if old_modem and old_modem.deployed_with_unit_id == current.unit_id:
old_modem.deployed_with_unit_id = None
old_unit.deployed_with_modem_id = None
# Create new assignment # Create new assignment
new_assignment = UnitAssignment( new_assignment = UnitAssignment(
+703
View File
@@ -0,0 +1,703 @@
{% extends "base.html" %}
{% block title %}Unit Swap - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="max-w-md mx-auto">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Unit Swap</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Swap a vibration unit (and modem) at a monitoring location.
</p>
</div>
<a href="/tools" class="text-xs text-seismo-orange hover:text-seismo-burgundy whitespace-nowrap">← Tools</a>
</div>
<!-- Stepper -->
<div class="flex items-center justify-between mb-4 text-[11px] text-gray-500 dark:text-gray-400">
<div id="swap-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
<span class="w-5 h-5 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-[10px]">1</span>
Project
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
<div id="swap-pill-2" class="flex items-center gap-1">
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">2</span>
Location
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
<div id="swap-pill-3" class="flex items-center gap-1">
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">3</span>
Unit
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
<div id="swap-pill-4" class="flex items-center gap-1">
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">4</span>
Modem
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
<div id="swap-pill-5" class="flex items-center gap-1">
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">5</span>
Confirm
</div>
</div>
<!-- Step 1: Project picker -->
<div id="swap-step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which project?</span>
<input id="swap-project-search" type="search" autocomplete="off"
placeholder="Filter by number, client, or name…"
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</label>
<div id="swap-project-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
</div>
<!-- Step 2: Location picker -->
<div id="swap-step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="text-xs text-gray-500 dark:text-gray-400">Project</p>
<p class="font-semibold text-seismo-orange truncate" id="swap-project-label"></p>
</div>
<button onclick="swapGoToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Which location?</p>
<div id="swap-location-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
</div>
</div>
<!-- Step 3: New unit picker -->
<div id="swap-step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<p class="text-xs text-gray-500 dark:text-gray-400">Swapping at</p>
<p class="font-semibold text-seismo-orange truncate" id="swap-location-label"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Out: <span id="swap-old-unit-label" class="font-mono"></span>
</p>
</div>
<button onclick="swapGoToStep(2)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
</div>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Incoming unit</span>
<input id="swap-unit-search" type="search" autocomplete="off"
placeholder="Filter by serial…"
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only seismographs without an active assignment.</p>
</label>
<div id="swap-unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
</div>
<!-- Step 4: Modem decision -->
<div id="swap-step-4" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<p class="text-xs text-gray-500 dark:text-gray-400">Incoming</p>
<p class="font-mono font-semibold text-seismo-orange" id="swap-new-unit-label"></p>
</div>
<button onclick="swapGoToStep(3)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" id="swap-modem-question">Modem?</p>
<div id="swap-modem-choice-list" class="space-y-2"></div>
</div>
<div id="swap-modem-picker-wrap" class="hidden">
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Pick a modem</span>
<input id="swap-modem-search" type="search" autocomplete="off"
placeholder="Filter modems…"
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</label>
<div id="swap-modem-list" class="max-h-60 overflow-y-auto space-y-1.5 mt-2"></div>
</div>
<button id="swap-modem-next"
onclick="swapAfterModem()"
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
Continue
</button>
</div>
<!-- Step 5: Review + confirm -->
<div id="swap-step-5" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Review the swap</h3>
<dl class="text-sm space-y-2">
<div class="flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Project</dt>
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-project"></dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-location"></dd>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Unit out</dt>
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-unit"></dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Unit in</dt>
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-unit"></dd>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Modem out</dt>
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-modem"></dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Modem in</dt>
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-modem"></dd>
</div>
</dl>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes (optional)</span>
<textarea id="swap-notes" rows="2"
placeholder="Reason for swap, anything to remember…"
class="mt-2 w-full px-3 py-2 text-base 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="swap-error" class="hidden text-sm text-red-600"></div>
<button id="swap-confirm-btn"
onclick="swapConfirm()"
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
Confirm swap
</button>
<button onclick="swapGoToStep(4)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Back</button>
</div>
<!-- Step 6: Success + optional photo -->
<div id="swap-step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<div class="text-center">
<div class="w-14 h-14 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-3">Swap complete</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1" id="swap-done-summary"></p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Add a photo of the new install?</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Optional. EXIF GPS will populate the unit's coordinates.</p>
<input id="swap-photo-input" type="file" accept="image/*" capture="environment"
onchange="swapOnPhotoPicked(event)"
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
<div id="swap-photo-preview-wrap" class="hidden mt-3">
<img id="swap-photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="">
</div>
<div id="swap-photo-error" class="hidden text-sm text-red-600 mt-2"></div>
<div id="swap-photo-status" class="hidden text-sm text-green-600 mt-2"></div>
</div>
<div class="grid grid-cols-2 gap-2">
<button id="swap-photo-upload-btn"
onclick="swapUploadPhoto()"
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
Upload photo
</button>
<button onclick="swapReset()"
class="px-4 py-3 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
Done — another swap
</button>
</div>
</div>
</div>
<script>
const _swap = {
step: 1,
project: null, // { id, display, ... }
location: null, // { id, name, unit, modem }
new_unit: null, // { id, ... }
modem_action: null, // 'keep' | 'swap' | 'remove' | 'add' | 'none'
new_modem: null, // { id, ... }
all_projects: [],
all_units: [],
all_modems: [],
swap_result: null,
photo_file: null,
photo_preview_url: null,
};
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function swapGoToStep(n) {
_swap.step = n;
for (let i = 1; i <= 5; i++) {
const el = document.getElementById('swap-step-' + i);
if (el) el.classList.toggle('hidden', i !== n);
const pill = document.getElementById('swap-pill-' + i);
if (!pill) continue;
const dot = pill.querySelector('span');
if (i === n) {
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
pill.classList.add('text-seismo-orange', 'font-medium');
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700');
dot.classList.add('bg-seismo-orange', 'text-white');
} else if (i < n) {
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
pill.classList.add('text-green-600', 'dark:text-green-400');
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'bg-seismo-orange', 'text-white');
dot.classList.add('bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
} else {
pill.classList.add('text-gray-500', 'dark:text-gray-400');
pill.classList.remove('text-seismo-orange', 'font-medium', 'text-green-600', 'dark:text-green-400');
dot.classList.add('bg-gray-200', 'dark:bg-gray-700');
dot.classList.remove('bg-seismo-orange', 'text-white', 'bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
}
}
document.getElementById('swap-step-done').classList.add('hidden');
}
// ── Step 1: project picker ──────────────────────────────────────────
async function _swapLoadProjects() {
const list = document.getElementById('swap-project-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try {
const r = await fetch('/api/projects/search-json?limit=50');
if (!r.ok) throw new Error('HTTP ' + r.status);
_swap.all_projects = await r.json();
_swapRenderProjects();
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
}
}
function _swapRenderProjects() {
const q = document.getElementById('swap-project-search').value.trim().toLowerCase();
const list = document.getElementById('swap-project-list');
let items = _swap.all_projects;
if (q) {
items = items.filter(p => {
const hay = [(p.project_number||''), (p.client_name||''), (p.name||''), (p.display||'')]
.join(' ').toLowerCase();
return hay.includes(q);
});
}
if (items.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching projects.</p>';
return;
}
list.innerHTML = items.map(p => {
const num = p.project_number ? `<span class="font-mono text-xs text-gray-500 dark:text-gray-400">${_esc(p.project_number)}</span>` : '';
const client = p.client_name ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(p.client_name)}</div>` : '';
return `<button onclick='swapPickProject(${JSON.stringify(p.id)})'
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(p.name)}</span>
${num}
</div>
${client}
</button>`;
}).join('');
}
function swapPickProject(projectId) {
const p = _swap.all_projects.find(x => x.id === projectId);
if (!p) return;
_swap.project = p;
document.getElementById('swap-project-label').textContent = p.name;
_swapLoadLocations();
swapGoToStep(2);
}
// ── Step 2: location picker ─────────────────────────────────────────
async function _swapLoadLocations() {
const list = document.getElementById('swap-location-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try {
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/locations-with-assignments?location_type=vibration`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
_swapRenderLocations(data);
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
}
}
function _swapRenderLocations(locations) {
const list = document.getElementById('swap-location-list');
if (!locations || locations.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No vibration locations in this project.</p>';
return;
}
list.innerHTML = locations.map(loc => {
const unit = loc.unit;
const modem = loc.modem;
const unitLine = unit
? `<div class="text-xs text-gray-600 dark:text-gray-300 font-mono">${_esc(unit.id)}<span class="text-gray-400">${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}</span></div>`
: `<div class="text-xs italic text-gray-400">Empty — first assign</div>`;
const modemLine = modem
? `<div class="text-[11px] text-gray-500 dark:text-gray-400 font-mono">+ modem ${_esc(modem.id)}</div>`
: '';
// Pass index for cleaner attribute escaping
return `<button data-locidx="${locations.indexOf(loc)}" onclick="_swapPickLocationByIdx(this.dataset.locidx)"
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(loc.name)}</span>
</div>
${unitLine}
${modemLine}
</button>`;
}).join('');
_swap._locations_cache = locations;
}
function _swapPickLocationByIdx(idxStr) {
const idx = parseInt(idxStr, 10);
const loc = _swap._locations_cache[idx];
if (!loc) return;
_swap.location = loc;
document.getElementById('swap-location-label').textContent = loc.name;
document.getElementById('swap-old-unit-label').textContent = loc.unit ? loc.unit.id : '(empty)';
_swapLoadUnits();
swapGoToStep(3);
}
// ── Step 3: incoming unit picker ────────────────────────────────────
async function _swapLoadUnits() {
const list = document.getElementById('swap-unit-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try {
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-units?location_type=vibration`);
if (!r.ok) throw new Error('HTTP ' + r.status);
_swap.all_units = await r.json();
_swapRenderUnits();
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
}
}
function _swapRenderUnits() {
const q = document.getElementById('swap-unit-search').value.trim().toLowerCase();
const list = document.getElementById('swap-unit-list');
let items = _swap.all_units;
if (q) {
items = items.filter(u => {
const hay = [(u.id||''), (u.model||''), (u.location||'')].join(' ').toLowerCase();
return hay.includes(q);
});
}
if (items.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No available seismographs.</p>';
return;
}
list.innerHTML = items.map(u => {
const model = u.model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(u.model)}</span>` : '';
const loc = u.location ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.location)}</div>` : '';
return `<button onclick='swapPickUnit(${JSON.stringify(u.id)})'
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="flex items-center justify-between gap-2">
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
${model}
</div>
${loc}
</button>`;
}).join('');
}
function swapPickUnit(unitId) {
const u = _swap.all_units.find(x => x.id === unitId);
if (!u) return;
_swap.new_unit = u;
document.getElementById('swap-new-unit-label').textContent = u.id;
_swapInitModemStep();
swapGoToStep(4);
}
// ── Step 4: modem decision ──────────────────────────────────────────
function _swapInitModemStep() {
_swap.modem_action = null;
_swap.new_modem = null;
document.getElementById('swap-modem-picker-wrap').classList.add('hidden');
document.getElementById('swap-modem-next').disabled = true;
const current = _swap.location && _swap.location.modem;
const choices = document.getElementById('swap-modem-choice-list');
const question = document.getElementById('swap-modem-question');
if (current) {
question.textContent = `Modem currently at this location: ${current.id}`;
choices.innerHTML = `
<button data-action="keep" onclick="swapPickModemAction('keep')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Re-pair the existing modem to the incoming unit.</div>
</button>
<button data-action="swap" onclick="swapPickModemAction('swap')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">Swap modem too</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Pick a different unassigned modem.</div>
</button>
<button data-action="remove" onclick="swapPickModemAction('remove')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Incoming unit goes solo (no cellular).</div>
</button>`;
} else {
question.textContent = 'No modem at this location currently.';
choices.innerHTML = `
<button data-action="none" onclick="swapPickModemAction('none')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Standalone / manual download.</div>
</button>
<button data-action="add" onclick="swapPickModemAction('add')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">Add a modem</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Pair an unassigned modem with the incoming unit.</div>
</button>`;
}
}
function swapPickModemAction(action) {
_swap.modem_action = action;
_swap.new_modem = null;
// Highlight the picked choice; dim the others.
document.querySelectorAll('.swap-modem-choice').forEach(btn => {
if (btn.dataset.action === action) {
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
} else {
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
}
});
const pickerWrap = document.getElementById('swap-modem-picker-wrap');
const nextBtn = document.getElementById('swap-modem-next');
if (action === 'swap' || action === 'add') {
pickerWrap.classList.remove('hidden');
nextBtn.disabled = true;
_swapLoadModems();
} else {
pickerWrap.classList.add('hidden');
nextBtn.disabled = false;
}
}
async function _swapLoadModems() {
const list = document.getElementById('swap-modem-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try {
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-modems`);
if (!r.ok) throw new Error('HTTP ' + r.status);
// Filter out the modem that's currently at this location (it's the "keep" option, not "swap").
let modems = await r.json();
const currentModemId = _swap.location && _swap.location.modem ? _swap.location.modem.id : null;
if (currentModemId) modems = modems.filter(m => m.id !== currentModemId);
_swap.all_modems = modems;
_swapRenderModems();
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
}
}
function _swapRenderModems() {
const q = document.getElementById('swap-modem-search').value.trim().toLowerCase();
const list = document.getElementById('swap-modem-list');
let items = _swap.all_modems;
if (q) {
items = items.filter(m => {
const hay = [(m.id||''), (m.hardware_model||''), (m.ip_address||'')].join(' ').toLowerCase();
return hay.includes(q);
});
}
if (items.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No modems available.</p>';
return;
}
list.innerHTML = items.map(m => {
const hw = m.hardware_model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(m.hardware_model)}</span>` : '';
const ip = m.ip_address ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${_esc(m.ip_address)}</div>` : '';
return `<button onclick='swapPickModem(${JSON.stringify(m.id)})'
class="swap-modem-pick w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"
data-modem-id="${_esc(m.id)}">
<div class="flex items-center justify-between gap-2">
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(m.id)}</span>
${hw}
</div>
${ip}
</button>`;
}).join('');
}
function swapPickModem(modemId) {
const m = _swap.all_modems.find(x => x.id === modemId);
if (!m) return;
_swap.new_modem = m;
document.querySelectorAll('.swap-modem-pick').forEach(btn => {
if (btn.dataset.modemId === modemId) {
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
} else {
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
}
});
document.getElementById('swap-modem-next').disabled = false;
}
function swapAfterModem() {
// Populate review screen
document.getElementById('swap-review-project').textContent = _swap.project.name;
document.getElementById('swap-review-location').textContent = _swap.location.name;
document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)';
document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id;
const oldModem = _swap.location.modem ? _swap.location.modem.id : '(none)';
document.getElementById('swap-review-old-modem').textContent = oldModem;
let newModem = '(none)';
if (_swap.modem_action === 'keep' && _swap.location.modem) {
newModem = _swap.location.modem.id + ' (kept)';
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
newModem = _swap.new_modem.id;
}
document.getElementById('swap-review-new-modem').textContent = newModem;
swapGoToStep(5);
}
// ── Step 5: confirm ─────────────────────────────────────────────────
async function swapConfirm() {
const btn = document.getElementById('swap-confirm-btn');
const err = document.getElementById('swap-error');
err.classList.add('hidden');
// Determine modem_id to send:
// 'keep' → current modem id (re-pair to new unit)
// 'swap' → newly-picked modem id
// 'add' → newly-picked modem id
// 'remove' → omit (endpoint clears new unit's pairing)
// 'none' → omit
let modemIdToSend = null;
if (_swap.modem_action === 'keep' && _swap.location.modem) {
modemIdToSend = _swap.location.modem.id;
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
modemIdToSend = _swap.new_modem.id;
}
btn.disabled = true;
btn.textContent = 'Swapping…';
const fd = new FormData();
fd.append('unit_id', _swap.new_unit.id);
if (modemIdToSend) fd.append('modem_id', modemIdToSend);
const notes = document.getElementById('swap-notes').value.trim();
if (notes) fd.append('notes', notes);
try {
const url = `/api/projects/${encodeURIComponent(_swap.project.id)}/locations/${encodeURIComponent(_swap.location.id)}/swap`;
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);
}
_swap.swap_result = await r.json();
_swapShowDone();
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Confirm swap';
}
}
function _swapShowDone() {
for (let i = 1; i <= 5; i++) {
document.getElementById('swap-step-' + i).classList.add('hidden');
}
document.getElementById('swap-step-done').classList.remove('hidden');
const summary = `${_swap.new_unit.id} is now at ${_swap.location.name}` +
(_swap.location.unit ? ` (replacing ${_swap.location.unit.id}).` : '.');
document.getElementById('swap-done-summary').textContent = summary;
}
// ── Photo upload (optional) ─────────────────────────────────────────
function swapOnPhotoPicked(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
_swap.photo_file = file;
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
_swap.photo_preview_url = URL.createObjectURL(file);
document.getElementById('swap-photo-preview').src = _swap.photo_preview_url;
document.getElementById('swap-photo-preview-wrap').classList.remove('hidden');
document.getElementById('swap-photo-upload-btn').disabled = false;
}
async function swapUploadPhoto() {
if (!_swap.photo_file) return;
const btn = document.getElementById('swap-photo-upload-btn');
const err = document.getElementById('swap-photo-error');
const ok = document.getElementById('swap-photo-status');
err.classList.add('hidden');
ok.classList.add('hidden');
btn.disabled = true;
btn.textContent = 'Uploading…';
const fd = new FormData();
fd.append('photo', _swap.photo_file);
try {
const url = `/api/unit/${encodeURIComponent(_swap.new_unit.id)}/upload-photo?auto_populate_coords=true`;
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);
}
const data = await r.json();
const coords = data && data.metadata && data.metadata.coordinates;
ok.textContent = coords ? `Uploaded. GPS: ${coords}` : 'Uploaded (no GPS in EXIF).';
ok.classList.remove('hidden');
btn.textContent = 'Uploaded';
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Upload photo';
}
}
function swapReset() {
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
Object.assign(_swap, {
project: null, location: null, new_unit: null,
modem_action: null, new_modem: null,
all_projects: [], all_units: [], all_modems: [],
swap_result: null, photo_file: null, photo_preview_url: null,
});
document.getElementById('swap-project-search').value = '';
document.getElementById('swap-unit-search').value = '';
document.getElementById('swap-modem-search').value = '';
document.getElementById('swap-notes').value = '';
document.getElementById('swap-photo-input').value = '';
document.getElementById('swap-photo-preview-wrap').classList.add('hidden');
document.getElementById('swap-photo-error').classList.add('hidden');
document.getElementById('swap-photo-status').classList.add('hidden');
document.getElementById('swap-error').classList.add('hidden');
const confirmBtn = document.getElementById('swap-confirm-btn');
confirmBtn.disabled = false; confirmBtn.textContent = 'Confirm swap';
const photoBtn = document.getElementById('swap-photo-upload-btn');
photoBtn.disabled = true; photoBtn.textContent = 'Upload photo';
swapGoToStep(1);
_swapLoadProjects();
}
// Wire up live filtering inputs.
document.getElementById('swap-project-search').addEventListener('input', _swapRenderProjects);
document.getElementById('swap-unit-search').addEventListener('input', _swapRenderUnits);
document.getElementById('swap-modem-search').addEventListener('input', _swapRenderModems);
// Kick off.
_swapLoadProjects();
</script>
{% endblock %}
+1
View File
@@ -150,6 +150,7 @@
(project tidy, metadata backfill, pair devices). #} (project tidy, metadata backfill, pair devices). #}
{% set _is_tools = ( {% set _is_tools = (
request.url.path == '/tools' request.url.path == '/tools'
or request.url.path.startswith('/tools/')
or request.url.path == '/pair-devices' or request.url.path == '/pair-devices'
or request.url.path == '/settings/developer/project-tidy' or request.url.path == '/settings/developer/project-tidy'
or request.url.path == '/settings/developer/metadata-backfill' or request.url.path == '/settings/developer/metadata-backfill'
+18
View File
@@ -141,6 +141,24 @@
</div> </div>
</a> </a>
<!-- Unit Swap -->
<a href="/tools/unit-swap"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">Unit Swap</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Field-swap a unit (and modem) at a vibration location. Pick project → location → incoming unit → confirm. Optional photo of the new install.
</p>
</div>
</div>
</a>
<!-- Swap Detection (Phase 5c — coming soon) --> <!-- Swap Detection (Phase 5c — coming soon) -->
<div class="bg-gray-50 dark:bg-slate-800/50 rounded-xl shadow p-5 border border-dashed border-gray-300 dark:border-gray-700 cursor-not-allowed"> <div class="bg-gray-50 dark:bg-slate-800/50 rounded-xl shadow p-5 border border-dashed border-gray-300 dark:border-gray-700 cursor-not-allowed">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">