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:
@@ -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
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user