feat: add Machine Replacements history view

- New /api/admin/replacements/history endpoint detects replacements via:
  - Sync batch diff_summary inline replacements + crossref (new vs removed)
  - Location-group detection (same building_name, active+inactive pair)
- New Replacements page in admin SPA with:
  - Table showing old MID → new MID, location, statuses, confidence
  - Search/filter by location or MID
  - Clickable MID links navigating to asset detail
- 146 historical replacement events detected from location data
This commit is contained in:
Leo
2026-05-27 20:26:48 -04:00
parent 6a37ea9f77
commit 2cafe6879a
2 changed files with 397 additions and 0 deletions
+178
View File
@@ -824,6 +824,9 @@
<button class="nav-item" data-page="export" onclick="switchPage('export')" title="Export & Admin — CSV exports, GPS clearing, database reset">
<span class="ni-icon">📤</span><span>Export</span>
</button>
<button class="nav-item" data-page="replacements" onclick="switchPage('replacements')" title="Replacements — machine replacement history tracking old→new MID at same location">
<span class="ni-icon">🔄</span><span>Replacements</span>
</button>
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')" title="Import — upload Excel files and review changes">
<span class="ni-icon">📥</span><span>Import</span>
</button>
@@ -1018,6 +1021,51 @@
</div>
</div>
<!-- ── REPLACEMENTS ── -->
<div id="pageReplacements" class="page hidden">
<div class="page-title">
<span>🔄 Machine Replacements</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadReplacements()">🔄 Refresh</button>
</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
<div>
<span style="font-size:13px;color:var(--text2);" id="repCount"></span>
</div>
<div style="display:flex;gap:8px;">
<input type="text" id="repSearch" placeholder="🔍 Search location or MID..." style="padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;width:260px;" oninput="filterReplacements()">
</div>
</div>
<div id="repError" class="text-sm text-muted" style="display:none;padding:16px;"></div>
<div style="overflow-x:auto;">
<table class="data-table" id="repTable">
<thead>
<tr>
<th>Date</th>
<th>Location</th>
<th>Old MID</th>
<th>Old Name</th>
<th>Old Status</th>
<th></th>
<th>New MID</th>
<th>New Name</th>
<th>New Status</th>
<th>Confidence</th>
</tr>
</thead>
<tbody id="repBody"></tbody>
</table>
</div>
<div id="repEmpty" class="empty-state" style="display:none;padding:40px;">
<div class="es-icon">🔄</div>
<div>No replacement events found</div>
<div class="text-sm text-muted">Replacements appear when a machine is swapped at the same location.</div>
</div>
</div>
</div>
<!-- ── EXCEL IMPORT ── -->
<div id="pageCantaloupe" class="page hidden">
<div class="page-title">
@@ -1380,6 +1428,7 @@ function switchPage(page) {
else if (page === 'customers') loadCustomers();
else if (page === 'activity') loadActivity();
else if (page === 'cantaloupe') loadCantaloupeSync();
else if (page === 'replacements') loadReplacements();
else if (page === 'exifscanner') loadExifScanner();
else if (page === 'route') initRoutePage();
}
@@ -4233,6 +4282,135 @@ function closeExifLightbox() {
if (exifLightboxEl) exifLightboxEl.style.display = 'none';
}
// ═════════════════════════════════════════════════════════════════════════════
// REPLACEMENTS
// ═════════════════════════════════════════════════════════════════════════════
let cachedReplacements = [];
function escHtml(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function loadReplacements() {
const body = document.getElementById('repBody');
const empty = document.getElementById('repEmpty');
const error = document.getElementById('repError');
const count = document.getElementById('repCount');
body.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:32px;color:var(--text2);">Loading...</td></tr>';
empty.style.display = 'none';
error.style.display = 'none';
try {
const data = await api('/api/admin/replacements/history');
cachedReplacements = data.replacements || [];
renderReplacements(cachedReplacements);
const src = data.sources || {};
const srcParts = [];
if (src.sync_batch_inline) srcParts.push(`${src.sync_batch_inline} from sync`);
if (src.batch_crossref) srcParts.push(`${src.batch_crossref} from crossref`);
if (src.location_group) srcParts.push(`${src.location_group} from location data`);
const srcInfo = srcParts.length ? ` (${srcParts.join(', ')})` : '';
count.textContent = `${data.total} replacement event${data.total !== 1 ? 's' : ''}${srcInfo}`;
} catch (e) {
error.textContent = 'Error loading replacements: ' + (e.message || e);
error.style.display = 'block';
body.innerHTML = '';
}
}
function renderReplacements(reps) {
const body = document.getElementById('repBody');
const empty = document.getElementById('repEmpty');
if (!reps || reps.length === 0) {
body.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
body.innerHTML = reps.map(rep => {
const date = rep.batch_date ? new Date(rep.batch_date + 'Z').toLocaleDateString() : '—';
const oldStatus = rep.old_status || '';
const newStatus = rep.new_status || 'On';
const confidence = rep.confidence || 'low';
const oldStatusLabel = oldStatus ? statusBadge(oldStatus) : '';
const newStatusLabel = statusBadge(newStatus);
let confClass = 'badge badge-info';
if (confidence === 'high') confClass = 'badge badge-success';
else if (confidence === 'medium') confClass = 'badge badge-warning';
else if (confidence === 'low') confClass = 'badge badge-default';
const oldMid = rep.old_machine_id || '';
const newMid = rep.new_machine_id || '';
const loc = rep.location || '';
const oldName = rep.old_name || '';
const newName = rep.new_name || '';
return `<tr>
<td style="white-space:nowrap;">${date}</td>
<td><strong>${escHtml(loc)}</strong></td>
<td><a href="#" onclick="showAssetDetail('${escHtml(oldMid)}');return false;" style="font-family:monospace;">${escHtml(oldMid)}</a></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(oldName)}">${escHtml(oldName)}</td>
<td>${oldStatusLabel}</td>
<td style="text-align:center;font-size:18px;color:var(--accent);">→</td>
<td><a href="#" onclick="showAssetDetail('${escHtml(newMid)}');return false;" style="font-family:monospace;font-weight:600;color:var(--green);">${escHtml(newMid)}</a></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(newName)}">${escHtml(newName)}</td>
<td>${newStatusLabel}</td>
<td><span class="${confClass}">${confidence}</span></td>
</tr>`;
}).join('');
}
function statusBadge(status) {
const colors = {
'On': { bg: '#1a3a2a', color: '#4ade80' },
'Incompatible': { bg: '#3a1a1a', color: '#f87171' },
'Action Required': { bg: '#3a2a1a', color: '#fbbf24' },
'Off': { bg: '#2a2a2a', color: '#9ca3af' },
'retired': { bg: '#2a1a2a', color: '#c084fc' },
};
const c = colors[status] || { bg: '#2a2a2a', color: '#ccc' };
return `<span style="background:${c.bg};color:${c.color};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${escHtml(status)}</span>`;
}
function filterReplacements() {
const q = document.getElementById('repSearch').value.toLowerCase().trim();
if (!q) {
renderReplacements(cachedReplacements);
return;
}
const filtered = cachedReplacements.filter(rep =>
(rep.location || '').toLowerCase().includes(q) ||
(rep.old_machine_id || '').includes(q) ||
(rep.new_machine_id || '').includes(q) ||
(rep.old_name || '').toLowerCase().includes(q) ||
(rep.new_name || '').toLowerCase().includes(q)
);
renderReplacements(filtered);
document.getElementById('repCount').textContent = `${filtered.length} of ${cachedReplacements.length} replacement events`;
}
function showAssetDetail(machineId) {
// Navigate to assets page with search
document.getElementById('assetSearch').value = machineId;
switchPage('assets');
// Trigger search after render
setTimeout(() => {
const input = document.getElementById('assetSearch');
if (input) input.value = machineId;
if (typeof doAssetSearch === 'function') doAssetSearch();
}, 100);
}
// ═════════════════════════════════════════════════════════════════════════════
// ROUTE PLANNER
// ═════════════════════════════════════════════════════════════════════════════