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:
|
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")
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user