From 510bafc91961e4947c24898986e98910eae74e85 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 25 May 2026 23:10:40 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20multi-role=20support=20=E2=80=94=20comm?= =?UTF-8?q?a-separated=20roles=20with=20checkbox=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added validate_roles() and roles_contain() helpers for comma-separated roles - Updated create_user/update_user to accept role combinations (e.g. 'admin,technician') - Changed all exact role queries (='technician', IN('technician','admin')) to LIKE - Replaced single + + + + + +
+
Loading assets...
+
+
+ + @@ -1085,11 +1238,13 @@ function switchPage(page) { document.getElementById('sidebarOverlay')?.classList.remove('open'); // Lazy load if (page === 'dashboard') loadDashboard(); + else if (page === 'assets') loadAssets(); else if (page === 'settings') renderAllSettings(); else if (page === 'users') loadUsers(); else if (page === 'customers') loadCustomers(); else if (page === 'activity') loadActivity(); else if (page === 'cantaloupe') loadCantaloupeSync(); + else if (page === 'exifscanner') loadExifScanner(); } function closeDetail() { if (AppState._prevPage) switchPage(AppState._prevPage); @@ -1256,7 +1411,437 @@ function renderDashActivity(items) { } // ═════════════════════════════════════════════════════════════════════════════ -// SETTINGS — All Entities CRUD +// ASSETS — Sortable Table View with Inline Editing +// ═════════════════════════════════════════════════════════════════════════════ + +let assetSortField = 'name'; +let assetSortDir = 'asc'; +let cachedAssets = []; +let assetSettingsCache = {}; + +const ASSET_DROPDOWN_FIELDS = { + category: { api: '/api/settings/categories', labelKey: 'name', valueKey: 'name' }, + status: { static: ['active', 'maintenance', 'retired'] }, + make: { api: '/api/settings/makes', labelKey: 'name', valueKey: 'name' }, + model: { api: '/api/settings/models', labelKey: 'name', valueKey: 'name' }, +}; + +const ASSET_DATE_FIELDS = ['install_date', 'dex_report_date', 'pulled_date']; +const ASSET_CHECK_FIELDS = ['deployed']; +const ASSET_TEXT_FIELDS = ['serial_number', 'name', 'company', 'address', 'building_name', 'building_number', 'floor', 'room', 'place', 'location_area']; + +async function loadAssetSettings() { + if (assetSettingsCache._loaded) return; + try { + const [catRes, makeRes] = await Promise.all([ + api('/api/settings/categories'), + api('/api/settings/makes'), + ]); + assetSettingsCache = { + categories: catRes || [], + makes: makeRes || [], + _loaded: true, + }; + } catch (e) { + console.warn('Failed to load asset settings:', e); + assetSettingsCache = { categories: [], makes: [], _loaded: true }; + } +} + +async function loadAssets() { + const el = document.getElementById('assetsContent'); + const search = document.getElementById('assetSearch')?.value?.trim() || ''; + const status = document.getElementById('assetStatusFilter')?.value || ''; + + await loadAssetSettings(); + + let url = '/api/assets?limit=5000'; + if (search) url += '&q=' + encodeURIComponent(search); + if (status) url += '&status=' + encodeURIComponent(status); + + try { + const resp = await api(url); + const data = typeof resp === 'object' && resp.assets ? resp : { assets: resp, total: resp?.length || 0 }; + cachedAssets = data.assets || []; + document.getElementById('assetBadgeNav').textContent = data.total || cachedAssets.length; + renderAssetTable(); + } catch (e) { + el.innerHTML = `
⚠️
Failed to load assets: ${esc(e.message)}
`; + } +} + +function toggleAssetSort(field) { + if (assetSortField === field) assetSortDir = assetSortDir === 'asc' ? 'desc' : 'asc'; + else { assetSortField = field; assetSortDir = 'asc'; } + renderAssetTable(); +} + +// ── Inline Editing ── + +function startEdit(assetId, field, currentVal) { + const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="${field}"]`); + if (!cell) return; + if (cell.classList.contains('editing')) return; + cell.classList.add('editing'); + + const safeVal = currentVal == null ? '' : String(currentVal); + + if (field === 'deployed') { + cell.innerHTML = ``; + const sel = cell.querySelector('select'); + sel.focus(); + sel.onchange = () => saveCell(assetId, field, sel.value); + sel.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200); + return; + } + + if (ASSET_DATE_FIELDS.includes(field)) { + const dateVal = safeVal.substring(0, 10); + cell.innerHTML = ``; + const inp = cell.querySelector('input'); + inp.focus(); + inp.onchange = () => saveCell(assetId, field, inp.value); + inp.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200); + inp.onkeydown = (e) => { if (e.key === 'Escape') renderAssetTable(); }; + return; + } + + // Dropdown fields + if (ASSET_DROPDOWN_FIELDS[field]) { + const cfg = ASSET_DROPDOWN_FIELDS[field]; + let options = []; + if (cfg.static) { + options = cfg.static; + } else if (cfg.api) { + const list = cfg.api.includes('categories') ? assetSettingsCache.categories : assetSettingsCache.makes; + options = list.map(item => item[cfg.valueKey]); + } + cell.innerHTML = ``; + const sel = cell.querySelector('select'); + sel.focus(); + sel.onchange = () => saveCell(assetId, field, sel.value); + sel.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200); + sel.onkeydown = (e) => { if (e.key === 'Escape') renderAssetTable(); }; + return; + } + + // Text fields + cell.innerHTML = ``; + const inp = cell.querySelector('input'); + inp.focus(); + inp.select(); + inp.onchange = () => saveCell(assetId, field, inp.value); + inp.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200); + inp.onkeydown = (e) => { + if (e.key === 'Enter') { inp.blur(); } + if (e.key === 'Escape') renderAssetTable(); + }; +} + +async function saveCell(assetId, field, value) { + const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="${field}"]`); + if (!cell) return; + + // Optimistic update + const asset = cachedAssets.find(a => a.id === assetId); + if (asset) { + if (field === 'deployed') { + asset[field] = value === '1' ? 1 : 0; + } else { + asset[field] = value; + } + } + + cell.classList.remove('editing'); + renderAssetTable(); + showToast(`Saving ${field}...`); + + try { + const payload = {}; + payload[field] = value; + const updated = await api(`/api/assets/${assetId}`, { + method: 'PUT', + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }); + if (asset && updated) Object.assign(asset, updated); + showToast(`✓ ${field} saved`); + } catch (e) { + showToast(`✗ Failed to save ${field}: ${e.message}`, true); + if (asset) renderAssetTable(); + } +} + +function renderAssetCell(asset, field) { + const val = asset[field]; + const isEmpty = val === null || val === undefined || val === ''; + const emptyCls = isEmpty ? 'ae-empty' : ''; + + if (field === 'deployed') { + return `${val ? '✓' : '✗'}`; + } + if (field === 'status') { + const statusClass = val === 'active' ? 'status-active' : val === 'maintenance' ? 'status-maintenance' : 'status-retired'; + return `${esc(val || '—')}`; + } + if (field === 'address') { + return `${esc((val || '').substring(0, 22))}`; + } + if (field === 'building_name') { + const parts = [asset.building_name, asset.building_number].filter(Boolean); + return `${esc(parts.join(' ') || '—')}`; + } + if (field === 'room') { + return `${esc(asset.room || asset.place || '—')}`; + } + const display = val == null || val === '' ? '—' : String(val).substring(0, 25); + return `${esc(display)}`; +} + +function renderAssetTable() { + const el = document.getElementById('assetsContent'); + + const sorted = [...cachedAssets].sort((a, b) => { + let va = (a[assetSortField] || '').toString().toLowerCase(); + let vb = (b[assetSortField] || '').toString().toLowerCase(); + if (assetSortField === 'machine_id' || assetSortField === 'id') { + va = Number(a[assetSortField]) || 0; + vb = Number(b[assetSortField]) || 0; + return assetSortDir === 'asc' ? va - vb : vb - va; + } + if (va < vb) return assetSortDir === 'asc' ? -1 : 1; + if (va > vb) return assetSortDir === 'asc' ? 1 : -1; + return 0; + }); + + if (sorted.length === 0) { + el.innerHTML = '
📦
No assets found
'; + document.getElementById('assetPagination').innerHTML = ''; + return; + } + + const sortArrow = (f) => assetSortField === f ? (assetSortDir === 'asc' ? ' ▲' : ' ▼') : ''; + + const INLINE_FIELDS = [ + 'machine_id', 'serial_number', 'name', 'company', 'category', 'status', + 'make', 'model', 'address', 'building_name', 'room', 'floor', 'location_area', + 'install_date', 'dex_report_date', 'deployed', 'pulled_date', + ]; + + const HEADER_LABELS = { + machine_id: 'ID', serial_number: 'Serial', name: 'Name', company: 'Company', + category: 'Cat', status: 'Status', make: 'Make', model: 'Model', + address: 'Address', building_name: 'Bldg', room: 'Room/Place', floor: 'Floor', + location_area: 'Area', install_date: 'Install', dex_report_date: 'DEX Date', + deployed: 'Dep', pulled_date: 'Pulled', + }; + + const rows = sorted.map(a => { + return ` + ${INLINE_FIELDS.map(f => { + let cls = 'text-xs ae-cell'; + if (f === 'name') cls += ' ae-name'; + if (f === 'machine_id') cls += ' ae-id'; + const val = a[f]; + const isEmpty = val === null || val === undefined || val === ''; + if (isEmpty) cls += ' ae-empty-cell'; + return `${renderAssetCell(a, f)}`; + }).join('')} + + 🔍 + + `; + }).join(''); + + el.innerHTML = `
+ 💡 Click any cell to edit + = empty field +
+
+ + + + ${INLINE_FIELDS.map(f => ``).join('')} + + + ${rows} +
${HEADER_LABELS[f]}${sortArrow(f)}
+
`; + + document.getElementById('assetPagination').innerHTML = `${sorted.length} asset${sorted.length !== 1 ? 's' : ''} · click cells to edit, Enter to save, Esc to cancel`; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// BUSINESS NAME LOOKUP (Google Maps) +// ═════════════════════════════════════════════════════════════════════════════ + +async function lookupBusinessName(assetId) { + const asset = cachedAssets.find(a => a.id === assetId); + if (!asset) return; + const addr = asset.address?.trim(); + if (!addr) { showToast('No address to look up for this asset', true); return; } + + showToast(`🔍 Looking up "${addr}"...`); + try { + const result = await api('/api/lookup-business', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: addr }), + }); + const name = result.business_name; + if (!name) { + showToast('No business name found at this address', true); + return; + } + showToast(`Found: "${name}" — Click "Apply" to save`, false, 8000); + // Show a subtle inline prompt to apply + const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="company"]`); + if (cell) { + cell.innerHTML += ` [Apply: ${esc(name)}]`; + } + } catch (e) { + showToast(`✗ Lookup failed: ${e.message}`, true); + } +} + +async function applyBusinessName(assetId, name) { + const asset = cachedAssets.find(a => a.id === assetId); + if (!asset) return; + // Save to company field + try { + const updated = await api(`/api/assets/${assetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company: name }), + }); + if (asset) asset.company = name; + showToast(`✓ Company set to "${name}"`); + renderAssetTable(); + } catch (e) { + showToast(`✗ Failed to save: ${e.message}`, true); + } +} + +async function batchLookupAll() { + const withAddr = cachedAssets.filter(a => a.address?.trim() && !a.company?.trim()); + if (withAddr.length === 0) { + const anyWithAddr = cachedAssets.filter(a => a.address?.trim()); + if (anyWithAddr.length === 0) { showToast('No assets with addresses found', true); return; } + showToast('All assets already have company names set', true); + return; + } + + const batchSize = Math.min(withAddr.length, 50); + showToast(`🔍 Looking up ${batchSize} address${batchSize > 1 ? 'es' : ''} (showing first 50)...`, false, 10000); + + const el = document.getElementById('assetsContent'); + + // Add results panel + let resultsPanel = document.getElementById('lookupResultsPanel'); + if (!resultsPanel) { + resultsPanel = document.createElement('div'); + resultsPanel.id = 'lookupResultsPanel'; + resultsPanel.className = 'card'; + resultsPanel.style.marginTop = '12px'; + el.parentNode.insertBefore(resultsPanel, el.nextSibling); + } + resultsPanel.innerHTML = `
🔍 Business Name Lookup Results
+
Looking up ${batchSize} addresses...
`; + + // Batch call — only pass assets without company names, limited to 50 + try { + const assets = withAddr.slice(0, 50).map(a => ({ id: a.id, address: a.address })); + const resp = await api('/api/lookup-business/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assets }), + }); + const results = resp.results || []; + + const found = results.filter(r => r.business_name); + const notFound = results.filter(r => !r.business_name); + + resultsPanel.innerHTML = `
🔍 Business Name Lookup Results
+
+ ✓ ${found.length} found + ✗ ${notFound.length} not found + · ${results.length} total looked up +
+ ${found.length > 0 ? ` +
+ + + + + + + + + ${found.map(r => { + const asset = cachedAssets.find(a => a.id === r.id); + const currentCompany = asset?.company || ''; + const needsUpdate = currentCompany !== r.business_name; + return ` + + + + + `; + }).join('')} + +
Business NameAddressCurrent Company
${esc(r.business_name)}${esc(r.address)}${esc(currentCompany || '—empty—')}${needsUpdate ? `` : '✅'}
+
+ ` : ''} + ${notFound.length > 0 ? ` +
+ + ${notFound.length} address${notFound.length > 1 ? 'es' : ''} with no result + +
+ ${notFound.map(r => { + const asset = cachedAssets.find(a => a.id === r.id); + return `
+ ${asset?.name || 'ID ' + r.id}: ${esc(r.address)} +
`; + }).join('')} +
+
+ ` : ''} +
+ + +
`; + showToast(`✓ Lookup complete: ${found.length} found, ${notFound.length} not found`); + } catch (e) { + showToast(`✗ Batch lookup failed: ${e.message}`, true); + resultsPanel.innerHTML += `
Error: ${esc(e.message)}
`; + } +} + +async function applyAllFoundNames() { + const panel = document.getElementById('lookupResultsPanel'); + if (!panel) return; + // Parse the results table + const rows = panel.querySelectorAll('table tbody tr'); + let count = 0; + for (const row of rows) { + const applyBtn = row.querySelector('.btn-green'); + if (applyBtn) applyBtn.click(); + count++; + // Small delay to not overwhelm + if (count % 5 === 0) await new Promise(r => setTimeout(r, 100)); + } + showToast(`Applied ${count} names`); +} // ═════════════════════════════════════════════════════════════════════════════ async function loadSettingsCache() { try { @@ -1510,6 +2095,12 @@ async function loadUsers() { } } +function renderRoleBadges(roleStr) { + if (!roleStr) return 'technician'; + const roles = roleStr.split(',').map(r => r.trim()).filter(Boolean); + return roles.map(r => `${r}`).join(' '); +} + function renderUsers() { const el = document.getElementById('usersContent'); if (cachedUsers.length === 0) { @@ -1527,7 +2118,7 @@ function renderUsers() { ${cachedUsers.map(u => ` ${u.id} ${esc(u.username)} - ${u.role || 'technician'} + ${renderRoleBadges(u.role)} ${formatDate(u.created_at)} @@ -1550,12 +2141,12 @@ function showAddUserForm() {
- - + +
+ + + +
`; document.getElementById('modalActions').innerHTML = ` @@ -1565,10 +2156,14 @@ function showAddUserForm() { document.getElementById('modalOverlay').classList.add('open'); } +function getSelectedRoles() { + return Array.from(document.querySelectorAll('.role-cb:checked')).map(cb => cb.value).join(','); +} + async function saveAddUser() { const username = document.getElementById('modalFormUser').value.trim(); const password = document.getElementById('modalFormPass').value; - const role = document.getElementById('modalFormRole').value; + const role = getSelectedRoles(); if (!username || !password) { showToast('Username and password required', true); return; } try { await api('/api/users', { @@ -1588,12 +2183,12 @@ function showEditUserForm(userId) { document.getElementById('modalTitle').textContent = `Edit User: ${esc(user.username)}`; document.getElementById('modalBody').innerHTML = `
- - + +
+ + + +
@@ -1608,7 +2203,7 @@ function showEditUserForm(userId) { } async function saveEditUser(userId) { - const role = document.getElementById('modalFormRole').value; + const role = getSelectedRoles(); const password = document.getElementById('modalFormPass').value; const body = { role }; if (password) body.password = password; @@ -2895,6 +3490,538 @@ document.addEventListener('DOMContentLoaded', () => { if (e.key === 'Enter') document.getElementById('loginPass').focus(); }); }); + +// ═════════════════════════════════════════════════════════════════════════════ +// EXIF SCANNER — State +// ═════════════════════════════════════════════════════════════════════════════ +let exifAllPhotos = []; // {file, exif, hasGps, lat, lng, thumb} +let exifSelectedIdx = -1; +let exifBulkData = []; +let exifMap = null; +let exifLastPhotoId = null; +let exifLastPhotoGps = null; + +function esc(s) { + const d = document.createElement('div'); + d.textContent = String(s); + return d.innerHTML; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// EXIF SCANNER — Page Init +// ═════════════════════════════════════════════════════════════════════════════ +async function loadExifScanner() { + exifAllPhotos = []; + exifSelectedIdx = -1; + exifBulkData = []; + if (exifMap) { exifMap.remove(); exifMap = null; } + document.getElementById('exifGallery').innerHTML = ''; + document.getElementById('exifBulkResults').innerHTML = ''; + document.getElementById('exifDetail').style.display = 'none'; + document.getElementById('exifBulkSection').style.display = 'none'; + document.getElementById('exifSessionsSection').style.display = 'none'; + document.getElementById('exifPrevSection').style.display = 'none'; + document.getElementById('exifOcrOptions').style.display = 'none'; + document.getElementById('exifSummary').style.display = 'none'; + // Load sessions and previous photos in parallel + await Promise.all([ + loadExifSessions(), + loadExifPrevPhotos(), + ]); +} + +async function loadExifSessions() { + try { + const data = await api('/api/admin/exif/sessions'); + const sessions = data.sessions || []; + const list = document.getElementById('exifSessionsList'); + const section = document.getElementById('exifSessionsSection'); + if (sessions.length === 0) { + section.style.display = 'none'; + return; + } + section.style.display = 'block'; + document.getElementById('exifSessionCount').textContent = sessions.length; + list.innerHTML = sessions.map(s => { + const closed = s.closed_at ? `🔒 Closed ${new Date(s.closed_at).toLocaleDateString()}` : '🟢 Open'; + return `
+
+
${esc(s.name || 'Unnamed')}
+
${s.building || ''}${s.floor ? ' • Floor ' + s.floor : ''} — ${s.photo_count || 0} photos, ${s.matched_count || 0} matched
+
+ ${closed} +
`; + }).join(''); + } catch (e) { + /* session section stays hidden */ + } +} + +async function loadExifPrevPhotos() { + try { + const data = await api('/api/admin/exif/photos?limit=30'); + const photos = data.photos || []; + const section = document.getElementById('exifPrevSection'); + const div = document.getElementById('exifPrevResults'); + if (!photos.length) { + section.style.display = 'none'; + return; + } + section.style.display = 'block'; + document.getElementById('exifPrevCount').textContent = photos.length; + div.innerHTML = photos.map((p, i) => renderExifPrevCard(p, i)).join(''); + } catch (e) { + const section = document.getElementById('exifPrevSection'); + section.style.display = 'none'; + } +} + +function renderExifPrevCard(p, idx) { + const hasGps = p.gps_lat && p.gps_lng; + const matched = p.machine_id ? 'matched' : 'no-match'; + const gpsBadge = hasGps + ? '📍 GPS' + : '📍 No GPS'; + const matchBadge = matched === 'matched' + ? `✓ ${esc(p.machine_id)}` + : '⏳ No match'; + const uploaderBadge = p.uploaded_by + ? `👤 ${esc(p.uploaded_by)}` + : ''; + const thumb = p.id ? `/api/admin/exif/photos/${p.id}/file` : ''; + return `
\n
\n ${thumb ? `` : ''}\n
\n
${esc(p.orig_filename || 'photo.jpg')}
\n
\n ${gpsBadge} ${matchBadge} ${uploaderBadge} +
\n
${p.created_at ? new Date(p.created_at).toLocaleString() : ''}
\n
\n
\n
`; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// EXIF SCANNER — File handling +// ═════════════════════════════════════════════════════════════════════════════ +function handleExifFiles(files) { + if (!files || !files.length) return; + exifAllPhotos = []; + exifSelectedIdx = -1; + exifBulkData = []; + if (exifMap) { exifMap.remove(); exifMap = null; } + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const reader = new FileReader(); + reader.onload = (e) => { + // Check EXIF in browser first + let hasGps = false, lat = null, lng = null; + if (typeof exifr !== 'undefined' && exifr.parse) { + exifr.parse(file).then(exif => { + hasGps = !!(exif && exif.latitude && exif.longitude); + lat = hasGps ? exif.latitude : null; + lng = hasGps ? exif.longitude : null; + exifAllPhotos.push({ file, hasGps, lat, lng, thumb: e.target.result }); + if (exifAllPhotos.length === files.length) { + renderExifGallery(); + showExifOcrOptions(); + } + }).catch(() => { + exifAllPhotos.push({ file, hasGps: false, lat: null, lng: null, thumb: e.target.result }); + if (exifAllPhotos.length === files.length) { + renderExifGallery(); + showExifOcrOptions(); + } + }); + } else { + exifAllPhotos.push({ file, hasGps: false, lat: null, lng: null, thumb: e.target.result }); + if (exifAllPhotos.length === files.length) { + renderExifGallery(); + showExifOcrOptions(); + } + } + }; + reader.readAsDataURL(file); + } +} + +function renderExifGallery() { + const gallery = document.getElementById('exifGallery'); + const total = exifAllPhotos.length; + const withGps = exifAllPhotos.filter(p => p.hasGps).length; + const noGps = total - withGps; + gallery.innerHTML = exifAllPhotos.map((p, i) => ` +
+ +
📍 ${p.hasGps ? 'GPS' : 'No GPS'}
+
+ `).join(''); + gallery.style.display = 'grid'; + + // Update summary + document.getElementById('exifSummary').style.display = 'block'; + document.getElementById('exifSumGps').textContent = withGps; + document.getElementById('exifSumNoGps').textContent = noGps; + document.getElementById('exifSumTotal').textContent = total; + document.getElementById('exifSumMatched').textContent = 0; +} + +function showExifOcrOptions() { + const total = exifAllPhotos.length; + document.getElementById('exifOcrOptions').style.display = 'block'; + document.getElementById('exifBulkBtn').style.display = total > 0 ? 'inline-flex' : 'none'; + document.getElementById('exifFileCount').textContent = total; +} + +function onExifOcrToggle() { + const engine = document.getElementById('exifOcrEngine').value; + const sticker = document.getElementById('exifStickerMode').checked; + const label = document.getElementById('exifModelLabel'); + if (engine === 'llm' && sticker) label.textContent = '🏷️ sticker mode'; + else if (engine === 'llm') label.textContent = 'mimo-v2-omni'; + else if (engine === 'google' && sticker) label.textContent = '🏷️ gemini sticker'; + else if (engine === 'google') label.textContent = 'gemini-2.5-flash'; + else label.textContent = 'tesseract'; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// EXIF SCANNER — Bulk Process +// ═════════════════════════════════════════════════════════════════════════════ +async function startExifBulkProcess() { + const btn = document.getElementById('exifBulkBtn'); + btn.disabled = true; + btn.textContent = '⏳ Processing...'; + try { + const formData = new FormData(); + for (const p of exifAllPhotos) { + formData.append('files', p.file); + } + const engine = document.getElementById('exifOcrEngine').value; + const sticker = document.getElementById('exifStickerMode').checked; + let url = `/api/admin/exif/bulk-process?ocr_engine=${engine}`; + if (sticker) url += '&sticker_mode=true'; + const resp = await fetch(url, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + AppState.authToken }, + body: formData, + }); + if (!resp.ok) { + const err = await resp.text(); + throw new Error(err); + } + const data = await resp.json(); + exifBulkData = data.results || []; + renderExifBulkResults(); + } catch (e) { + document.getElementById('exifBulkResults').innerHTML = + '
❌ Bulk process failed: ' + esc(e.message) + '
'; + } finally { + btn.disabled = false; + btn.textContent = '🔍 Bulk Process (' + exifAllPhotos.length + ')'; + } +} + +function renderExifBulkResults() { + const section = document.getElementById('exifBulkSection'); + section.style.display = 'block'; + const div = document.getElementById('exifBulkResults'); + const items = exifBulkData; + const hasGps = items.filter(i => i.exif && i.exif.gps).length; + const matched = items.filter(i => i.asset).length; + const needsGps = items.filter(i => i.needs_gps).length; + const total = items.length; + document.getElementById('exifBulkSummary').textContent = `📍${hasGps} ✓${matched} 📤${needsGps} 📷${total}`; + div.innerHTML = items.map((item, idx) => renderExifBulkCard(item, idx)).join(''); + // Init map + initExifMap(items); + // Show batch bar if there are needs_gps items + const hasNeedsGps = items.some(i => i.needs_gps); + if (hasNeedsGps || items.length > 0) { + const bar = document.getElementById('exifBatchBar'); + bar.style.display = 'block'; + bar.style.marginTop = '8px'; + } +} + +function renderExifBulkCard(item, idx) { + const gps = item.exif && item.exif.gps; + const asset = item.asset; + const matchBadge = asset + ? `✓ ${esc(asset.name || asset.machine_id)}` + : (item.machine_id + ? `🔍 ${esc(item.machine_id)}` + : '⏳ No match'); + const gpsBadge = gps + ? `📍 ${gps.lat.toFixed(5)}, ${gps.lng.toFixed(5)}` + : '📍 No GPS'; + const dupBadge = item.duplicate + ? '🔁 Duplicate' + : ''; + return `
+
${esc(item.filename)}
+
+ ${gpsBadge} ${matchBadge} ${dupBadge} +
+ ${item.needs_gps ? `
+ +
` : ''} +
`; +} + +function initExifMap(items) { + const container = document.getElementById('exifMapContainer'); + const points = items.filter(i => i.exif && i.exif.gps); + if (!points.length) { container.style.display = 'none'; return; } + container.style.display = 'block'; + + // Load Leaflet dynamically if not loaded + if (typeof L === 'undefined') { + const script = document.createElement('script'); + script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; + script.onload = () => initExifMapInner(points); + document.head.appendChild(script); + return; + } + initExifMapInner(points); +} + +function initExifMapInner(points) { + if (exifMap) exifMap.remove(); + const container = document.getElementById('exifMapContainer'); + const center = { lat: points[0].exif.gps.lat, lng: points[0].exif.gps.lng }; + exifMap = L.map(container).setView([center.lat, center.lng], 16); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 19, + }).addTo(exifMap); + const bounds = []; + points.forEach((item, idx) => { + const gps = item.exif.gps; + const color = item.asset ? '#4ade80' : '#5b6ef7'; + const marker = L.circleMarker([gps.lat, gps.lng], { + radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.8, + }).addTo(exifMap); + marker.bindPopup(`${esc(item.filename)}
📍 ${gps.lat.toFixed(5)}, ${gps.lng.toFixed(5)}${item.asset ? '
✓ ' + esc(item.asset.name) : ''}`); + bounds.push([gps.lat, gps.lng]); + }); + if (bounds.length > 1) exifMap.fitBounds(bounds, { padding: [30, 30] }); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// EXIF SCANNER — Detail View +// ═════════════════════════════════════════════════════════════════════════════ +function showExifDetail(idx) { + exifSelectedIdx = idx; + renderExifGallery(); + const photo = exifAllPhotos[idx]; + const detail = document.getElementById('exifDetail'); + const preview = document.getElementById('exifDetailPreview'); + const content = document.getElementById('exifDetailContent'); + detail.style.display = 'block'; + preview.src = photo.thumb; + let html = ''; + if (photo.lat && photo.lng) { + html += `
+ 📍 ${photo.lat.toFixed(6)}, ${photo.lng.toFixed(6)} +
`; + } else { + html += '
⚠️ No GPS data found in this photo
'; + } + html += `
+ +
`; + content.innerHTML = html; +} + +async function exifUploadSelected(idx) { + const photo = exifAllPhotos[idx]; + const engine = document.getElementById('exifOcrEngine').value; + const sticker = document.getElementById('exifStickerMode').checked; + let url = `/api/admin/exif/analyze?ocr_engine=${engine}`; + if (sticker) url += '&sticker_mode=true'; + const formData = new FormData(); + formData.append('file', photo.file); + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + AppState.authToken }, + body: formData, + }); + const data = await resp.json(); + exifLastPhotoId = data.photo_id; + exifLastPhotoGps = data.exif && data.exif.gps ? { lat: data.exif.gps.lat, lng: data.exif.gps.lng } : null; + showExifServerResults(data); + } catch (e) { + document.getElementById('exifDetailContent').innerHTML += + `
❌ Analysis failed: ${esc(e.message)}
`; + } +} + +function showExifServerResults(data) { + const content = document.getElementById('exifDetailContent'); + let html = '
'; + html += '
🖥️ Server Results
'; + // EXIF data + const exif = data.exif || {}; + if (exif.has_exif) { + html += '
📷 EXIF Tags
'; + const tags = exif.tags || {}; + const tagEntries = Object.entries(tags).slice(0, 15); + for (const [k, v] of tagEntries) { + html += `
+ ${esc(k)} + ${esc(v)} +
`; + } + } + if (exif.gps) { + html += `
+ 📍 ${exif.gps.lat.toFixed(6)}, ${exif.gps.lng.toFixed(6)} +
`; + } + // OCR results + const ocr = data.ocr || {}; + if (ocr.raw_text) { + html += '
🔎 OCR'; + html += `${esc(ocr.engine || '?')}`; + html += `
${esc(ocr.raw_text)}
`; + } + if (ocr.match_5dash6 || ocr.match_5plus) { + html += `
✓ ${esc(ocr.match_5dash6 || ocr.match_5plus)}
`; + } + // Machine match + const asset = data.asset; + if (asset) { + html += `
+
✓ Matched: ${esc(asset.name || asset.machine_id)}
+
${asset.building_name || ''}${asset.floor ? ' Floor ' + asset.floor : ''}${asset.room ? ' • ' + asset.room : ''}
+
`; + } else if (data.machine_id) { + html += `
+
🔍 Machine ID: ${esc(data.machine_id)} (not found in DB)
+
`; + } + html += '
'; + content.innerHTML += html; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// EXIF SCANNER — GPS Push +// ═════════════════════════════════════════════════════════════════════════════ +async function exifPushGpsToAsset(idx) { + const item = exifBulkData[idx]; + if (!item || !item.asset || !item.needs_gps) return; + const btn = document.getElementById('exifPushBtn' + idx); + btn.disabled = true; + btn.textContent = '⏳...'; + btn.style.background = 'var(--card2)'; + const gps = item.exif.gps; + try { + const data = await api('/api/admin/exif/push-gps', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + asset_id: item.asset.id, + latitude: gps.lat, + longitude: gps.lng, + }), + }); + if (data.updated) { + btn.textContent = '✓ Pushed!'; + btn.style.background = 'var(--green)'; + btn.style.color = '#000'; + } else { + btn.textContent = data.reason || 'Failed'; + btn.style.background = 'var(--red)'; + } + } catch (e) { + btn.textContent = 'Error'; + btn.style.background = 'var(--red)'; + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// EXIF SCANNER — Batch bar +// ═════════════════════════════════════════════════════════════════════════════ +async function exifBatchLookup() { + // Simple: find first unmatched photo and try the typed machine ID + const machineId = document.getElementById('exifBatchMachineId').value.trim(); + if (!machineId) return; + try { + const data = await api('/api/admin/exif/lookup?machine_id=' + encodeURIComponent(machineId)); + if (data.found) { + showToast('✓ Found: ' + data.asset.name); + } else { + showToast(data.reason || 'Not found', true); + } + } catch (e) { + showToast('Lookup failed: ' + e.message, true); + } +} + +async function exifBatchPushGps() { + const machineId = document.getElementById('exifBatchMachineId').value.trim(); + if (!machineId) return; + // Find all needs_gps items + const toPush = exifBulkData.filter(i => i.needs_gps && i.asset && i.exif && i.exif.gps); + if (!toPush.length) { + showToast('No GPS-ready items to push', true); + return; + } + let pushed = 0; + for (const item of toPush) { + try { + const data = await api('/api/admin/exif/push-gps', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + asset_id: item.asset.id, + latitude: item.exif.gps.lat, + longitude: item.exif.gps.lng, + }), + }); + if (data.updated) pushed++; + } catch (e) { /* skip */ } + } + showToast(`📤 GPS pushed to ${pushed} / ${toPush.length} assets`); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// EXIF SCANNER — Sessions +// ═════════════════════════════════════════════════════════════════════════════ +async function exifCreateSession() { + const name = document.getElementById('exifSessionName').value.trim(); + if (!name) { + showToast('Enter a session name', true); + return; + } + try { + await api('/api/admin/exif/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + showToast('✅ Session created'); + document.getElementById('exifSessionName').value = ''; + await loadExifSessions(); + } catch (e) { + showToast('Failed: ' + e.message, true); + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// EXIF SCANNER — Lightbox +// ═════════════════════════════════════════════════════════════════════════════ +var exifLightboxEl = null; +function openExifLightbox(src) { + if (!exifLightboxEl) { + exifLightboxEl = document.createElement('div'); + exifLightboxEl.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.95);z-index:10000;display:flex;align-items:center;justify-content:center;cursor:zoom-out;'; + exifLightboxEl.onclick = closeExifLightbox; + const img = document.createElement('img'); + img.style.cssText = 'max-width:95vw;max-height:95vh;object-fit:contain;border-radius:4px;'; + img.id = 'exifLightboxImg'; + exifLightboxEl.appendChild(img); + document.body.appendChild(exifLightboxEl); + } + document.getElementById('exifLightboxImg').src = src; + exifLightboxEl.style.display = 'flex'; +} +function closeExifLightbox() { + if (exifLightboxEl) exifLightboxEl.style.display = 'none'; +}