Files
terra-view/templates/admin/unit_swap.html
T
serversdown 6d37bd759e feat(unit-swap): show benched candidates and clean stale modem pairings
`available-units` and `available-modems` now accept `include_benched=true`
to also return units/modems with `deployed=False`.  Default is False so
the existing location-detail swap modal is unchanged.  Each row carries
a `deployed` boolean for badge rendering.  The Unit Swap wizard fetches
with the flag enabled — exactly the candidates a field tech pulls off
the shelf.

The /swap endpoint now flips the incoming unit (and modem) back to
`deployed=True` when they came in benched, keeping the legacy roster
flag consistent with the active-assignment signal.

Adds the symmetric half of the orphan-pairing fix: when a newly-paired
modem still claims a different seismograph (whose
`deployed_with_modem_id` was never cleared in a past swap), break that
stale back-reference before re-pairing.

`locations-with-assignments` includes `modem.deployed` so the wizard
can badge the current modem in the location card, the "Keep current
modem" choice, the picker rows, and the review screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:46:23 +00:00

729 lines
36 KiB
HTML

{% 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 _badge(deployed) {
return deployed
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">deployed</span>'
: '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">benched</span>';
}
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 mt-0.5 flex items-center gap-2">
<span>+ modem ${_esc(modem.id)}</span>
${_badge(modem.deployed)}
</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&include_benched=true`);
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>` : '';
const badge = _badge(u.deployed);
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>
<div class="flex items-center gap-2">
${badge}
${model}
</div>
</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 at this location';
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="flex items-center justify-between gap-2">
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
${_badge(current.deployed)}
</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?include_benched=true`);
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>` : '';
const badge = _badge(m.deployed);
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>
<div class="flex items-center gap-2">
${badge}
${hw}
</div>
</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 oldModemEl = document.getElementById('swap-review-old-modem');
if (_swap.location.modem) {
oldModemEl.innerHTML = `${_esc(_swap.location.modem.id)} ${_badge(_swap.location.modem.deployed)}`;
} else {
oldModemEl.textContent = '(none)';
}
const newModemEl = document.getElementById('swap-review-new-modem');
if (_swap.modem_action === 'keep' && _swap.location.modem) {
newModemEl.innerHTML = `${_esc(_swap.location.modem.id)} <span class="text-xs text-gray-500">(kept)</span> ${_badge(_swap.location.modem.deployed)}`;
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
newModemEl.innerHTML = `${_esc(_swap.new_modem.id)} ${_badge(_swap.new_modem.deployed)}`;
} else {
newModemEl.textContent = '(none)';
}
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 %}