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:
@@ -1020,6 +1020,21 @@
|
|||||||
.no-print { display: none !important; }
|
.no-print { display: none !important; }
|
||||||
.stop-num { background: #333 !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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1267,6 +1282,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Info card -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">Asset Details</div>
|
<div class="card-title">Asset Details</div>
|
||||||
@@ -3891,6 +3921,9 @@
|
|||||||
photoCard.style.display = 'none';
|
photoCard.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render GPS Map Editor card (at top of detail view)
|
||||||
|
renderGpsCard(a);
|
||||||
|
|
||||||
// Location / Directions
|
// 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 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');
|
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>`;
|
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) {
|
function statusBadge(status) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user