Fix mode structure: Scan (barcode+OCR+gallery) + Manual only

- Removed separate OCR and Connect mode toggles — merged into Scan
- Renamed Barcode mode to Scan with OCR fallback + gallery upload
- Added captureScanOcr() — tap to capture frame for OCR when barcode fails
- handleBarcode() now compares barcode vs OCR result, picks valid ID
- Gallery photo picker integrated into Scan mode (OCR + EXIF GPS)
- Removed old photoPreviewCard and connect-mode HTML sections
- Removed orphaned functions referencing removed elements
This commit is contained in:
2026-05-21 12:20:14 -04:00
parent 37ba54b750
commit 8480a6d364
+182 -214
View File
@@ -1217,26 +1217,39 @@
<input type="file" id="photoPicker" accept="image/*" capture="environment" style="display:none;">
<!-- Mode Toggles -->
<div class="mode-toggles">
<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 active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Scan</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>
<!-- ── BARCODE MODE ──────────────────────────────────────────────── -->
<!-- ── SCAN MODE (barcode + OCR + gallery upload) ────────────────── -->
<div id="addBarcodeMode" class="add-mode active">
<div class="card">
<div class="card-title">Barcode Scanner</div>
<div class="card-title">Scan</div>
<div id="cameraArea" class="camera-area">
<div id="cameraPlaceholder" class="camera-placeholder">
<span class="cam-icon">📷</span>
<div class="cam-hint">Tap to start camera</div>
</div>
<video id="cameraVideo" autoplay playsinline muted></video>
<div class="ocr-capture-overlay" id="scanCaptureOverlay" style="display:none;">
<button class="ocr-capture-btn" onclick="captureScanOcr()" title="Capture for OCR">📸</button>
</div>
</div>
<div id="scanStatus" class="status-bar">Point camera at a barcode</div>
<div id="scanStatus" class="status-bar">Point camera at a barcode or sticker</div>
<div id="scanResult" class="scan-result" style="display:none;"></div>
<div style="display:flex;gap:6px;margin-top:6px;">
<button class="btn btn-outline btn-sm" onclick="document.getElementById('photoPicker').click()" style="flex:1;">📱 Pick from Gallery</button>
</div>
</div>
<!-- Photo preview (from gallery) -->
<div id="scanPhotoPreview" class="card" style="display:none;">
<div class="card-title">📷 Photo Preview</div>
<img id="scanPhotoThumb" style="width:100%;border-radius:8px;margin-bottom:8px;">
<input type="hidden" id="scanMachineId" value="">
<div id="scanGpsResult" class="gps-result" style="display:none;"></div>
<div id="scanOcrResult" style="font-size:13px;color:var(--text2);"></div>
<button class="btn btn-primary" onclick="createFromScanPhoto()" style="margin-top:6px;"> Create Asset</button>
</div>
<!-- New Asset form (shown when machine_id not found) -->
@@ -1268,45 +1281,7 @@
</div>
</div>
<!-- ── OCR MODE ──────────────────────────────────────────────────── -->
<div id="addOcrMode" class="add-mode">
<div class="card">
<div class="card-title">OCR Scanner — Photograph Sticker</div>
<div id="ocrCameraArea" class="camera-area">
<div id="ocrPlaceholder" class="camera-placeholder" onclick="startOcrCamera()">
<span class="cam-icon">📸</span>
<div class="cam-hint" style="margin-top:4px;">Tap to start camera</div>
</div>
<video id="ocrVideo" autoplay playsinline muted style="display:none;"></video>
<div class="ocr-capture-overlay" id="ocrCaptureOverlay" style="display:none;">
<button class="ocr-capture-btn" onclick="captureOcr()" title="Capture">📸</button>
</div>
</div>
<div id="ocrStatus" class="status-bar">Point camera at the machine sticker and tap capture</div>
<div id="ocrProgressWrap" class="ocr-progress-wrap">
<div id="ocrProgressBar" class="ocr-progress-bar"></div>
</div>
</div>
<!-- OCR result -->
<div id="ocrResult" class="ocr-result" style="display:none;"></div>
<!-- Quick create from OCR -->
<div id="ocrCreateCard" class="card" style="display:none;">
<div class="card-title">Create from OCR</div>
<input id="ocrMachineId" type="text" class="input-field" placeholder="Machine ID" readonly>
<input id="ocrName" type="text" class="input-field" placeholder="Asset name *" required>
<select id="ocrCatSelect" class="input-field">
<option value="">Category</option>
</select>
<select id="ocrStatus" class="input-field">
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="retired">Retired</option>
</select>
<button class="btn btn-primary" onclick="createOcrAsset()">Create Asset</button>
</div>
</div>
<!-- ── MANUAL MODE ───────────────────────────────────────────────── -->
<!-- ── MANUAL MODE ──────────────────────────────────────────────── -->
<div id="addManualMode" class="add-mode">
<div class="card">
<div class="card-title">Manual Entry</div>
@@ -1414,75 +1389,8 @@
<textarea id="manCheckinNotes" class="input-field" placeholder="Notes (optional)" rows="2"></textarea>
<button class="btn btn-green" onclick="quickCheckinManual()" style="width:100%;">✓ Check In Now</button>
</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">
<div class="photo-preview-toolbar">
<button onclick="ocrPickedPhoto()">🔍 OCR this photo</button>
<button onclick="extractGpsFromPicked()">📍 Extract GPS</button>
<button onclick="createAssetFromPicked()"> Create Asset</button>
</div>
<div class="exif-data" id="pickedExifData" style="display:none;"></div>
<button class="btn btn-outline btn-sm" style="margin-top:8px;width:100%;" onclick="clearPickedPhoto()">✕ Clear</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: ASSETS LIST
@@ -2667,9 +2575,7 @@
// Stop any running scanners
stopScanning();
stopOcrCamera();
stopManualPhoto();
stopConnectCamera();
if (mode === 'barcode') {
startScanning();
@@ -2695,63 +2601,88 @@
function handlePickedPhoto(file) {
pickedPhotoFile = file;
pickedPhotoGps = null;
// Switch to Scan mode
setAddAssetMode('barcode');
// Show thumbnail in scan photo preview
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('pickedPhotoPreview').src = e.target.result;
document.getElementById('photoPreviewCard').style.display = 'block';
reader.onload = async function(e) {
const thumb = document.getElementById('scanPhotoThumb');
if (thumb) thumb.src = e.target.result;
// Extract EXIF / file metadata
extractExifData(file);
const preview = document.getElementById('scanPhotoPreview');
if (preview) preview.style.display = 'block';
// Try EXIF GPS extraction
tryExtractGpsFromPicked();
// Run OCR on the photo
const ocrEl = document.getElementById('scanOcrResult');
if (ocrEl) ocrEl.textContent = '⏳ Running OCR...';
// Scroll to preview
document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' });
try {
const ocrData = await ocrImageClient(file);
if (ocrData && ocrData.machine_id) {
if (ocrEl) ocrEl.innerHTML = `✅ Machine ID: <strong>${esc(ocrData.machine_id)}</strong>` +
(ocrData.confidence === 'low' ? ' ⚠️ Low confidence' : '');
} else {
if (ocrEl) ocrEl.innerHTML = '❌ No machine ID detected. <button class="btn btn-outline btn-sm" onclick="document.getElementById(\'scanMachineId\').value=prompt(\'Enter machine ID:\')">Enter manually</button>';
}
} catch (ocrErr) {
if (ocrEl) ocrEl.textContent = '❌ OCR failed: ' + ocrErr.message;
}
// Extract GPS from EXIF
const gpsEl = document.getElementById('scanGpsResult');
try {
const gps = await extractGpsFromPhoto(file);
if (gps && gps.lat != null && gps.lng != null) {
pickedPhotoGps = gps;
if (gpsEl) {
gpsEl.style.display = 'block';
gpsEl.innerHTML = `📍 GPS: ${gps.lat.toFixed(6)}, ${gps.lng.toFixed(6)}`;
}
} else {
if (gpsEl) {
gpsEl.style.display = 'block';
gpsEl.innerHTML = '📍 No GPS data in photo';
}
}
} catch (gpsErr) {
if (gpsEl) {
gpsEl.style.display = 'block';
gpsEl.innerHTML = '📍 GPS error: ' + gpsErr.message;
}
}
};
reader.readAsDataURL(file);
}
function extractExifData(file) {
const exifDiv = document.getElementById('pickedExifData');
const rows = [];
rows.push({ label: 'File name', value: file.name });
rows.push({ label: 'File size', value: formatFileSize(file.size) });
rows.push({ label: 'Type', value: file.type || 'unknown' });
rows.push({ label: 'Last modified', value: formatDate(new Date(file.lastModified).toISOString()) });
function createFromScanPhoto() {
// Gather OCR + GPS results from scan photo and create asset
const midEl = document.getElementById('scanMachineId');
const mid = midEl ? midEl.value : '';
const name = prompt('Asset name:', '');
if (!mid || !name) return showToast('Machine ID and name required', true);
exifDiv.innerHTML = rows.map(r =>
`<div class="exif-row"><span class="exif-label">${esc(r.label)}</span><span class="exif-value">${esc(r.value)}</span></div>`
).join('');
exifDiv.style.display = 'block';
const payload = { machine_id: mid, name };
if (pickedPhotoGps) {
payload.latitude = pickedPhotoGps.lat;
payload.longitude = pickedPhotoGps.lng;
}
api('/api/connect-label', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(() => {
showToast('✅ Asset created');
document.getElementById('scanPhotoPreview').style.display = 'none';
pickedPhotoFile = null;
pickedPhotoGps = null;
}).catch(e => showToast('Error: ' + e.message, true));
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function clearPickedPhoto() {
pickedPhotoFile = null;
pickedPhotoGps = null;
document.getElementById('photoPreviewCard').style.display = 'none';
document.getElementById('pickedPhotoPreview').src = '';
document.getElementById('pickedExifData').style.display = 'none';
document.getElementById('pickedExifData').innerHTML = '';
// Reset the file input so the same file can be picked again
const pp = document.getElementById('photoPicker');
if (pp) pp.value = '';
}
function ocrPickedPhoto() {
if (!pickedPhotoFile) return;
// Switch to OCR mode with the picked photo available
setAddAssetMode('ocr');
showToast('Photo loaded in preview — OCR processing ready');
}
// ▶ Core EXIF GPS extraction using exifr.js (loaded in <head>)
// ▶ Core EXIF GPS extraction using exifr.js
async function extractGpsFromPhoto(file) {
try {
if (typeof exifr === 'undefined') {
@@ -2772,61 +2703,9 @@
return null;
}
}
// ▶ Try to extract GPS from the currently picked photo and render results
async function tryExtractGpsFromPicked() {
if (!pickedPhotoFile) return;
const exifEl = document.getElementById('pickedExifData');
// Append GPS status to existing EXIF display
let gpsSection = document.getElementById('pickedGpsSection');
if (!gpsSection) {
gpsSection = document.createElement('div');
gpsSection.id = 'pickedGpsSection';
gpsSection.style.marginTop = '8px';
gpsSection.style.paddingTop = '8px';
gpsSection.style.borderTop = '1px solid var(--border)';
exifEl.appendChild(gpsSection);
}
gpsSection.innerHTML = '<span style="color:var(--amber);">⏳ Reading GPS from photo...</span>';
const gps = await extractGpsFromPhoto(pickedPhotoFile);
pickedPhotoGps = gps;
if (gps) {
gpsSection.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<span class="gps-badge ok">📍 GPS from photo</span>
</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:4px;">
${gps.lat.toFixed(6)}, ${gps.lng.toFixed(6)}
${gps.accuracy ? ` (±${gps.accuracy.toFixed(1)}m)` : ''}
</div>
<div id="gpsMiniMap"></div>
<div style="display:flex;gap:6px;margin-top:8px;">
<button class="btn btn-sm btn-outline" onclick="fillManualFromGps()" style="flex:1;font-size:12px;">
📝 Fill Form
</button>
<button class="btn btn-sm btn-outline" onclick="openInMaps()" style="flex:1;font-size:12px;">
🗺️ Maps
</button>
</div>
`;
// Render mini-map
const miniMapEl = document.getElementById('gpsMiniMap');
if (miniMapEl) {
setTimeout(() => renderGpsPreview(gps.lat, gps.lng, miniMapEl), 100);
}
} else {
gpsSection.innerHTML = `
<span class="gps-badge err">📍 No GPS in photo</span>
<div style="font-size:11px;color:var(--text3);margin-top:4px;">
Phones often strip EXIF GPS when sharing. You can enter coordinates manually.
</div>
`;
}
}
// ▶ Render a mini-map preview using Leaflet
function renderGpsPreview(lat, lng, containerEl) {
if (!containerEl) return;
@@ -3164,6 +3043,55 @@
if (el) { el.textContent = msg; el.className = 'status-bar ' + cls; }
}
async function captureScanOcr() {
const video = document.getElementById('cameraVideo');
if (!video || video.readyState < 2) {
setScanStatus('Camera not ready — tap to start it first', 'error');
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));
setScanStatus('Processing OCR...', 'working');
try {
// Try server first with short timeout
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 6000);
const fd = new FormData();
fd.append('file', blob, 'sticker.jpg');
const res = await fetch('/api/ocr', {
method: 'POST', body: fd, signal: controller.signal,
headers: AppState.authToken
? { 'Authorization': 'Bearer ' + AppState.authToken } : {},
});
clearTimeout(t);
if (res.ok) {
const data = await res.json();
if (data && data.machine_id) {
handleBarcode(data.machine_id);
return;
}
}
throw new Error('Server OCR failed');
} catch (e) {
// Fall back to client-side Tesseract.js
try {
const data = await ocrImageClient(blob);
if (data && data.machine_id) {
handleBarcode(data.machine_id);
return;
}
} catch (clientErr) {
setScanStatus('OCR failed: ' + clientErr.message, 'error');
return;
}
}
setScanStatus('No machine ID detected in frame', 'error');
}
async function handleBarcode(machineId) {
if (barcodeDebounce) return;
barcodeDebounce = machineId;
@@ -3174,6 +3102,46 @@
document.getElementById('newAssetCard').style.display = 'none';
document.getElementById('checkinCard').style.display = 'none';
// Also OCR the current frame to compare with barcode result
try {
const video = document.getElementById('cameraVideo');
if (video && video.readyState >= 2) {
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.8));
if (blob && blob.size > 0) {
const ocrData = await ocrImageClient(blob);
if (ocrData && ocrData.machine_id) {
console.log(`Barcode: ${machineId}, OCR: ${ocrData.machine_id}`);
// Prefer the one that looks more like a valid machine ID
// Machine ID pattern: 5 digits (last 5 of XXXXX-XXXXXX)
const barcodeDigits = machineId.replace(/\D/g, '');
const ocrDigits = ocrData.machine_id.replace(/\D/g, '');
const barcodeValid = barcodeDigits.length >= 5;
const ocrValid = ocrDigits.length >= 5 && ocrData.confidence !== 'low';
if (ocrValid && !barcodeValid) {
console.log('Using OCR result over barcode');
machineId = ocrData.machine_id;
currentScannedMachineId = machineId;
} else if (ocrValid && barcodeValid) {
// Both valid — check which matches the XXXXX-XXXXXX pattern better
const barcodeIsId = machineId.includes('-') || /^\d{4,6}$/.test(machineId.trim());
const ocrIsId = ocrData.machine_id.includes('-') || /^\d{4,6}$/.test(ocrData.machine_id);
if (ocrIsId && !barcodeIsId) {
console.log('OCR result matches ID format better');
machineId = ocrData.machine_id;
currentScannedMachineId = machineId;
}
}
}
}
}
} catch (ocrErr) {
console.warn('OCR comparison failed:', ocrErr.message);
}
try {
const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
autoCheckin(asset.id);