|
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 %}
|