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:
+199
-3
@@ -134,10 +134,15 @@ COLUMN_HEURISTICS = [
|
|||||||
(["last dex report time", "dex report time", "last dex", "dex report", "dex time"], "dex_report_date"),
|
(["last dex report time", "dex report time", "last dex", "dex report", "dex time"], "dex_report_date"),
|
||||||
(["deployed"], "deployed"),
|
(["deployed"], "deployed"),
|
||||||
(["pulled date", "pulled", "removal date"], "pulled_date"),
|
(["pulled date", "pulled", "removal date"], "pulled_date"),
|
||||||
|
# Cantaloupe Machine List export overrides (before generic single-word)
|
||||||
|
(["place"], "name"), # Place → name (breakroom/site name)
|
||||||
|
(["state"], "_state"), # State → prefixed, NOT overwrite status
|
||||||
|
(["class"], "category"), # Class → category (cleaner than Type values)
|
||||||
|
(["type", "asset type", "equipment type"], "_type"), # Type → prefixed (dirty values)
|
||||||
# Single-word/generic heuristics (last, after specific multi-word ones)
|
# Single-word/generic heuristics (last, after specific multi-word ones)
|
||||||
(["serial", "s/n"], "serial_number"),
|
(["serial", "s/n"], "serial_number"),
|
||||||
(["category", "type", "asset type", "equipment type", "class"], "category"),
|
(["category"], "category"),
|
||||||
(["status", "state", "condition"], "status"),
|
(["status", "condition"], "status"), # removed 'state' to prevent col 33 override
|
||||||
(["make", "manufacturer", "brand", "vendor"], "make"),
|
(["make", "manufacturer", "brand", "vendor"], "make"),
|
||||||
(["address", "street"], "address"),
|
(["address", "street"], "address"),
|
||||||
(["floor", "level", "story"], "floor"),
|
(["floor", "level", "story"], "floor"),
|
||||||
@@ -240,7 +245,11 @@ def parse_excel(file_path: str) -> tuple[list[str], list[dict]]:
|
|||||||
if i < len(headers):
|
if i < len(headers):
|
||||||
# Convert to native Python types
|
# Convert to native Python types
|
||||||
if isinstance(val, datetime):
|
if isinstance(val, datetime):
|
||||||
val = val.isoformat()
|
# Use space-separated format so SQLite comparison works
|
||||||
|
# datetime('now') outputs '2026-05-18 22:04:00' (space)
|
||||||
|
# .isoformat() outputs '2026-05-18T22:04:00' (T) which
|
||||||
|
# breaks lexicographic comparison ('T' > ' ' in ASCII)
|
||||||
|
val = val.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
elif val is not None and not isinstance(val, (str, int, float, bool)):
|
elif val is not None and not isinstance(val, (str, int, float, bool)):
|
||||||
val = str(val)
|
val = str(val)
|
||||||
row_dict[headers[i]] = val if val is not None else ""
|
row_dict[headers[i]] = val if val is not None else ""
|
||||||
@@ -1195,3 +1204,190 @@ def resolve_replacements(batch_id: int, body: ReplacementActionRequest):
|
|||||||
raise HTTPException(status_code=500, detail=f"Resolve replacements failed: {e}")
|
raise HTTPException(status_code=500, detail=f"Resolve replacements failed: {e}")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Per-field granular approval ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FieldChangeItem(BaseModel):
|
||||||
|
machine_id: str
|
||||||
|
field: str
|
||||||
|
|
||||||
|
|
||||||
|
class PerFieldApprovalRequest(BaseModel):
|
||||||
|
changes: list[FieldChangeItem]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches/{batch_id}/field-changes")
|
||||||
|
def get_field_changes(batch_id: int):
|
||||||
|
"""Return flat list of per-field changes for UI rendering.
|
||||||
|
|
||||||
|
Each entry: {machine_id, name, field, old_value, new_value, is_blank_fill}
|
||||||
|
"""
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT diff_summary FROM cantaloupe_sync_batches WHERE id = ?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Batch not found")
|
||||||
|
|
||||||
|
diff = row["diff_summary"]
|
||||||
|
if isinstance(diff, str):
|
||||||
|
try:
|
||||||
|
diff = _json.loads(diff)
|
||||||
|
except _json.JSONDecodeError:
|
||||||
|
diff = {}
|
||||||
|
if not isinstance(diff, dict):
|
||||||
|
diff = {}
|
||||||
|
|
||||||
|
changed = diff.get("changed_assets", [])
|
||||||
|
result = []
|
||||||
|
for asset in changed:
|
||||||
|
mid = asset.get("machine_id", "")
|
||||||
|
name = asset.get("name", "")
|
||||||
|
for ch in asset.get("changes", []):
|
||||||
|
old_val = ch.get("old_value")
|
||||||
|
new_val = ch.get("new_value")
|
||||||
|
is_blank = old_val is None or str(old_val).strip() == ""
|
||||||
|
result.append({
|
||||||
|
"machine_id": mid,
|
||||||
|
"name": name,
|
||||||
|
"field": ch.get("field", ""),
|
||||||
|
"old_value": old_val,
|
||||||
|
"new_value": new_val,
|
||||||
|
"is_blank_fill": is_blank,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Also add new assets (field-level doesn't apply to inserts)
|
||||||
|
new_assets = diff.get("new_assets", [])
|
||||||
|
replacements = diff.get("replacements", [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"field_changes": result,
|
||||||
|
"new_asset_count": len(new_assets),
|
||||||
|
"replacement_count": len(replacements),
|
||||||
|
"import_count": diff.get("import_count", 0),
|
||||||
|
"live_asset_count": diff.get("live_asset_count", 0),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batches/{batch_id}/apply-field-changes")
|
||||||
|
def apply_field_changes(batch_id: int, body: PerFieldApprovalRequest, request: Request):
|
||||||
|
"""Apply only the selected field changes to existing assets.
|
||||||
|
|
||||||
|
New assets and replacements are NOT handled by this endpoint — use the
|
||||||
|
existing approve/replacement endpoints for those.
|
||||||
|
|
||||||
|
Body: {changes: [{machine_id: str, field: str}, ...]}
|
||||||
|
Only fields listed are applied (old_value→new_value for existing assets).
|
||||||
|
"""
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM cantaloupe_sync_batches WHERE id = ?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Batch not found")
|
||||||
|
if row["status"] != "pending":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Cannot apply changes in status '{row['status']}'. Only 'pending' batches can be modified.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse diff and raw data
|
||||||
|
diff = row["diff_summary"]
|
||||||
|
if isinstance(diff, str):
|
||||||
|
try:
|
||||||
|
diff = _json.loads(diff)
|
||||||
|
except _json.JSONDecodeError:
|
||||||
|
diff = {}
|
||||||
|
if not isinstance(diff, dict):
|
||||||
|
diff = {}
|
||||||
|
|
||||||
|
raw_data = row["raw_data"]
|
||||||
|
if isinstance(raw_data, str):
|
||||||
|
raw_data = _json.loads(raw_data)
|
||||||
|
if not raw_data:
|
||||||
|
raise HTTPException(status_code=400, detail="Batch has no raw data")
|
||||||
|
|
||||||
|
# Index imported rows by machine_id
|
||||||
|
imported_by_mid: dict[str, dict] = {}
|
||||||
|
for item in raw_data:
|
||||||
|
mid = str(item.get("machine_id", "")).strip()
|
||||||
|
if mid:
|
||||||
|
imported_by_mid[mid] = item
|
||||||
|
|
||||||
|
stats = {"assets_updated": 0, "fields_applied": 0}
|
||||||
|
|
||||||
|
# Group approved changes by machine_id
|
||||||
|
approved_by_mid: dict[str, set[str]] = {}
|
||||||
|
for change in body.changes:
|
||||||
|
mid = change.machine_id.strip()
|
||||||
|
if mid not in approved_by_mid:
|
||||||
|
approved_by_mid[mid] = set()
|
||||||
|
approved_by_mid[mid].add(change.field)
|
||||||
|
|
||||||
|
for mid, approved_fields in approved_by_mid.items():
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM assets WHERE machine_id = ?",
|
||||||
|
(mid,),
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
continue # skip new assets (handled by approve endpoint)
|
||||||
|
|
||||||
|
imported = imported_by_mid.get(mid)
|
||||||
|
if not imported:
|
||||||
|
continue
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
for field in approved_fields:
|
||||||
|
val = imported.get(field)
|
||||||
|
if val is not None and str(val).strip() != "":
|
||||||
|
updates[field] = str(val).strip()
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
updates["updated_at"] = "datetime('now')"
|
||||||
|
set_clause = ", ".join(
|
||||||
|
f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
|
||||||
|
for k, v in updates.items()
|
||||||
|
)
|
||||||
|
values = [v for k, v in updates.items() if k != "updated_at"]
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE assets SET {set_clause} WHERE id = ?",
|
||||||
|
values + [existing["id"]],
|
||||||
|
)
|
||||||
|
stats["assets_updated"] += 1
|
||||||
|
stats["fields_applied"] += len(approved_fields)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO activity_log (user_id, action, entity_type, entity_id, details) "
|
||||||
|
"VALUES (?, 'apply_field_changes', 'cantaloupe_batch', ?, ?)",
|
||||||
|
(user_id, batch_id, _json.dumps(stats)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"applied_stats": stats,
|
||||||
|
"message": f"Applied {stats['fields_applied']} field change(s) across {stats['assets_updated']} asset(s).",
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Apply field changes failed: {e}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
+205
-2
@@ -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 class="cs-diff-badge rem"><div class="csd-val">${remCount}</div><div class="csd-label">Removed</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="csPendingDetail"></div>
|
<div id="csPendingDetail"></div>
|
||||||
<div class="cs-actions">
|
<div id="csFieldReviewArea" style="margin-top:12px;display:none;"></div>
|
||||||
<button class="btn btn-green" onclick="approveBatch(${pending.id})">✓ Approve</button>
|
<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>
|
<button class="btn btn-danger" onclick="rejectBatch(${pending.id})">✕ Reject</button>
|
||||||
</div>
|
</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 ──
|
// ── Replacement Resolution ──
|
||||||
async function resolveReplacement(batchId, newMid, oldMid, action) {
|
async function resolveReplacement(batchId, newMid, oldMid, action) {
|
||||||
const label = action === 'approve' ? 'Approve' : 'Reject';
|
const label = action === 'approve' ? 'Approve' : 'Reject';
|
||||||
|
|||||||
Reference in New Issue
Block a user