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>
This commit is contained in:
@@ -232,6 +232,12 @@ function _esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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++) {
|
||||
@@ -339,7 +345,10 @@ function _swapRenderLocations(locations) {
|
||||
? `<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>`
|
||||
? `<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)"
|
||||
@@ -370,7 +379,7 @@ 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`);
|
||||
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();
|
||||
@@ -396,11 +405,15 @@ function _swapRenderUnits() {
|
||||
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>
|
||||
${model}
|
||||
<div class="flex items-center gap-2">
|
||||
${badge}
|
||||
${model}
|
||||
</div>
|
||||
</div>
|
||||
${loc}
|
||||
</button>`;
|
||||
@@ -428,11 +441,14 @@ function _swapInitModemStep() {
|
||||
const question = document.getElementById('swap-modem-question');
|
||||
|
||||
if (current) {
|
||||
question.textContent = `Modem currently at this location: ${current.id}`;
|
||||
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="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
|
||||
<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')"
|
||||
@@ -490,7 +506,7 @@ 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`);
|
||||
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();
|
||||
@@ -520,12 +536,16 @@ function _swapRenderModems() {
|
||||
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>
|
||||
${hw}
|
||||
<div class="flex items-center gap-2">
|
||||
${badge}
|
||||
${hw}
|
||||
</div>
|
||||
</div>
|
||||
${ip}
|
||||
</button>`;
|
||||
@@ -553,16 +573,21 @@ function swapAfterModem() {
|
||||
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;
|
||||
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)';
|
||||
}
|
||||
document.getElementById('swap-review-new-modem').textContent = newModem;
|
||||
|
||||
swapGoToStep(5);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user