feat: per-field approval backend + geotagger-style frontend

Backend (cantaloupe_sync.py):
- GET /api/admin/cantaloupe/batches/{id}/field-changes — flat list of per-field diffs
- POST /api/admin/cantaloupe/batches/{id}/apply-field-changes — apply selected changes only
- PerFieldApprovalRequest/FieldChangeItem Pydantic models
- Activity log entries for apply-field-changes

Frontend (static/index.html):
- toggleFieldReview() — loads field changes via API, shows/hides review area
- renderFieldChanges() — geotagger-style cards with checkboxes per field, grouped by machine
- applyFieldChanges() — POST selected changes with confirmation modal
- Auto-select blank-fill changes (is_blank_fill flag)
- Sticky apply bar at bottom with live selected count
- Toggle all, toggle per-asset, toggle per-field helpers
- Review Changes button in pending review card
This commit is contained in:
Leo
2026-05-23 18:24:39 -04:00
parent ea1feaaad1
commit c35e2a1af9
2 changed files with 404 additions and 5 deletions
+205 -2
View File
@@ -2294,8 +2294,10 @@ function renderCsPendingReview() {
<div class="cs-diff-badge rem"><div class="csd-val">${remCount}</div><div class="csd-label">Removed</div></div>
</div>
<div id="csPendingDetail"></div>
<div class="cs-actions">
<button class="btn btn-green" onclick="approveBatch(${pending.id})">✓ Approve</button>
<div id="csFieldReviewArea" style="margin-top:12px;display:none;"></div>
<div class="cs-actions" style="gap:8px;flex-wrap:wrap;">
<button class="btn btn-accent btn-sm" onclick="toggleFieldReview(${pending.id})">📋 Review Changes</button>
<button class="btn btn-green" onclick="approveBatch(${pending.id})">✓ Approve All</button>
<button class="btn btn-danger" onclick="rejectBatch(${pending.id})">✕ Reject</button>
</div>
`;
@@ -2538,6 +2540,207 @@ async function rejectBatch(batchId) {
}
}
// ── Per-Field Review (geotagger-style) ──
let _csFieldReviewState = null; // { batchId, changes, selected: Set('mid:field') }
let _csFieldReviewOpen = false;
async function toggleFieldReview(batchId) {
const area = document.getElementById('csFieldReviewArea');
if (!area) return;
if (_csFieldReviewOpen) {
area.style.display = 'none';
_csFieldReviewOpen = false;
return;
}
_csFieldReviewState = { batchId, changes: [], selected: new Set() };
try {
const data = await api(`/api/admin/cantaloupe/batches/${batchId}/field-changes`);
_csFieldReviewState.changes = data.field_changes || [];
renderFieldChanges(area);
area.style.display = 'block';
_csFieldReviewOpen = true;
} catch (e) {
showToast('Failed to load field changes: ' + e.message, true);
}
}
function renderFieldChanges(area) {
const state = _csFieldReviewState;
if (!state || !state.changes.length) {
area.innerHTML = '<div class="text-muted text-sm" style="padding:12px;text-align:center;">No field-level changes to review.</div>';
return;
}
let html = '<div style="margin-bottom:8px;">';
html += '<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:8px;">';
html += `<span style="font-weight:600;font-size:13px;">🧩 ${state.changes.length} Field Change(s)</span>`;
html += '<div style="display:flex;gap:6px;">';
html += '<button class="btn btn-sm btn-accent" onclick="selectAllBlankFills()">✅ Auto-Select Blanks</button>';
html += '<button class="btn btn-sm" onclick="toggleAllFieldChanges()">Toggle All</button>';
html += '</div></div>';
// Group by machine_id for display
const grouped = {};
for (const ch of state.changes) {
const key = ch.machine_id + '|' + ch.name;
if (!grouped[key]) grouped[key] = { machine_id: ch.machine_id, name: ch.name, changes: [] };
grouped[key].changes.push(ch);
}
html += '<div style="display:flex;flex-direction:column;gap:8px;max-height:400px;overflow-y:auto;padding-right:4px;">';
for (const [key, asset] of Object.entries(grouped)) {
const assetChanges = asset.changes;
const allSelected = assetChanges.every(c => state.selected.has(c.machine_id + ':' + c.field));
const anySelected = assetChanges.some(c => state.selected.has(c.machine_id + ':' + c.field));
html += `<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:8px;transition:opacity 0.2s;" id="fcAsset_${esc(asset.machine_id)}">`;
html += `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<input type="checkbox" ${allSelected ? 'checked' : ''} onchange="toggleAssetChanges('${esc(asset.machine_id)}', this.checked)" style="accent-color:var(--green);">
<span style="font-weight:600;font-size:13px;"><code class="mono">${esc(asset.machine_id)}</code> — ${esc(asset.name || '—')}</span>
<span style="font-size:11px;color:var(--text2);">${assetChanges.length} change(s)</span>
</div>`;
for (const ch of assetChanges) {
const selKey = ch.machine_id + ':' + ch.field;
const checked = state.selected.has(selKey);
const isBlank = ch.is_blank_fill;
html += `<div style="display:flex;align-items:center;gap:8px;padding:4px 8px;margin:2px 0;border-radius:4px;${isBlank ? 'background:rgba(72,199,142,0.08);border-left:3px solid var(--green);' : ''}">`;
html += `<input type="checkbox" ${checked ? 'checked' : ''} id="fcb_${selKey}" onchange="toggleFieldChange('${esc(ch.machine_id)}', '${esc(ch.field)}', this.checked)" style="accent-color:var(--green);flex-shrink:0;">`;
html += `<span style="font-weight:600;font-size:12px;min-width:110px;flex-shrink:0;">${esc(ch.field)}</span>`;
html += `<span class="cs-old" style="font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(String(ch.old_value ?? '—'))}</span>`;
html += `<span style="color:var(--text2);padding:0 4px;">→</span>`;
html += `<span class="cs-new" style="font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(String(ch.new_value ?? '—'))}</span>`;
if (isBlank) html += `<span style="font-size:10px;color:var(--green);flex-shrink:0;background:rgba(72,199,142,0.12);padding:2px 6px;border-radius:4px;">BLANK FILL</span>`;
html += `</div>`;
}
html += '</div>';
}
html += '</div>'; // close scroll container
// Sticky apply bar at bottom
const selectedCount = state.selected.size;
html += `<div style="position:sticky;bottom:0;background:var(--card-bg);border-top:1px solid var(--border);padding:10px 0;margin-top:8px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;"><span id="csFieldCount" style="font-weight:700;">${selectedCount}</span> field change(s) selected</span>
<div style="display:flex;gap:8px;">
<button class="btn btn-sm btn-green" onclick="applyFieldChanges(${_csFieldReviewState.batchId})" id="csApplyFieldBtn"${selectedCount === 0 ? ' disabled style="opacity:0.5;"' : ''}>✓ Apply Selected</button>
</div>
</div>`;
area.innerHTML = html;
}
function toggleAssetChanges(machineId, checked) {
const state = _csFieldReviewState;
if (!state) return;
for (const ch of state.changes) {
if (ch.machine_id === machineId) {
const key = machineId + ':' + ch.field;
if (checked) state.selected.add(key);
else state.selected.delete(key);
}
}
// Refresh all checkboxes within this asset card
for (const ch of state.changes) {
if (ch.machine_id === machineId) {
const key = machineId + ':' + ch.field;
const cb = document.getElementById('fcb_' + key);
if (cb) cb.checked = checked;
}
}
updateFieldCount();
}
function toggleFieldChange(machineId, field, checked) {
const state = _csFieldReviewState;
if (!state) return;
const key = machineId + ':' + field;
if (checked) state.selected.add(key);
else state.selected.delete(key);
updateFieldCount();
}
function toggleAllFieldChanges() {
const state = _csFieldReviewState;
if (!state) return;
const allSelected = state.changes.every(c => state.selected.has(c.machine_id + ':' + c.field));
for (const ch of state.changes) {
const key = ch.machine_id + ':' + ch.field;
if (allSelected) state.selected.delete(key);
else state.selected.add(key);
}
renderFieldChanges(document.getElementById('csFieldReviewArea'));
}
function selectAllBlankFills() {
const state = _csFieldReviewState;
if (!state) return;
for (const ch of state.changes) {
if (ch.is_blank_fill) {
state.selected.add(ch.machine_id + ':' + ch.field);
}
}
renderFieldChanges(document.getElementById('csFieldReviewArea'));
}
function updateFieldCount() {
const state = _csFieldReviewState;
const countEl = document.getElementById('csFieldCount');
const btn = document.getElementById('csApplyFieldBtn');
if (!state) return;
const count = state.selected.size;
if (countEl) countEl.textContent = count;
if (btn) {
btn.disabled = count === 0;
btn.style.opacity = count === 0 ? '0.5' : '1';
}
}
async function applyFieldChanges(batchId) {
const state = _csFieldReviewState;
if (!state || state.selected.size === 0) {
showToast('No field changes selected', true);
return;
}
const changes = [];
for (const key of state.selected) {
// key is 'machineId:field'
const pipeIdx = key.indexOf(':');
if (pipeIdx > 0) {
changes.push({
machine_id: key.substring(0, pipeIdx),
field: key.substring(pipeIdx + 1),
});
}
}
const confirmed = await showModal('Apply Changes',
`Apply ${changes.length} selected field change(s) to live data? Blank-fill changes are pre-selected.`,
'Apply', 'btn-green');
if (!confirmed) return;
try {
const result = await api(`/api/admin/cantaloupe/batches/${batchId}/apply-field-changes`, {
method: 'POST',
body: JSON.stringify({ changes }),
headers: { 'Content-Type': 'application/json' },
});
showToast(result.message || 'Field changes applied');
// Close review area, refresh
const area = document.getElementById('csFieldReviewArea');
if (area) area.style.display = 'none';
_csFieldReviewOpen = false;
await loadCantaloupeSync();
} catch (e) {
showToast('Apply failed: ' + e.message, true);
}
}
// ── Replacement Resolution ──
async function resolveReplacement(batchId, newMid, oldMid, action) {
const label = action === 'approve' ? 'Approve' : 'Reject';