@@ -873,6 +961,7 @@ function switchPage(page) {
else if (page === 'users') loadUsers();
else if (page === 'customers') loadCustomers();
else if (page === 'activity') loadActivity();
+ else if (page === 'cantaloupe') loadCantaloupeSync();
}
function closeDetail() {
if (AppState._prevPage) switchPage(AppState._prevPage);
@@ -1927,6 +2016,287 @@ async function confirmResetDb() {
}
}
+// ═════════════════════════════════════════════════════════════════════════════
+// CANTALOUPE SYNC
+// ═════════════════════════════════════════════════════════════════════════════
+let csBatches = [];
+let csLatestBatch = null;
+
+async function loadCantaloupeSync() {
+ try {
+ const [batches] = await Promise.all([
+ api('/api/admin/cantaloupe/batches?limit=20'),
+ ]);
+ csBatches = batches || [];
+
+ // Find the latest batch
+ csLatestBatch = csBatches.length > 0 ? csBatches[0] : null;
+ renderCsOverview();
+ renderCsPendingReview();
+ renderCsHistory();
+ } catch (e) {
+ document.getElementById('csOverviewContent').innerHTML =
+ `
Failed to load: ${esc(e.message)}
`;
+ document.getElementById('csHistoryContent').innerHTML = '';
+ }
+}
+
+// ── Section 1: Overview ──
+function renderCsOverview() {
+ const el = document.getElementById('csOverviewContent');
+ if (!csLatestBatch) {
+ el.innerHTML = '
🔄
No sync batches yet. Click "Sync Now" to import data.
';
+ return;
+ }
+ const b = csLatestBatch;
+ const ds = b.diff_summary || {};
+ const statusTag = csStatusBadge(b.status);
+ const lastSync = formatDate(b.created_at);
+ const rowCount = b.row_count || 0;
+ const dsInfo = (ds.new_assets || ds.changed_assets || ds.removed_assets)
+ ? `
+ ${ds.new_assets && ds.new_assets.length > 0 ? `
${ds.new_assets.length}
New
` : ''}
+ ${ds.changed_assets && ds.changed_assets.length > 0 ? `
${ds.changed_assets.length}
Changed
` : ''}
+ ${ds.removed_assets && ds.removed_assets.length > 0 ? `
${ds.removed_assets.length}
Removed
` : ''}
+ ${ds.unchanged_assets && ds.unchanged_assets.length > 0 ? `
${ds.unchanged_assets.length}
Unchanged
` : ''}
+
` : '';
+ el.innerHTML = `
+
+ Batch #${b.id}
+ ${statusTag}
+ ${lastSync}
+ ${rowCount} rows
+ ${b.error_message ? `⚠ ${esc(b.error_message)}` : ''}
+
+ ${dsInfo}
+ `;
+}
+
+function csStatusBadge(status) {
+ const map = {
+ pending: '
PENDING',
+ approved: '
APPROVED',
+ rejected: '
REJECTED',
+ error: '
ERROR',
+ };
+ return map[status] || `
${esc(status)}`;
+}
+
+// ── Section 2: Pending Review ──
+function renderCsPendingReview() {
+ const card = document.getElementById('csPendingReview');
+ const el = document.getElementById('csPendingContent');
+ // Find a pending batch
+ const pending = csBatches.find(b => b.status === 'pending');
+ if (!pending) {
+ card.classList.add('hidden');
+ return;
+ }
+ card.classList.remove('hidden');
+ const ds = pending.diff_summary || {};
+ const newCount = (ds.new_assets || []).length;
+ const chgCount = (ds.changed_assets || []).length;
+ const remCount = (ds.removed_assets || []).length;
+
+ el.innerHTML = `
+
+ Batch #${pending.id}
+ · ${formatDate(pending.created_at)} · ${pending.row_count} rows
+
+
+
+
+
+
+
+ `;
+
+ // Load full detail for the pending batch
+ loadPendingBatchDetail(pending.id);
+}
+
+async function loadPendingBatchDetail(batchId) {
+ const el = document.getElementById('csPendingDetail');
+ try {
+ const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
+ renderBatchDiffDetail(el, batch);
+ } catch (e) {
+ el.innerHTML = `
Failed to load detail: ${esc(e.message)}
`;
+ }
+}
+
+function renderBatchDiffDetail(el, batch) {
+ const dr = batch.diff_rows || {};
+ const newItems = dr.new || [];
+ const removedItems = dr.removed || [];
+ const changedItems = dr.changed || [];
+
+ let html = '';
+
+ // New assets table
+ if (newItems.length > 0) {
+ html += `
+ New Assets (${newItems.length})
`;
+ html += `
| Machine ID | Name | Customer | Location |
`;
+ html += newItems.map(r => `
+ ${esc(r.machine_id)} |
+ ${esc(r.name || '—')} |
+ ${esc(r.customer || '—')} |
+ ${esc(r.location || '—')} |
+
`).join('');
+ html += `
`;
+ }
+
+ // Changed assets table
+ if (changedItems.length > 0) {
+ html += `
~ Changed Assets (${changedItems.length})
`;
+ html += `
| Machine ID | Name | Field | Old Value | | New Value |
`;
+ html += changedItems.map(r => {
+ const changes = r.changes || [];
+ const firstRow = `${esc(r.machine_id)} | ${esc(r.name || '—')} | `;
+ if (changes.length === 0) {
+ return `${firstRow}| No field-level changes |
`;
+ }
+ return changes.map((ch, i) => {
+ const row = i === 0 ? firstRow : '';
+ return `${row}
+ | ${esc(ch.field)} |
+ ${esc(String(ch.old_value ?? '—'))} |
+ → |
+ ${esc(String(ch.new_value ?? '—'))} |
+
`;
+ }).join('');
+ }).join('');
+ html += `
`;
+ }
+
+ // Removed assets table
+ if (removedItems.length > 0) {
+ html += `
− Removed Assets (${removedItems.length})
`;
+ html += `
| Machine ID | Name |
`;
+ html += removedItems.map(r => `
+ ${esc(r.machine_id)} |
+ ${esc(r.name || '—')} |
+
`).join('');
+ html += `
`;
+ }
+
+ if (newItems.length === 0 && changedItems.length === 0 && removedItems.length === 0) {
+ html = '
No changes detected.
';
+ }
+
+ el.innerHTML = html;
+}
+
+// ── Section 3: Import History ──
+function renderCsHistory() {
+ const el = document.getElementById('csHistoryContent');
+ if (csBatches.length === 0) {
+ el.innerHTML = '
';
+ return;
+ }
+ el.innerHTML = csBatches.map(b => {
+ const ds = b.diff_summary || {};
+ const newCount = (ds.new_assets || []).length;
+ const chgCount = (ds.changed_assets || []).length;
+ const remCount = (ds.removed_assets || []).length;
+ const summaryParts = [];
+ if (newCount) summaryParts.push(`+${newCount}`);
+ if (chgCount) summaryParts.push(`~${chgCount}`);
+ if (remCount) summaryParts.push(`−${remCount}`);
+ return `
+
+ ${csStatusBadge(b.status)}
+ ${formatDate(b.created_at)}
+ ${b.row_count} rows
+ ${summaryParts.join(' ')}
+ ▶
+
+
`;
+ }).join('');
+}
+
+async function toggleBatchHistory(batchId) {
+ const row = document.getElementById('csBatchRow_' + batchId);
+ const detail = document.getElementById('csBatchDetail_' + batchId);
+ if (!row || !detail) return;
+
+ const isExpanded = row.classList.contains('expanded');
+ if (isExpanded) {
+ row.classList.remove('expanded');
+ detail.classList.remove('open');
+ return;
+ }
+
+ row.classList.add('expanded');
+ detail.classList.add('open');
+
+ // Fetch detail if not already loaded
+ if (detail.dataset.loaded !== '1') {
+ try {
+ const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
+ // Render into a temporary container, then swap
+ const tmp = document.createElement('div');
+ renderBatchDiffDetail(tmp, batch);
+ detail.innerHTML = tmp.innerHTML;
+ detail.dataset.loaded = '1';
+ } catch (e) {
+ detail.innerHTML = `
Failed to load: ${esc(e.message)}
`;
+ }
+ }
+}
+
+// ── Sync Now ──
+async function runSync() {
+ const btn = document.getElementById('csSyncBtn');
+ btn.disabled = true;
+ btn.innerHTML = '
Syncing...';
+ try {
+ const result = await api('/api/admin/cantaloupe/sync', { method: 'POST' });
+ showToast('Sync completed — batch #' + result.id);
+ await loadCantaloupeSync();
+ } catch (e) {
+ showToast('Sync failed: ' + e.message, true);
+ } finally {
+ btn.disabled = false;
+ btn.innerHTML = '🔄 Sync Now';
+ }
+}
+
+// ── Approve / Reject ──
+async function approveBatch(batchId) {
+ const confirmed = await showModal('Approve Batch',
+ `Apply all ${csLatestBatch && csLatestBatch.id === batchId ? 'pending ' : ''}changes from batch #${batchId} to live data?`,
+ 'Approve', 'btn-green');
+ if (!confirmed) return;
+ try {
+ const result = await api(`/api/admin/cantaloupe/batches/${batchId}/approve`, { method: 'POST' });
+ showToast('Batch approved — ' + (result.applied_stats ? JSON.stringify(result.applied_stats) : 'done'));
+ await loadCantaloupeSync();
+ } catch (e) {
+ showToast('Approve failed: ' + e.message, true);
+ }
+}
+
+async function rejectBatch(batchId) {
+ const confirmed = await showModal('Reject Batch',
+ `Discard batch #${batchId}? This cannot be undone.`,
+ 'Reject', 'btn-danger');
+ if (!confirmed) return;
+ try {
+ await api(`/api/admin/cantaloupe/batches/${batchId}/reject`, { method: 'POST' });
+ showToast('Batch rejected');
+ await loadCantaloupeSync();
+ } catch (e) {
+ showToast('Reject failed: ' + e.message, true);
+ }
+}
+
// ═════════════════════════════════════════════════════════════════════════════
// HELPERS
// ═════════════════════════════════════════════════════════════════════════════
@@ -1937,9 +2307,16 @@ function esc(s) {
function formatDate(iso) {
if (!iso) return '—';
- const d = new Date(iso + (iso.includes('T') ? '' : 'T00:00:00'));
+ // Handle space-separated datetime (SQLite format): "2026-05-21 23:16:21"
+ let normalized = iso;
+ if (!iso.includes('T') && iso.includes(' ')) {
+ normalized = iso.replace(' ', 'T');
+ } else if (!iso.includes('T')) {
+ normalized = iso + 'T00:00:00';
+ }
+ const d = new Date(normalized);
if (isNaN(d.getTime())) return iso;
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function timeAgo(iso) {