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:
@@ -22,7 +22,7 @@ from pathlib import Path
|
||||
import pytesseract
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File
|
||||
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -2687,6 +2687,107 @@ async def ocr_sticker(file: UploadFile = File(...)):
|
||||
}
|
||||
|
||||
|
||||
# ─── T4: Connect Label — unified photo + OCR + GPS endpoint ─────────────────
|
||||
|
||||
class ConnectLabelRequest(BaseModel):
|
||||
"""Request body for the connect-label endpoint."""
|
||||
machine_id: str
|
||||
name: str = ""
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
category: Optional[str] = "Other"
|
||||
photo_path: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/connect-label", status_code=201)
|
||||
async def connect_label(
|
||||
photo: Optional[UploadFile] = File(None),
|
||||
machine_id: str = Form(""),
|
||||
name: str = Form(""),
|
||||
latitude: Optional[float] = Form(None),
|
||||
longitude: Optional[float] = Form(None),
|
||||
category: str = Form("Other"),
|
||||
photo_path: str = Form(""),
|
||||
):
|
||||
"""Unified endpoint for Connect Label workflow.
|
||||
|
||||
Accepts multipart form data with optional photo upload.
|
||||
Creates an asset with the provided machine_id, name, GPS coords.
|
||||
If photo is uploaded, it saves the file and sets photo_path.
|
||||
Validates machine_id format (XXXXX-XXXXXX → last 5 digits).
|
||||
"""
|
||||
# If machine_id/name come as form fields, use those; otherwise try query params
|
||||
machine_id = _sanitize_machine_id(machine_id)
|
||||
name = _sanitize_name(name or "Scanned Asset")
|
||||
_validate_status("active")
|
||||
|
||||
# Save photo if provided
|
||||
saved_photo_path = photo_path or ""
|
||||
if photo and photo.filename:
|
||||
saved_photo_path = _save_upload(photo, "connect_labels", PHOTO_ALLOWED_EXTS, PHOTO_MAX_SIZE)
|
||||
|
||||
conn = get_db()
|
||||
_validate_enum_table(conn, "categories", category or "Other", "category")
|
||||
|
||||
# Build insert
|
||||
columns: list[str] = ["machine_id", "name", "category", "status"]
|
||||
values: list = [machine_id, name, category or "Other", "active"]
|
||||
|
||||
if saved_photo_path:
|
||||
columns.append("photo_path")
|
||||
values.append(saved_photo_path)
|
||||
|
||||
if latitude is not None:
|
||||
columns.append("latitude")
|
||||
values.append(latitude)
|
||||
if longitude is not None:
|
||||
columns.append("longitude")
|
||||
values.append(longitude)
|
||||
|
||||
# Auto-populate address from GPS
|
||||
if latitude is not None and longitude is not None:
|
||||
geo = reverse_geocode(latitude, longitude)
|
||||
if geo:
|
||||
if "address" not in columns:
|
||||
columns.append("address")
|
||||
values.append(geo.get("address", geo.get("formatted", "")))
|
||||
if "building_name" not in columns and geo.get("building_name"):
|
||||
columns.append("building_name")
|
||||
values.append(geo.get("building_name", ""))
|
||||
if "building_number" not in columns and geo.get("building_number"):
|
||||
columns.append("building_number")
|
||||
values.append(geo.get("building_number", ""))
|
||||
|
||||
placeholders = ", ".join(["?"] * len(columns))
|
||||
col_names = ", ".join(columns)
|
||||
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
f"INSERT INTO assets ({col_names}) VALUES ({placeholders})",
|
||||
values,
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Asset with machine_id '{machine_id}' already exists",
|
||||
)
|
||||
raw_id = cursor.lastrowid
|
||||
assert raw_id is not None, "INSERT did not return lastrowid"
|
||||
asset_id: int = raw_id
|
||||
|
||||
_log_activity(conn, "created", "asset", asset_id,
|
||||
f"Asset '{name}' (machine_id: {machine_id}) created via Connect Label")
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
|
||||
result = row_to_dict(row)
|
||||
result["keys"] = _get_asset_keys(conn, asset_id)
|
||||
result["badges"] = _get_asset_badges(conn, asset_id)
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
# ─── Reverse Geocode (Nominatim) ────────────────────────────────────────────
|
||||
|
||||
def reverse_geocode(lat: float, lng: float) -> dict | None:
|
||||
|
||||
+459
-1
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user