feat: replacement candidate review UI + approve/reject workflow

- GET /api/admin/cantaloupe/batches/{id}/replacements returns candidates
- POST /api/admin/cantaloupe/batches/{id}/replacements resolves (approve/reject)
- Approve retires old asset (status=retired), sets install_date on new
- Reject records decision without modifying assets
- Batch approve blocks if unresolved replacements exist (use ?force=true)
- Frontend shows replacement cards with confidence badges in batch detail
- Per-replacement ✓ Approve / ✗ Reject buttons + ✓ Approve All bulk action
- History view shows replacement counts with unresolved warnings
This commit is contained in:
Leo
2026-05-23 17:55:54 -04:00
parent 78f4b74675
commit ea1feaaad1
2 changed files with 332 additions and 4 deletions
+147 -3
View File
@@ -661,6 +661,30 @@
.cs-changes-table .cs-arrow { color: var(--text2); padding: 0 4px; }
.cs-actions { display: flex; gap: 8px; margin-top: 12px; }
/* Replacement cards */
.cs-repl-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); }
.cs-repl-section h4 { font-size: 14px; margin-bottom: 10px; }
.cs-repl-card {
display: flex; align-items: center; gap: 12px;
background: rgba(255,255,255,0.02); border: 1px solid var(--border);
border-radius: 6px; padding: 10px 14px; margin-bottom: 8px;
flex-wrap: wrap;
}
.cs-repl-card.resolved { opacity: 0.55; }
.cs-repl-card .cs-repl-info { flex: 1; min-width: 200px; }
.cs-repl-card .cs-repl-info .cs-repl-old { color: var(--red); font-size: 12px; }
.cs-repl-card .cs-repl-info .cs-repl-new { color: var(--green); font-size: 12px; }
.cs-repl-card .cs-repl-info .cs-repl-addr { color: var(--text2); font-size: 11px; margin-top: 2px; }
.cs-repl-card .cs-repl-conf { font-size: 11px; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.cs-repl-conf.high { background: rgba(255,140,0,0.15); color: #ff8c00; }
.cs-repl-conf.medium { background: rgba(255,193,7,0.15); color: #ffc107; }
.cs-repl-card .cs-repl-actions { display: flex; gap: 6px; flex-shrink: 0; }
.cs-repl-card .cs-repl-actions button { font-size: 11px; padding: 4px 12px; }
.cs-repl-status { font-size: 11px; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.cs-repl-status.approved { background: rgba(72,199,142,0.15); color: var(--green); }
.cs-repl-status.rejected { background: rgba(240,61,62,0.15); color: var(--red); }
.cs-repl-resolve-all { margin-top: 8px; }
</style>
</head>
<body>
@@ -2349,6 +2373,42 @@ function renderBatchDiffDetail(el, batch) {
html = '<div class="text-muted text-sm">No changes detected.</div>';
}
// Replacement candidates section
const replacements = (batch.diff_summary && batch.diff_summary.replacements) || [];
if (replacements.length > 0) {
const unresolved = replacements.filter(r => !r.status).length;
html += `<div class="cs-repl-section">
<h4>🔄 Machine Replacements Detected (${replacements.length})${unresolved > 0 ? ` — <span style="color:var(--amber)">${unresolved} unresolved</span>` : ' — <span style="color:var(--green)">all resolved</span>'}</h4>
<div>`;
replacements.forEach(r => {
const resolved = !!r.status;
const statusLabel = r.status === 'approved' ? '✓ Approved' : r.status === 'rejected' ? '✗ Rejected' : '';
html += `<div class="cs-repl-card${resolved ? ' resolved' : ''}" id="csReplCard_${batch.id}_${r.id}">
<div class="cs-repl-info">
<div class="cs-repl-old">🏷️ Old: <b>${esc(r.old_machine_id)}</b> — ${esc(r.old_asset_name || '—')}</div>
<div class="cs-repl-new">🆕 New: <b>${esc(r.new_machine_id)}</b> — ${esc(r.new_asset_name || '—')}</div>
<div class="cs-repl-addr">📍 ${esc(r.location_address || '—')} · ${esc(r.customer_name || '—')}</div>
</div>
<span class="cs-repl-conf ${r.confidence || 'medium'}">${(r.confidence || 'medium').toUpperCase()}</span>`;
if (resolved) {
html += `<span class="cs-repl-status ${r.status}">${statusLabel}</span>`;
} else {
html += `<div class="cs-repl-actions">
<button class="btn btn-green btn-sm" onclick="resolveReplacement(${batch.id}, '${esc(r.new_machine_id)}', '${esc(r.old_machine_id)}', 'approve')">✓ Approve</button>
<button class="btn btn-danger btn-sm" onclick="resolveReplacement(${batch.id}, '${esc(r.new_machine_id)}', '${esc(r.old_machine_id)}', 'reject')">✗ Reject</button>
</div>`;
}
html += `</div>`;
});
html += `</div>`;
if (unresolved > 0) {
html += `<div class="cs-repl-resolve-all">
<button class="btn btn-accent btn-sm" onclick="approveAllReplacements(${batch.id})">✓ Approve All</button>
</div>`;
}
html += `</div>`;
}
el.innerHTML = html;
}
@@ -2364,10 +2424,13 @@ function renderCsHistory() {
const newCount = (ds.new_assets || []).length;
const chgCount = (ds.changed_assets || []).length;
const remCount = (ds.removed_assets || []).length;
const replCount = (ds.replacements || []).length;
const replUnresolved = (ds.replacements || []).filter(r => !r.status).length;
const summaryParts = [];
if (newCount) summaryParts.push(`+${newCount}`);
if (chgCount) summaryParts.push(`~${chgCount}`);
if (remCount) summaryParts.push(`${remCount}`);
if (replCount) summaryParts.push(`🔄${replCount}${replUnresolved > 0 ? `<span style="color:var(--amber)">⚠${replUnresolved}</span>` : ''}`);
return `
<div class="cs-batch-row" onclick="toggleBatchHistory(${b.id})" id="csBatchRow_${b.id}">
<span class="csb-status">${csStatusBadge(b.status)}</span>
@@ -2435,12 +2498,25 @@ async function uploadSyncFile(event) {
// ── Approve / Reject ──
async function approveBatch(batchId) {
// Check for unresolved replacements
let unresolved = 0;
try {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
unresolved = batch.unresolved_replacements || 0;
} catch (e) { /* ignore, proceed */ }
let warning = '';
if (unresolved > 0) {
warning = `\n\n⚠️ ${unresolved} replacement(s) are unresolved. Resolve them first or force-approve.`;
}
const confirmed = await showModal('Approve Batch',
`Apply all ${csLatestBatch && csLatestBatch.id === batchId ? 'pending ' : ''}changes from batch #${batchId} to live data?`,
'Approve', 'btn-green');
`Apply all ${csLatestBatch && csLatestBatch.id === batchId ? 'pending ' : ''}changes from batch #${batchId} to live data?${warning}`,
unresolved > 0 ? 'Force Approve' : 'Approve', unresolved > 0 ? 'btn-amber' : 'btn-green');
if (!confirmed) return;
try {
const result = await api(`/api/admin/cantaloupe/batches/${batchId}/approve`, { method: 'POST' });
const url = `/api/admin/cantaloupe/batches/${batchId}/approve${unresolved > 0 ? '?force=true' : ''}`;
const result = await api(url, { method: 'POST' });
showToast('Batch approved — ' + (result.applied_stats ? JSON.stringify(result.applied_stats) : 'done'));
await loadCantaloupeSync();
} catch (e) {
@@ -2462,6 +2538,74 @@ async function rejectBatch(batchId) {
}
}
// ── Replacement Resolution ──
async function resolveReplacement(batchId, newMid, oldMid, action) {
const label = action === 'approve' ? 'Approve' : 'Reject';
try {
const body = JSON.stringify({
replacements: [{ new_machine_id: newMid, old_machine_id: oldMid, action }]
});
const result = await api(`/api/admin/cantaloupe/batches/${batchId}/replacements`, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
});
showToast(`${label}d replacement: ${oldMid}${newMid}`);
// Reload the batch detail inline
const detail = document.getElementById('csBatchDetail_' + batchId);
if (detail && detail.dataset.loaded === '1') {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
const tmp = document.createElement('div');
renderBatchDiffDetail(tmp, batch);
detail.innerHTML = tmp.innerHTML;
}
// Refresh the batch list to update summary counts
await loadCantaloupeSync();
} catch (e) {
showToast(`${label} failed: ` + e.message, true);
}
}
async function approveAllReplacements(batchId) {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
const replacements = (batch.diff_summary && batch.diff_summary.replacements) || [];
const unresolved = replacements.filter(r => !r.status);
if (unresolved.length === 0) {
showToast('All replacements already resolved');
return;
}
const confirmed = await showModal('Approve All',
`Approve all ${unresolved.length} unresolved replacement(s) in batch #${batchId}?`,
'Approve All', 'btn-green');
if (!confirmed) return;
try {
const body = JSON.stringify({
replacements: unresolved.map(r => ({
new_machine_id: r.new_machine_id,
old_machine_id: r.old_machine_id,
action: 'approve',
}))
});
await api(`/api/admin/cantaloupe/batches/${batchId}/replacements`, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
});
showToast(`Approved ${unresolved.length} replacement(s)`);
// Reload detail
const detail = document.getElementById('csBatchDetail_' + batchId);
if (detail && detail.dataset.loaded === '1') {
const updated = await api(`/api/admin/cantaloupe/batches/${batchId}`);
const tmp = document.createElement('div');
renderBatchDiffDetail(tmp, updated);
detail.innerHTML = tmp.innerHTML;
}
await loadCantaloupeSync();
} catch (e) {
showToast('Approve all failed: ' + e.message, true);
}
}
// ═════════════════════════════════════════════════════════════════════════════
// HELPERS
// ═════════════════════════════════════════════════════════════════════════════