T4: Connect Label upload workflow — unified photo + OCR + GPS

- Added 'Connect' mode toggle on Add Asset tab with Camera/Gallery options
- processConnectLabelPhoto(file): parallel OCR + GPS extraction
- Shows results card with editable machine_id, lat/lng, map preview
- POST /api/connect-label endpoint with photo upload + GPS + reverse geocode
- Connect Label section in Assets list (localStorage-backed recent scans)
- Handles OCR failure (manual entry) and missing EXIF GPS gracefully
This commit is contained in:
2026-05-21 11:50:09 -04:00
parent f895bf99de
commit 622996624a
2 changed files with 561 additions and 2 deletions
+459 -1
View File
@@ -1212,6 +1212,7 @@
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
<button class="mode-toggle" data-mode="ocr" onclick="setAddAssetMode('ocr')">🔍 OCR</button>
<button class="mode-toggle" data-mode="manual" onclick="setAddAssetMode('manual')">✏️ Manual</button>
<button class="mode-toggle" data-mode="connect" onclick="setAddAssetMode('connect')">🏷️ Connect</button>
<button class="photo-picker-btn" onclick="document.getElementById('photoPicker').click()">📱 Pick from Gallery</button>
</div>
@@ -1407,6 +1408,62 @@
</div>
</div> </div>
<!-- ── CONNECT LABEL MODE (T4: unified photo + OCR + GPS) ──────────── -->
<div id="addConnectMode" class="add-mode">
<div class="card">
<div class="card-title">🏷️ Scan Connect Label — Photo + OCR + GPS</div>
<p style="font-size:12px;color:var(--text2);margin-bottom:10px;">
Take or pick a photo of a machine sticker. OCR reads the ID, GPS is extracted automatically.
</p>
<div style="display:flex;gap:8px;margin-bottom:4px;">
<button class="btn btn-primary" onclick="connectFromCamera()" style="flex:1;">
📷 Camera
</button>
<button class="btn btn-outline" onclick="connectFromGallery()" style="flex:1;">
🖼️ From Gallery
</button>
</div>
</div>
<!-- Hidden camera for connect mode -->
<div id="connectCameraArea" class="camera-area" style="display:none;">
<video id="connectVideo" autoplay playsinline muted style="display:none;width:100%;height:100%;object-fit:cover;position:absolute;inset:0;"></video>
<div id="connectCameraPlaceholder" class="camera-placeholder" style="display:none;">
<span class="cam-icon">📸</span>
<div class="cam-hint">Tap to start camera</div>
</div>
<div class="ocr-capture-overlay" id="connectCaptureOverlay" style="display:none;">
<button class="ocr-capture-btn" onclick="captureConnectPhoto()" title="Capture">📸</button>
</div>
</div>
<!-- Processing status -->
<div id="connectStatus" class="status-bar" style="display:none;"></div>
<!-- Results card -->
<div id="connectResults" class="card" style="display:none;">
<div class="card-title">📋 Scan Results</div>
<img id="connectThumb" src="" alt="Scanned photo" style="width:100%;max-height:200px;object-fit:cover;border-radius:var(--radius-sm);margin-bottom:10px;">
<div id="connectOcrResult" style="margin-bottom:8px;"></div>
<div id="connectGpsResult" style="margin-bottom:8px;"></div>
<div id="connectMiniMap" style="display:none;width:100%;height:160px;border-radius:var(--radius-sm);overflow:hidden;margin-bottom:8px;"></div>
<!-- Editable fields -->
<input id="connectMachineId" type="text" class="input-field" placeholder="Machine ID *">
<input id="connectName" type="text" class="input-field" placeholder="Asset Name *">
<div class="form-row" style="margin-bottom:8px;">
<input id="connectLat" type="number" step="any" class="input-field" placeholder="Latitude">
<input id="connectLng" type="number" step="any" class="input-field" placeholder="Longitude">
</div>
<select id="connectCatSelect" class="input-field"></select>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary" onclick="createConnectAsset()" style="flex:2;"> Create Asset</button>
<button class="btn btn-outline" onclick="resetConnectMode()" style="flex:1;">🔄 Reset</button>
</div>
</div>
</div>
<!-- ── Photo Preview Card (shown when a photo is picked from gallery) ── -->
<div id="photoPreviewCard" class="photo-preview-card" style="display:none;">
<img id="pickedPhotoPreview" src="" alt="Picked photo">
@@ -1434,6 +1491,16 @@
<div class="filter-scroll" id="filterPills"></div>
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
</div>
<!-- Connect Label recent scans -->
<div class="card" id="connectLabelSection" style="display:none;">
<div class="card-title" style="display:flex;align-items:center;justify-content:space-between;">
🏷️ Recently Scanned Labels
<button class="btn btn-outline btn-sm" onclick="clearConnectLabels()" style="font-size:10px;">Clear</button>
</div>
<div id="connectLabelList" style="font-size:12px;color:var(--text2);"></div>
</div>
<div id="assetList"></div>
</div>
@@ -2486,7 +2553,7 @@
if (tabId === 'tabAddAsset') startScanning();
else stopScanning();
if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); }
if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); renderConnectLabels(); }
if (tabId === 'tabDashboard') loadDashboard();
if (tabId === 'tabActivity') { loadActivity(); loadActivityUserFilter(); }
@@ -2594,6 +2661,7 @@
stopScanning();
stopOcrCamera();
stopManualPhoto();
stopConnectCamera();
if (mode === 'barcode') {
startScanning();
@@ -3519,6 +3587,396 @@
}
}
// ═══════════════════════════════════════════════════════════════════════
// T4: CONNECT LABEL — unified photo + OCR + GPS
// ═══════════════════════════════════════════════════════════════════════
let connectStream = null;
let connectFile = null;
let connectGps = null; // { lat, lng, accuracy }
let connectOcrData = null; // { machine_id, confidence, raw_text }
async function connectFromCamera() {
// Stop any existing streams
stopOcrCamera();
stopManualPhoto();
const area = document.getElementById('connectCameraArea');
const video = document.getElementById('connectVideo');
const placeholder = document.getElementById('connectCameraPlaceholder');
const overlay = document.getElementById('connectCaptureOverlay');
const results = document.getElementById('connectResults');
const status = document.getElementById('connectStatus');
area.style.display = 'block';
results.style.display = 'none';
status.style.display = 'none';
status.textContent = '';
placeholder.style.display = 'flex';
video.style.display = 'none';
overlay.style.display = 'none';
// Stop any previous connect stream
if (connectStream) {
connectStream.getTracks().forEach(t => t.stop());
connectStream = null;
}
try {
connectStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false,
});
video.srcObject = connectStream;
video.style.display = 'block';
placeholder.style.display = 'none';
overlay.style.display = 'flex';
} catch (e) {
showToast('Camera error: ' + e.message, true);
area.style.display = 'none';
}
}
function connectFromGallery() {
const pp = document.getElementById('photoPicker');
if (pp) {
// Override the default handler for connect mode
pp._connectMode = true;
pp.click();
}
}
async function captureConnectPhoto() {
const video = document.getElementById('connectVideo');
if (!video || !connectStream) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85));
// Stop the camera
stopConnectCamera();
// Process the photo
await processConnectLabelPhoto(new File([blob], 'sticker.jpg', { type: 'image/jpeg' }));
}
function stopConnectCamera() {
if (connectStream) {
connectStream.getTracks().forEach(t => t.stop());
connectStream = null;
}
document.getElementById('connectVideo').srcObject = null;
document.getElementById('connectVideo').style.display = 'none';
document.getElementById('connectCameraArea').style.display = 'none';
document.getElementById('connectCaptureOverlay').style.display = 'none';
document.getElementById('connectCameraPlaceholder').style.display = 'none';
}
// ▶ Core: process photo via OCR + GPS extraction
async function processConnectLabelPhoto(file) {
connectFile = file;
connectGps = null;
connectOcrData = null;
// Show status
const statusEl = document.getElementById('connectStatus');
statusEl.style.display = 'block';
statusEl.className = 'status-bar working';
statusEl.textContent = '⏳ Processing photo...';
document.getElementById('connectResults').style.display = 'none';
// Show thumbnail
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('connectThumb').src = e.target.result;
};
reader.readAsDataURL(file);
// Run OCR and GPS in parallel
const [ocrResult, gpsResult] = await Promise.allSettled([
runConnectOcr(file),
extractGpsFromPhoto(file),
]);
// Process OCR result
if (ocrResult.status === 'fulfilled' && ocrResult.value) {
connectOcrData = ocrResult.value;
} else {
connectOcrData = { machine_id: '', confidence: 'none', raw_text: 'OCR failed' };
}
// Process GPS result
if (gpsResult.status === 'fulfilled' && gpsResult.value) {
connectGps = gpsResult.value;
} else {
connectGps = null;
}
// Render results
renderConnectResults();
}
// ▶ Run OCR for connect mode (server first, client fallback)
async function runConnectOcr(file) {
// Try server-side OCR first
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 8000);
const formData = new FormData();
formData.append('file', file, 'sticker.jpg');
const res = await fetch('/api/ocr', {
method: 'POST',
body: formData,
signal: controller.signal,
headers: AppState.authToken
? { 'Authorization': 'Bearer ' + AppState.authToken }
: {},
});
clearTimeout(t);
if (res.ok) {
const data = await res.json();
data.method = 'server';
return data;
}
throw new Error('Server returned ' + res.status);
} catch (e) {
// Fall back to client-side Tesseract.js
try {
const data = await ocrImageClient(file);
return data;
} catch (clientErr) {
return { machine_id: '', confidence: 'none', raw_text: 'OCR unavailable', method: 'client' };
}
}
}
function renderConnectResults() {
const resultsEl = document.getElementById('connectResults');
const statusEl = document.getElementById('connectStatus');
resultsEl.style.display = 'block';
// OCR result line
const ocrEl = document.getElementById('connectOcrResult');
if (connectOcrData && connectOcrData.machine_id) {
const methodBadge = connectOcrData.method === 'client'
? '<span class="offline-badge">📡 Offline</span>' : '';
ocrEl.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span class="gps-badge ok">✅ Machine ID found</span>${methodBadge}
</div>
<div style="font-size:13px;color:var(--text2);">
${esc(connectOcrData.machine_id)}
${connectOcrData.confidence === 'low' ? ' ⚠️ Low confidence — verify' : ''}
</div>`;
document.getElementById('connectMachineId').value = connectOcrData.machine_id;
statusEl.className = 'status-bar success';
statusEl.textContent = '✅ Processing complete';
} else {
ocrEl.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span class="gps-badge err">❌ No ID detected</span>
</div>
<div style="font-size:12px;color:var(--text3);">Enter machine ID manually below.</div>`;
document.getElementById('connectMachineId').value = '';
statusEl.className = 'status-bar error';
statusEl.textContent = 'OCR failed — enter ID manually';
}
// GPS result line
const gpsEl = document.getElementById('connectGpsResult');
const miniMapEl = document.getElementById('connectMiniMap');
if (connectGps) {
gpsEl.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span class="gps-badge ok">📍 GPS from photo</span>
</div>
<div style="font-size:13px;color:var(--text2);">
${connectGps.lat.toFixed(6)}, ${connectGps.lng.toFixed(6)}
${connectGps.accuracy ? ` (±${connectGps.accuracy.toFixed(1)}m)` : ''}
</div>`;
document.getElementById('connectLat').value = connectGps.lat.toFixed(6);
document.getElementById('connectLng').value = connectGps.lng.toFixed(6);
miniMapEl.style.display = 'block';
setTimeout(() => renderGpsPreview(connectGps.lat, connectGps.lng, miniMapEl), 100);
} else {
gpsEl.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span class="gps-badge err">📍 No GPS in photo</span>
</div>
<div style="font-size:11px;color:var(--text3);margin-bottom:4px;">
Phone may have stripped EXIF GPS. Enter coordinates manually or skip.
</div>`;
document.getElementById('connectLat').value = '';
document.getElementById('connectLng').value = '';
miniMapEl.style.display = 'none';
}
// Populate category select
populateCategorySelect('connectCatSelect');
document.getElementById('connectName').value = '';
document.getElementById('connectName').focus();
// Save to recent connect labels
saveConnectLabel(connectOcrData ? connectOcrData.machine_id : '', connectGps);
}
async function createConnectAsset() {
const machineId = document.getElementById('connectMachineId').value.trim();
const name = document.getElementById('connectName').value.trim();
if (!machineId) { showToast('Machine ID is required', true); return; }
if (!name) { showToast('Asset name is required', true); return; }
const latStr = document.getElementById('connectLat').value.trim();
const lngStr = document.getElementById('connectLng').value.trim();
const payload = {
machine_id: machineId,
name: name,
category: document.getElementById('connectCatSelect').value || 'Other',
status: 'active',
};
if (latStr && lngStr) {
payload.latitude = parseFloat(latStr);
payload.longitude = parseFloat(lngStr);
}
// Try reverse geocode for address
if (payload.latitude && payload.longitude) {
try {
const geo = await reverseGeocode(payload.latitude, payload.longitude);
if (geo) {
payload.address = geo.address || geo.formatted || '';
payload.building_name = geo.building_name || '';
payload.building_number = geo.building_number || '';
}
} catch (e) { /* ignore */ }
}
// Upload photo if available
if (connectFile) {
try {
const formData = new FormData();
formData.append('file', connectFile);
const uploadRes = await fetch('/api/upload/photo', {
method: 'POST',
body: formData,
headers: AppState.authToken
? { 'Authorization': 'Bearer ' + AppState.authToken }
: {},
});
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
payload.photo_path = uploadData.path;
}
} catch (e) { /* photo upload is optional */ }
}
try {
const asset = await api('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (asset._queued) {
showToast('📴 Queued offline — will sync when connected');
} else {
showToast('✅ Asset created!');
AppState.currentAssetId = asset.id;
autoCheckin(asset.id);
}
// Switch to assets tab
switchTab('tabAssets');
loadAssets();
} catch (e) {
showToast(e.message, true);
}
}
function resetConnectMode() {
connectFile = null;
connectGps = null;
connectOcrData = null;
document.getElementById('connectResults').style.display = 'none';
document.getElementById('connectStatus').style.display = 'none';
document.getElementById('connectStatus').textContent = '';
document.getElementById('connectThumb').src = '';
document.getElementById('connectMachineId').value = '';
document.getElementById('connectName').value = '';
document.getElementById('connectLat').value = '';
document.getElementById('connectLng').value = '';
const miniMapEl = document.getElementById('connectMiniMap');
miniMapEl.innerHTML = '';
miniMapEl.style.display = 'none';
stopConnectCamera();
}
// ── Connect Label History ───────────────────────────────────────────────
function saveConnectLabel(machineId, gps) {
try {
const labels = JSON.parse(localStorage.getItem('connectLabels') || '[]');
labels.unshift({
machine_id: machineId || 'Manual entry',
lat: gps ? gps.lat : null,
lng: gps ? gps.lng : null,
time: new Date().toISOString(),
});
// Keep last 20
if (labels.length > 20) labels.length = 20;
localStorage.setItem('connectLabels', JSON.stringify(labels));
} catch (e) { /* ignore storage errors */ }
}
function renderConnectLabels() {
const section = document.getElementById('connectLabelSection');
const list = document.getElementById('connectLabelList');
try {
const labels = JSON.parse(localStorage.getItem('connectLabels') || '[]');
if (!labels.length) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
list.innerHTML = labels.map((l, i) => {
const gpsStr = l.lat && l.lng
? `📍 (${l.lat.toFixed(4)}, ${l.lng.toFixed(4)})`
: '📍 No GPS';
const timeStr = new Date(l.time).toLocaleString();
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);">
<span style="font-weight:600;">${esc(l.machine_id)}</span>
<span style="font-size:11px;color:var(--text3);">${gpsStr}</span>
<span style="font-size:10px;color:var(--text3);margin-left:auto;">${timeStr}</span>
</div>`;
}).join('');
} catch (e) {
section.style.display = 'none';
}
}
function clearConnectLabels() {
localStorage.removeItem('connectLabels');
document.getElementById('connectLabelSection').style.display = 'none';
document.getElementById('connectLabelList').innerHTML = '';
}
// ── Hook into gallery picker for connect mode ────────────────────────────
(function() {
const pp = document.getElementById('photoPicker');
const origHandler = pp.onchange;
pp.addEventListener('change', function(e) {
if (pp._connectMode) {
pp._connectMode = false;
if (e.target.files && e.target.files[0]) {
processConnectLabelPhoto(e.target.files[0]);
}
pp.value = ''; // Reset so same file can be picked again
return;
}
});
})();
// ═══════════════════════════════════════════════════════════════════════
// MANUAL MODE — Photo
// ═══════════════════════════════════════════════════════════════════════