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:
@@ -856,6 +856,14 @@ def create_asset(body: AssetCreate):
|
||||
if body.assigned_to is not None:
|
||||
_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)
|
||||
placeholders = ", ".join(["?"] * len(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:
|
||||
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:
|
||||
updates["updated_at"] = "datetime('now')"
|
||||
set_clause = ", ".join(
|
||||
@@ -1174,12 +1192,18 @@ def create_checkin(body: CheckinCreate):
|
||||
conn.commit()
|
||||
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:
|
||||
conn.execute(
|
||||
"UPDATE assets SET latitude = ?, longitude = ? WHERE 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()
|
||||
|
||||
# Auto-log visit
|
||||
@@ -2665,22 +2689,22 @@ async def ocr_sticker(file: UploadFile = File(...)):
|
||||
|
||||
# ─── Reverse Geocode (Nominatim) ────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/geocode")
|
||||
def geocode(lat: float = Query(...), lng: float = Query(...)):
|
||||
"""Reverse geocode GPS coordinates to address using OpenStreetMap Nominatim."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
def reverse_geocode(lat: float, lng: float) -> dict | None:
|
||||
"""Call OpenStreetMap Nominatim to reverse geocode GPS coords.
|
||||
|
||||
Returns a dict with address fields mapped to our schema, or None on failure.
|
||||
Does NOT raise HTTPException — callers handle the None case.
|
||||
"""
|
||||
try:
|
||||
url = (
|
||||
f"https://nominatim.openstreetmap.org/reverse"
|
||||
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"})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = _json.loads(resp.read())
|
||||
except (urllib.error.URLError, _json.JSONDecodeError, OSError) as e:
|
||||
raise HTTPException(status_code=502, detail=f"Geocoding failed: {str(e)}")
|
||||
except (urllib.error.URLError, _json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
addr = data.get("address", {})
|
||||
display_name = data.get("display_name", "")
|
||||
@@ -2717,10 +2741,19 @@ def geocode(lat: float = Query(...), lng: float = Query(...)):
|
||||
"city": city,
|
||||
"state": state,
|
||||
"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) ──────────────────────
|
||||
|
||||
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
|
||||
|
||||
@@ -3518,28 +3518,6 @@
|
||||
}).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() {
|
||||
if (!AppState.currentAssetId) return;
|
||||
if (!AppState.gpsLat || !AppState.gpsLng) {
|
||||
|
||||
Reference in New Issue
Block a user