feat: oninput handler copies building # to trailer number

- Manual asset form: manBuildingNumber → manAddress (address/trailer field)
- Location form: locFormBldgNum → locFormTrailer
This commit is contained in:
2026-05-20 17:55:00 -04:00
parent 8b92d37d01
commit 02b3ba665e
5 changed files with 296 additions and 11 deletions
+92
View File
@@ -0,0 +1,92 @@
# Canteen Asset Tracker — Feature Batch Implementation Plan
**Date:** 2026-05-20
**Goal:** Six UX improvements for field technicians
---
## Feature 1: Scan existing asset → auto check-in
**Current:** Scanning a known asset shows it with a manual "Check In" button
**Target:** Scanning a known asset immediately checks in with GPS, no extra tap
**Files:**
- `static/index.html``handleBarcode()` function (line ~2638)
**Change:** After `showScannedAsset(asset)`, call `autoCheckin(asset.id)` + `submitCheckin()`-equivalent logic. Skip showing the manual checkin card.
---
## Feature 2: Auto-generate address from GPS reverse geocode
**Current:** Address fields are always empty; user must type them manually
**Target:** When creating an asset (all 3 modes), reverse-geocode GPS to populate address fields
**Files:**
- `static/index.html``createScannedAsset()`, `createOcrAsset()`, `submitManualAsset()`
- `server.py` — new endpoint `GET /api/geocode?lat=&lng=` using OpenStreetMap Nominatim
**Approach:** Add a helper `reverseGeocode(lat, lng)` that calls Nominatim API, returns address parts. Call it in all three creation paths before submitting. Populate `address`, `building_name`, `building_number`, `floor` fields from the response.
---
## Feature 3: Larger camera viewport
**Current:** Camera area uses `aspect-ratio: 16/12` — decent but could be taller
**Target:** Bigger camera preview for easier barcode/OCR scanning
**Files:**
- `static/index.html``.camera-area` CSS (~line 437)
**Change:** Increase aspect ratio to `4/3` or make it fill more vertical space. Reduce margins. Consider max-height removal.
---
## Feature 4: Building # → trailer number sync
**Current:** `building_number` and `trailer_number` are separate fields
**Target:** When building_number is entered, auto-populate trailer_number with same value
**Files:**
- `static/index.html` — manual form event listeners, auto-checkin logic
**Change:** Add an `oninput` handler on the building number field that copies the value to trailer number. Also handle the OCR/barcode creation paths if they ever populate building number.
---
## Feature 5: Parking location from current GPS
**Current:** `parking_location` is a free-text field
**Target:** Add a "📍 Use Current Location" button next to parking location that fills in GPS coords
**Files:**
- `static/index.html` — manual form HTML + JS
**Change:** Add a button next to `manParkingLocation` that sets its value to `${AppState.gpsLat}, ${AppState.gpsLng}` when tapped. Also add this to the scanned/OCR create flows if those forms have parking.
---
## Feature 6: Navigation tool
**Current:** Map shows asset pins with a popup "Directions" link to Google Maps
**Target:** A dedicated navigation tool in the app that shows route from current location to the selected asset
**Files:**
- `static/index.html` — new tab/panel or enhancement to map/asset detail
**Approach:** Add a "🧭 Navigate" button in asset detail view and map popups. Opens a full-screen map with:
- Current GPS position marker (blue dot)
- Asset location marker
- Route line between them (straight line using Leaflet polyline, since Leaflet routing needs external service)
- Distance readout
- "Open in Google Maps" fallback link
**Simpler approach:** Enhance existing popup "Directions" link to include the user's current GPS as the origin (`&origin=lat,lng`). Also add a "Navigate" tab that shows current location → selected asset with distance.
---
## Execution Order
All 6 features are independent (touch different parts of frontend) except F6 may touch map code that F3 doesn't touch. Safe to parallelize.
- F1, F2, F3, F4, F5, F6 can all be done in parallel
- F2 depends on backend endpoint first
**Plan:**
1. Write backend endpoint for F2 (reverse geocode)
2. Implement all 6 frontend changes
3. Test end-to-end
4. Commit, push, update docs/wiki/issues
+124
View File
@@ -998,6 +998,72 @@ def get_asset(asset_id: int):
return result return result
# ─── Navigation endpoint ────────────────────────────────────────────────────
@app.get("/api/assets/{asset_id}/navigation")
def get_navigation(asset_id: int, lat: float = Query(...), lng: float = Query(...)):
"""Return distance, bearing, and coordinates for navigation from GPS to asset."""
import math
conn = get_db()
row = conn.execute(
"SELECT id, name, latitude, longitude, machine_id, address FROM assets WHERE id = ?",
(asset_id,),
).fetchone()
conn.close()
if row is None:
raise HTTPException(status_code=404, detail="Asset not found")
if row["latitude"] is None or row["longitude"] is None:
raise HTTPException(
status_code=400,
detail="Asset has no GPS coordinates set",
)
dest_lat, dest_lng = row["latitude"], row["longitude"]
# Haversine distance
R = 6371000 # Earth radius in meters
phi1 = math.radians(lat)
phi2 = math.radians(dest_lat)
dphi = math.radians(dest_lat - lat)
dlambda = math.radians(dest_lng - lng)
a_val = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
c_val = 2 * math.atan2(math.sqrt(a_val), math.sqrt(1 - a_val))
distance_m = R * c_val
# Bearing
y = math.sin(dlambda) * math.cos(phi2)
x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlambda)
bearing = (math.degrees(math.atan2(y, x)) + 360) % 360
# Cardinal direction
dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
idx = round(bearing / 45) % 8
cardinal = dirs[idx]
return {
"asset_id": asset_id,
"asset_name": row["name"],
"asset_lat": dest_lat,
"asset_lng": dest_lng,
"asset_address": row["address"] or None,
"origin_lat": lat,
"origin_lng": lng,
"distance_meters": round(distance_m, 1),
"distance_km": round(distance_m / 1000, 2),
"bearing": round(bearing, 1),
"cardinal": cardinal,
"google_maps_url": (
f"https://www.google.com/maps/dir/?api=1"
f"&origin={lat},{lng}"
f"&destination={dest_lat},{dest_lng}"
),
}
# ─── Task 6: PUT / DELETE /api/assets/{id} ───────────────────────────────── # ─── Task 6: PUT / DELETE /api/assets/{id} ─────────────────────────────────
@@ -2595,6 +2661,64 @@ 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
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)}")
addr = data.get("address", {})
display_name = data.get("display_name", "")
# Map Nominatim address parts to our schema
building = addr.get("building", "") or addr.get("house_number", "")
building_name = addr.get("building_name", "") or addr.get("amenity", "")
street = addr.get("road", "") or addr.get("pedestrian", "") or addr.get("path", "")
house_number = addr.get("house_number", "")
city = addr.get("city", "") or addr.get("town", "") or addr.get("village", "")
state = addr.get("state", "")
postcode = addr.get("postcode", "")
# Build a formatted address string
parts = []
if house_number and street:
parts.append(f"{house_number} {street}")
elif street:
parts.append(street)
if city:
parts.append(city)
if state:
parts.append(state)
if postcode:
parts.append(postcode)
formatted = ", ".join(parts) if parts else display_name
return {
"display_name": display_name,
"formatted": formatted,
"building_number": house_number or building,
"building_name": building_name,
"street": street,
"city": city,
"state": state,
"postcode": postcode,
"address": formatted, # mapped to our asset.address field
}
# ─── 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")
+80 -11
View File
@@ -435,8 +435,8 @@
═══════════════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════════════ */
.camera-area { .camera-area {
position: relative; background: #0a0b0f; border-radius: var(--radius-sm); position: relative; background: #0a0b0f; border-radius: var(--radius-sm);
aspect-ratio: 16/14; display: flex; align-items: center; justify-content: center; aspect-ratio: 3/4; display: flex; align-items: center; justify-content: center;
overflow: hidden; margin-bottom: 4px; overflow: hidden; margin: 0 -12px 8px -12px; max-height: none;
} }
.camera-area video { width: 100%; height: 100%; object-fit: cover; position: absolute; inset: 0; } .camera-area video { width: 100%; height: 100%; object-fit: cover; position: absolute; inset: 0; }
.camera-placeholder { text-align: center; z-index: 1; cursor: pointer; } .camera-placeholder { text-align: center; z-index: 1; cursor: pointer; }
@@ -1235,7 +1235,7 @@
<input id="manAddress" type="text" class="input-field" placeholder="Address / Trailer Number"> <input id="manAddress" type="text" class="input-field" placeholder="Address / Trailer Number">
<div class="form-row"> <div class="form-row">
<input id="manBuildingName" type="text" class="input-field" placeholder="Building Name"> <input id="manBuildingName" type="text" class="input-field" placeholder="Building Name">
<input id="manBuildingNumber" type="text" class="input-field" placeholder="Building #"> <input id="manBuildingNumber" type="text" class="input-field" placeholder="Building #" oninput="document.getElementById('manAddress').value = this.value">
</div> </div>
<div class="form-row"> <div class="form-row">
<input id="manFloor" type="text" class="input-field" placeholder="Floor"> <input id="manFloor" type="text" class="input-field" placeholder="Floor">
@@ -1246,7 +1246,10 @@
<input id="manMapLink" type="url" class="input-field" placeholder="Map link (URL)"> <input id="manMapLink" type="url" class="input-field" placeholder="Map link (URL)">
<button class="btn btn-outline btn-sm" onclick="openMapPin()" style="flex-shrink:0;">📍 Pin</button> <button class="btn btn-outline btn-sm" onclick="openMapPin()" style="flex-shrink:0;">📍 Pin</button>
</div> </div>
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location" style="margin-bottom:0;"> <div class="form-row">
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location">
<button class="btn btn-outline btn-sm" onclick="fillGpsParking('manParkingLocation')" style="flex-shrink:0;">📍 GPS</button>
</div>
</div> </div>
<!-- Keys --> <!-- Keys -->
@@ -1362,6 +1365,7 @@
<div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;">
<button class="btn btn-green btn-sm" onclick="quickCheckin()" style="flex:1;min-width:100px;">✓ Check In</button> <button class="btn btn-green btn-sm" onclick="quickCheckin()" style="flex:1;min-width:100px;">✓ Check In</button>
<button class="btn btn-outline btn-sm" id="detailDirectionsBtn" onclick="openMapLink()" style="flex:1;min-width:100px;display:none;">🗺️ Directions</button> <button class="btn btn-outline btn-sm" id="detailDirectionsBtn" onclick="openMapLink()" style="flex:1;min-width:100px;display:none;">🗺️ Directions</button>
<button class="btn btn-outline btn-sm" id="detailNavigateBtn" onclick="navigateToAsset()" style="flex:1;min-width:100px;display:none;">🧭 Navigate</button>
</div> </div>
<div style="display:flex;gap:8px;margin-bottom:10px;"> <div style="display:flex;gap:8px;margin-bottom:10px;">
<button class="btn btn-outline btn-sm" onclick="editAsset()" style="flex:1;">✏️ Edit</button> <button class="btn btn-outline btn-sm" onclick="editAsset()" style="flex:1;">✏️ Edit</button>
@@ -1582,7 +1586,7 @@
<input id="locFormAddress" type="text" class="input-field" placeholder="Address"> <input id="locFormAddress" type="text" class="input-field" placeholder="Address">
<div class="form-row"> <div class="form-row">
<input id="locFormBldgName" type="text" class="input-field" placeholder="Building name"> <input id="locFormBldgName" type="text" class="input-field" placeholder="Building name">
<input id="locFormBldgNum" type="text" class="input-field" placeholder="Building #"> <input id="locFormBldgNum" type="text" class="input-field" placeholder="Building #" oninput="document.getElementById('locFormTrailer').value = this.value">
</div> </div>
<div class="form-row"> <div class="form-row">
<input id="locFormFloor" type="text" class="input-field" placeholder="Floor"> <input id="locFormFloor" type="text" class="input-field" placeholder="Floor">
@@ -2687,7 +2691,13 @@
try { try {
const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`); const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
autoCheckin(asset.id);
showScannedAsset(asset); showScannedAsset(asset);
// F1: Auto check-in when scanning an existing asset
AppState.currentAssetId = asset.id;
autoCheckin(asset.id);
setScanStatus(`Checked in: ${asset.name}`, 'success');
document.getElementById('checkinCard').style.display = 'none';
} catch (e) { } catch (e) {
if (e.message === 'Asset not found' || e.message.includes('404')) { if (e.message === 'Asset not found' || e.message.includes('404')) {
showNewAssetForm(machineId); showNewAssetForm(machineId);
@@ -2754,6 +2764,16 @@
category: document.getElementById('newCatSelect').value || 'Other', category: document.getElementById('newCatSelect').value || 'Other',
status: document.getElementById('newStatus').value, status: document.getElementById('newStatus').value,
}; };
// F2: Auto-populate address from GPS
if (AppState.gpsLat) {
const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng);
if (geo) {
payload.address = geo.address || geo.formatted || '';
payload.building_number = geo.building_number || '';
payload.building_name = geo.building_name || '';
payload.trailer_number = geo.building_number || ''; // F4
}
}
try { try {
const asset = await api('/api/assets', { const asset = await api('/api/assets', {
method: 'POST', method: 'POST',
@@ -2819,6 +2839,32 @@
} }
} }
// ── Reverse geocode GPS → address (Nominatim) ─────────────────────────
async function reverseGeocode(lat, lng) {
if (!lat || !lng) return null;
try {
const data = await api(`/api/geocode?lat=${lat}&lng=${lng}`);
return data; // { address, building_number, building_name, ... }
} catch (e) {
console.warn('Reverse geocode failed:', e);
return null;
}
}
// ── Populate address fields from geocode result ────────────────────────
function applyGeocodeToFields(geo, prefix = 'man') {
if (!geo) return;
const set = (suffix, val) => {
const el = document.getElementById(prefix + suffix);
if (el && !el.value) el.value = val || '';
};
set('Address', geo.address || geo.formatted || '');
set('BuildingNumber', geo.building_number || '');
set('BuildingName', geo.building_name || '');
set('TrailerNumber', geo.building_number || ''); // F4: sync building# → trailer
// Don't overwrite floor/room — those are interior details GPS can't know
}
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// OCR MODE // OCR MODE
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -2918,15 +2964,26 @@
showToast('Machine ID and name are required', true); showToast('Machine ID and name are required', true);
return; return;
} }
const payload = {
machine_id: machineId, name,
category: document.getElementById('ocrCatSelect').value || 'Other',
status: document.getElementById('ocrStatus').value,
};
// F2: Auto-populate address from GPS
if (AppState.gpsLat) {
const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng);
if (geo) {
payload.address = geo.address || geo.formatted || '';
payload.building_number = geo.building_number || '';
payload.building_name = geo.building_name || '';
payload.trailer_number = geo.building_number || '';
}
}
try { try {
const asset = await api('/api/assets', { const asset = await api('/api/assets', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload),
machine_id: machineId, name,
category: document.getElementById('ocrCatSelect').value || 'Other',
status: document.getElementById('ocrStatus').value,
}),
}); });
showToast('Asset created!'); showToast('Asset created!');
document.getElementById('ocrCreateCard').style.display = 'none'; document.getElementById('ocrCreateCard').style.display = 'none';
@@ -3133,6 +3190,15 @@
else showToast('Enter a map link first', true); else showToast('Enter a map link first', true);
} }
function fillGpsParking(inputId) {
if (!AppState.gpsLat) {
showToast('GPS not available — allow location access', true);
return;
}
document.getElementById(inputId).value = AppState.gpsLat.toFixed(6) + ', ' + AppState.gpsLng.toFixed(6);
showToast('GPS coords filled!');
}
// Settings are loaded after auth (in doLogin / initAuth) // Settings are loaded after auth (in doLogin / initAuth)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -3524,7 +3590,10 @@
</div> </div>
<input id="editWalkingDirections" type="text" class="input-field" placeholder="Walking directions" value="${esc(a.walking_directions || '')}"> <input id="editWalkingDirections" type="text" class="input-field" placeholder="Walking directions" value="${esc(a.walking_directions || '')}">
<input id="editMapLink" type="text" class="input-field" placeholder="Map link (URL)" value="${esc(a.map_link || '')}"> <input id="editMapLink" type="text" class="input-field" placeholder="Map link (URL)" value="${esc(a.map_link || '')}">
<input id="editParking" type="text" class="input-field" placeholder="Parking location" value="${esc(a.parking_location || '')}"> <div class="form-row">
<input id="editParking" type="text" class="input-field" placeholder="Parking location" value="${esc(a.parking_location || '')}">
<button class="btn btn-outline btn-sm" onclick="fillGpsParking('editParking')" style="flex-shrink:0;">📍 GPS</button>
</div>
<datalist id="catList"> <datalist id="catList">
<option value="Coffee"><option value="Cold Beverage"><option value="Snacks"> <option value="Coffee"><option value="Cold Beverage"><option value="Snacks">
<option value="Cold Food"><option value="Market"><option value="Smart Cooler"> <option value="Cold Food"><option value="Market"><option value="Smart Cooler">
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB