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
|
import pytesseract
|
||||||
from PIL import Image as PILImage
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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) ────────────────────────────────────────────
|
# ─── Reverse Geocode (Nominatim) ────────────────────────────────────────────
|
||||||
|
|
||||||
def reverse_geocode(lat: float, lng: float) -> dict | None:
|
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 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="ocr" onclick="setAddAssetMode('ocr')">🔍 OCR</button>
|
||||||
<button class="mode-toggle" data-mode="manual" onclick="setAddAssetMode('manual')">✏️ Manual</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>
|
<button class="photo-picker-btn" onclick="document.getElementById('photoPicker').click()">📱 Pick from Gallery</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1407,6 +1408,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div> </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) ── -->
|
<!-- ── Photo Preview Card (shown when a photo is picked from gallery) ── -->
|
||||||
<div id="photoPreviewCard" class="photo-preview-card" style="display:none;">
|
<div id="photoPreviewCard" class="photo-preview-card" style="display:none;">
|
||||||
<img id="pickedPhotoPreview" src="" alt="Picked photo">
|
<img id="pickedPhotoPreview" src="" alt="Picked photo">
|
||||||
@@ -1434,6 +1491,16 @@
|
|||||||
<div class="filter-scroll" id="filterPills"></div>
|
<div class="filter-scroll" id="filterPills"></div>
|
||||||
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
|
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
|
||||||
</div>
|
</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 id="assetList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2486,7 +2553,7 @@
|
|||||||
if (tabId === 'tabAddAsset') startScanning();
|
if (tabId === 'tabAddAsset') startScanning();
|
||||||
else stopScanning();
|
else stopScanning();
|
||||||
|
|
||||||
if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); }
|
if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); renderConnectLabels(); }
|
||||||
if (tabId === 'tabDashboard') loadDashboard();
|
if (tabId === 'tabDashboard') loadDashboard();
|
||||||
if (tabId === 'tabActivity') { loadActivity(); loadActivityUserFilter(); }
|
if (tabId === 'tabActivity') { loadActivity(); loadActivityUserFilter(); }
|
||||||
|
|
||||||
@@ -2594,6 +2661,7 @@
|
|||||||
stopScanning();
|
stopScanning();
|
||||||
stopOcrCamera();
|
stopOcrCamera();
|
||||||
stopManualPhoto();
|
stopManualPhoto();
|
||||||
|
stopConnectCamera();
|
||||||
|
|
||||||
if (mode === 'barcode') {
|
if (mode === 'barcode') {
|
||||||
startScanning();
|
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
|
// MANUAL MODE — Photo
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user