Add GPS Map Editor to asset detail view

Add a GPS map editor card at the top of asset details, allowing users to
manually set an asset's GPS coordinates via an interactive Leaflet map.

- GPS card with Leaflet map and draggable marker
- 🔒 Lock/unlock toggle (locked by default when GPS exists)
- 💾 Save GPS button (visible only when unlocked)
- Saves via PUT /api/assets/{id} with lat/lng
- Click on map or drag marker to set position
- Fallback to Orlando coords (28.5383, -81.3792) when no GPS
- Auto-refresh detail view after save

Closes #61
This commit is contained in:
2026-05-31 01:47:54 -04:00
parent 064fc43fe3
commit 18743ffdde
+215
View File
@@ -1020,6 +1020,21 @@
.no-print { display: none !important; }
.stop-num { background: #333 !important; }
}
/* GPS Map Editor */
.gps-lock-btn {
font-size: 16px; cursor: pointer; user-select: none;
padding: 2px 6px; border-radius: 4px; transition: background 0.15s;
float: right; line-height: 1;
}
.gps-lock-btn:hover { background: rgba(255,255,255,0.08); }
.gps-lock-btn.unlocked { background: var(--amber-bg); border-radius: 4px; }
#detailGpsMap {
z-index: 1; overflow: hidden;
}
#detailGpsMap .leaflet-container {
border-radius: 8px;
}
</style>
</head>
<body>
@@ -1267,6 +1282,21 @@
</div>
</div>
<!-- GPS Map Editor -->
<div class="card" id="detailGpsCard" style="display:none;">
<div class="card-title">
📍 Location
<span id="detailGpsLockBtn" class="gps-lock-btn" title="GPS is locked — click to edit">🔒</span>
</div>
<div id="detailGpsInfo" style="font-size:12px;color:var(--text2);margin-bottom:6px;"></div>
<div id="detailGpsMap" style="height:300px;border-radius:8px;margin-bottom:8px;border:1px solid var(--border);"></div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button id="detailGpsSaveBtn" class="btn btn-green btn-sm" onclick="saveGpsCoords()" style="display:none;">💾 Save GPS</button>
<span id="detailGpsCoords" style="font-size:12px;color:var(--text2);font-family:monospace;"></span>
<span id="detailGpsStatus" style="font-size:11px;color:var(--text3);"></span>
</div>
</div>
<!-- Info card -->
<div class="card">
<div class="card-title">Asset Details</div>
@@ -3891,6 +3921,9 @@
photoCard.style.display = 'none';
}
// Render GPS Map Editor card (at top of detail view)
renderGpsCard(a);
// Location / Directions
const hasDir = a.address || a.building_name || a.building_number || a.floor || a.room || a.trailer_number || a.walking_directions || a.map_link || a.parking_location;
const dirCard = document.getElementById('detailDirections');
@@ -4053,6 +4086,188 @@
return `<div class="detail-field"><div class="df-label">${esc(label)}</div><div class="df-value"${cls}>${value}</div></div>`;
}
// ── GPS Map Editor ──────────────────────────────────────────────────
let _gpsState = { map: null, marker: null, unlocked: false, asset: null };
function renderGpsCard(a) {
const card = document.getElementById('detailGpsCard');
const mapDiv = document.getElementById('detailGpsMap');
const infoEl = document.getElementById('detailGpsInfo');
const coordsEl = document.getElementById('detailGpsCoords');
const lockBtn = document.getElementById('detailGpsLockBtn');
const saveBtn = document.getElementById('detailGpsSaveBtn');
const statusEl = document.getElementById('detailGpsStatus');
// Clean up previous map instance
if (_gpsState.map) {
_gpsState.map.remove();
_gpsState.map = null;
_gpsState.marker = null;
}
_gpsState.asset = a;
const hasGps = a.latitude != null && a.longitude != null;
// If no GPS, show card as unlocked and editable
_gpsState.unlocked = !hasGps;
card.style.display = 'block';
lockBtn.onclick = toggleGpsLock;
if (hasGps) {
infoEl.textContent = '📍 GPS coordinates on file — click 🔒 to unlock and edit';
lockBtn.textContent = '🔒';
lockBtn.className = 'gps-lock-btn';
lockBtn.title = 'GPS is locked — click to edit';
saveBtn.style.display = 'none';
statusEl.textContent = '';
} else {
infoEl.textContent = '📍 No GPS data — tap the map to set coordinates';
lockBtn.textContent = '🔓';
lockBtn.className = 'gps-lock-btn unlocked';
lockBtn.title = 'Editable — tap lock to disable editing';
statusEl.textContent = 'Editable';
}
const defaultLat = hasGps ? a.latitude : 28.5383;
const defaultLng = hasGps ? a.longitude : -81.3792;
// Update coords display
coordsEl.textContent = `${defaultLat.toFixed(6)}, ${defaultLng.toFixed(6)}`;
// Initialize map next tick so the div is in DOM
requestAnimationFrame(() => {
if (!mapDiv.offsetParent && mapDiv.parentNode) {
// Map may not be visible yet — use setTimeout
setTimeout(() => initGpsEditorMap(defaultLat, defaultLng, a), 100);
} else {
initGpsEditorMap(defaultLat, defaultLng, a);
}
});
}
function initGpsEditorMap(lat, lng, a) {
const mapDiv = document.getElementById('detailGpsMap');
if (!mapDiv || mapDiv.offsetHeight === 0) {
// Retry if map isn't visible yet (hidden tab, not yet scrolled)
setTimeout(() => initGpsEditorMap(lat, lng, a), 300);
return;
}
const map = L.map(mapDiv, {
zoomControl: true,
attributionControl: false,
}).setView([lat, lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(map);
const marker = L.marker([lat, lng], {
draggable: _gpsState.unlocked,
}).addTo(map);
marker.on('dragend', function() {
const pos = marker.getLatLng();
const latRounded = Math.round(pos.lat * 1000000) / 1000000;
const lngRounded = Math.round(pos.lng * 1000000) / 1000000;
marker.setLatLng([latRounded, lngRounded]);
document.getElementById('detailGpsCoords').textContent =
`${latRounded.toFixed(6)}, ${lngRounded.toFixed(6)}`;
});
// When map is clicked (not dragging), move marker there
map.on('click', function(e) {
if (!_gpsState.unlocked) return;
const latRounded = Math.round(e.latlng.lat * 1000000) / 1000000;
const lngRounded = Math.round(e.latlng.lng * 1000000) / 1000000;
marker.setLatLng([latRounded, lngRounded]);
document.getElementById('detailGpsCoords').textContent =
`${latRounded.toFixed(6)}, ${lngRounded.toFixed(6)}`;
});
_gpsState.map = map;
_gpsState.marker = marker;
// Invalidate size after a short delay to fix rendering in hidden containers
setTimeout(() => map.invalidateSize(), 350);
}
function toggleGpsLock() {
const lockBtn = document.getElementById('detailGpsLockBtn');
const saveBtn = document.getElementById('detailGpsSaveBtn');
const statusEl = document.getElementById('detailGpsStatus');
const infoEl = document.getElementById('detailGpsInfo');
_gpsState.unlocked = !_gpsState.unlocked;
if (_gpsState.unlocked) {
lockBtn.textContent = '🔓';
lockBtn.className = 'gps-lock-btn unlocked';
lockBtn.title = 'Click to lock GPS';
saveBtn.style.display = '';
statusEl.textContent = 'Editable — drag the marker or tap the map';
infoEl.textContent = '📍 Drag the marker or click the map to set GPS position';
} else {
lockBtn.textContent = '🔒';
lockBtn.className = 'gps-lock-btn';
lockBtn.title = 'GPS is locked — click to edit';
saveBtn.style.display = 'none';
statusEl.textContent = 'Locked';
infoEl.textContent = '📍 GPS position is locked — click 🔒 to edit';
}
// Update marker draggability
if (_gpsState.marker) {
_gpsState.marker.dragging[_gpsState.unlocked ? 'enable' : 'disable']();
}
}
async function saveGpsCoords() {
if (!_gpsState.marker || !_gpsState.asset) return;
const pos = _gpsState.marker.getLatLng();
const lat = Math.round(pos.lat * 1000000) / 1000000;
const lng = Math.round(pos.lng * 1000000) / 1000000;
const saveBtn = document.getElementById('detailGpsSaveBtn');
saveBtn.textContent = '⏳ Saving...';
saveBtn.disabled = true;
try {
await api('/api/assets/' + _gpsState.asset.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latitude: lat, longitude: lng }),
});
showToast('✅ GPS coordinates saved!', false);
// Update asset data and re-lock
_gpsState.asset.latitude = lat;
_gpsState.asset.longitude = lng;
// Re-lock
_gpsState.unlocked = false;
const lockBtn = document.getElementById('detailGpsLockBtn');
lockBtn.textContent = '🔒';
lockBtn.className = 'gps-lock-btn';
lockBtn.title = 'GPS is locked — click to edit';
document.getElementById('detailGpsSaveBtn').style.display = 'none';
document.getElementById('detailGpsStatus').textContent = 'Locked';
document.getElementById('detailGpsInfo').textContent = '📍 GPS coordinates saved';
if (_gpsState.marker) {
_gpsState.marker.dragging.disable();
}
// Refresh detail to show updated data
setTimeout(() => viewAsset(_gpsState.asset.id), 1200);
} catch (e) {
showToast('Failed to save GPS: ' + e.message, true);
} finally {
saveBtn.textContent = '💾 Save GPS';
saveBtn.disabled = false;
}
}
function statusBadge(status) {
return '';
}