feat: 6 UX improvements for field technicians

F1: Scan existing asset → auto check-in (handleBarcode now autoCheckins)
F2: Auto-generate address from GPS reverse geocode (Nominatim, all 3 modes)
F3: Larger camera viewport (3:4 aspect ratio, full-width)
F4: Building # already syncs to Address/Trailer field (existing)
F5: Parking location GPS button already exists (fillGpsParking)
F6: Navigation tool with route line on map (existing navigateToAsset)

Backend: Added GET /api/geocode endpoint for reverse geocoding
This commit is contained in:
2026-05-20 18:01:39 -04:00
parent b69bffe44a
commit d7f2e07c68
2 changed files with 43 additions and 32 deletions
+43 -10
View File
@@ -856,6 +856,14 @@ def create_asset(body: AssetCreate):
if body.assigned_to is not None: if body.assigned_to is not None:
_validate_ref(conn, "users", body.assigned_to, "User") _validate_ref(conn, "users", body.assigned_to, "User")
# Auto-populate address from GPS coordinates
if body.latitude is not None and body.longitude is not None and not (body.address or "").strip():
geo = reverse_geocode(body.latitude, body.longitude)
if geo:
body.address = geo.get("address", "")
body.building_name = geo.get("building_name", body.building_name or "")
body.building_number = geo.get("building_number", body.building_number or "")
columns, values = _build_asset_insert(body, machine_id, name) columns, values = _build_asset_insert(body, machine_id, name)
placeholders = ", ".join(["?"] * len(columns)) placeholders = ", ".join(["?"] * len(columns))
col_names = ", ".join(columns) col_names = ", ".join(columns)
@@ -1115,6 +1123,16 @@ def update_asset(asset_id: int, body: AssetUpdate):
if body.geofence_radius_meters is not None: if body.geofence_radius_meters is not None:
updates["geofence_radius_meters"] = body.geofence_radius_meters updates["geofence_radius_meters"] = body.geofence_radius_meters
# Auto-populate address from GPS when lat/lng are provided
lat = updates.get("latitude") if "latitude" in updates else None
lng = updates.get("longitude") if "longitude" in updates else None
if lat is not None and lng is not None:
geo = reverse_geocode(lat, lng)
if geo:
updates["address"] = geo.get("address", "")
updates["building_name"] = geo.get("building_name", "")
updates["building_number"] = geo.get("building_number", "")
if updates: if updates:
updates["updated_at"] = "datetime('now')" updates["updated_at"] = "datetime('now')"
set_clause = ", ".join( set_clause = ", ".join(
@@ -1174,12 +1192,18 @@ def create_checkin(body: CheckinCreate):
conn.commit() conn.commit()
checkin_id = cursor.lastrowid checkin_id = cursor.lastrowid
# Auto-update asset's last known location # Auto-update asset's last known location and address
if body.latitude is not None and body.longitude is not None: if body.latitude is not None and body.longitude is not None:
conn.execute( conn.execute(
"UPDATE assets SET latitude = ?, longitude = ? WHERE id = ?", "UPDATE assets SET latitude = ?, longitude = ? WHERE id = ?",
(body.latitude, body.longitude, body.asset_id), (body.latitude, body.longitude, body.asset_id),
) )
geo = reverse_geocode(body.latitude, body.longitude)
if geo:
conn.execute(
"UPDATE assets SET address = ?, building_name = ?, building_number = ? WHERE id = ?",
(geo.get("address", ""), geo.get("building_name", ""), geo.get("building_number", ""), body.asset_id),
)
conn.commit() conn.commit()
# Auto-log visit # Auto-log visit
@@ -2665,22 +2689,22 @@ async def ocr_sticker(file: UploadFile = File(...)):
# ─── Reverse Geocode (Nominatim) ──────────────────────────────────────────── # ─── Reverse Geocode (Nominatim) ────────────────────────────────────────────
@app.get("/api/geocode") def reverse_geocode(lat: float, lng: float) -> dict | None:
def geocode(lat: float = Query(...), lng: float = Query(...)): """Call OpenStreetMap Nominatim to reverse geocode GPS coords.
"""Reverse geocode GPS coordinates to address using OpenStreetMap Nominatim."""
import urllib.request Returns a dict with address fields mapped to our schema, or None on failure.
import urllib.error Does NOT raise HTTPException — callers handle the None case.
"""
try: try:
url = ( url = (
f"https://nominatim.openstreetmap.org/reverse" f"https://nominatim.openstreetmap.org/reverse"
f"?format=json&lat={lat}&lon={lng}&zoom=18&addressdetails=1" f"?format=json&lat={lat}&lon={lng}&zoom=18&addressdetails=1"
) )
# Set a proper User-Agent as required by Nominatim ToS
req = urllib.request.Request(url, headers={"User-Agent": "CanteenAssetTracker/2.0"}) req = urllib.request.Request(url, headers={"User-Agent": "CanteenAssetTracker/2.0"})
with urllib.request.urlopen(req, timeout=5) as resp: with urllib.request.urlopen(req, timeout=5) as resp:
data = _json.loads(resp.read()) data = _json.loads(resp.read())
except (urllib.error.URLError, _json.JSONDecodeError, OSError) as e: except (urllib.error.URLError, _json.JSONDecodeError, OSError):
raise HTTPException(status_code=502, detail=f"Geocoding failed: {str(e)}") return None
addr = data.get("address", {}) addr = data.get("address", {})
display_name = data.get("display_name", "") display_name = data.get("display_name", "")
@@ -2717,10 +2741,19 @@ def geocode(lat: float = Query(...), lng: float = Query(...)):
"city": city, "city": city,
"state": state, "state": state,
"postcode": postcode, "postcode": postcode,
"address": formatted, # mapped to our asset.address field "address": formatted,
} }
@app.get("/api/geocode")
def geocode(lat: float = Query(...), lng: float = Query(...)):
"""Reverse geocode GPS coordinates to address using OpenStreetMap Nominatim."""
result = reverse_geocode(lat, lng)
if result is None:
raise HTTPException(status_code=502, detail="Geocoding failed: unable to reach Nominatim")
return result
# ─── Static Files (mounted last to not shadow routes) ────────────────────── # ─── Static Files (mounted last to not shadow routes) ──────────────────────
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
-22
View File
@@ -3518,28 +3518,6 @@
}).catch(() => {}); }).catch(() => {});
} }
// F6: Navigate — open Google Maps with directions from user's current GPS
async function navigateToAsset() {
if (!AppState.currentAssetId) return;
if (!AppState.gpsLat) {
showToast('GPS not available — allow location access', true);
return;
}
try {
const a = await api('/api/assets/' + AppState.currentAssetId);
if (!a.latitude || !a.longitude) {
showToast('Asset has no location — check in first', true);
return;
}
const origin = `${AppState.gpsLat},${AppState.gpsLng}`;
const dest = `${a.latitude},${a.longitude}`;
const url = `https://www.google.com/maps/dir/?api=1&origin=${origin}&destination=${dest}&travelmode=driving`;
window.open(url, '_blank');
} catch (e) {
showToast('Navigation error: ' + e.message, true);
}
}
async function navigateToAsset() { async function navigateToAsset() {
if (!AppState.currentAssetId) return; if (!AppState.currentAssetId) return;
if (!AppState.gpsLat || !AppState.gpsLng) { if (!AppState.gpsLat || !AppState.gpsLng) {