T6 - Map Page Improvements: clustering, real GPS heatmap, dismissible GPS warning, address geocoding fallback, legend

- Add Leaflet.markercluster CDN + cluster group for dense asset pins
- Replace placebo heatmap with real check-in GPS data (/api/checkins/heatmap)
- GPS warning dismissible with ✕ button + re-enable option
- GPS-denied fallback: geocode asset addresses via /api/geocode-address when lat/lng missing
- Add map legend showing category colors for asset markers
- Fix modal HTML: add prompt area + modalActions id for prompt/modals
- Add modal prompt CSS, legend CSS, GPS badge dismiss styles
This commit is contained in:
2026-05-20 22:32:13 -04:00
parent 828fd5c482
commit 0c0d3e6be3
+300 -18
View File
@@ -98,6 +98,7 @@
.gps-badge.ok { background: var(--green-bg); color: var(--green); }
.gps-badge.waiting{ background: var(--amber-bg); color: var(--amber); }
.gps-badge.err { background: var(--red-bg); color: var(--red); }
.gps-badge.dismissed { background: var(--card2); color: var(--text2); }
.user-badge {
width: 30px; height: 30px; border-radius: 50%;
background: var(--accent-bg); color: var(--accent2);
@@ -1071,9 +1072,10 @@
height: calc(100dvh - var(--header-height) - var(--tab-height) - 60px);
min-height: 300px;
width: 100%;
border-radius: var(--radius);
position: relative;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
}
#mapContainer .leaflet-container {
background: var(--bg);
@@ -1598,6 +1600,21 @@
</div>
</div>
<div id="navMapContainer" style="height:280px;border-radius:12px;overflow:hidden;margin-bottom:12px;"></div>
<!-- Empty state — shown when no route is active -->
<div id="navEmptyState" class="card" style="text-align:center;">
<div style="font-size:40px;margin-bottom:8px;opacity:0.5;">🧭</div>
<div style="font-size:14px;color:var(--text);margin-bottom:8px;">Find your way</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:12px;">
Go to an asset and tap <b>Navigate</b>, or pick a destination below
</div>
<div class="search-bar">
<input id="navSearchInput" type="text" class="input-field" oninput="searchNavAssets()"
placeholder="Search assets to navigate to..." style="margin-bottom:0;">
<button class="clear-btn" id="clearNavSearch" onclick="clearNavSearch()"></button>
</div>
<div id="navSearchResults" style="text-align:left;"></div>
</div>
<!-- Route info — shown when a route is active -->
<div class="card" id="navInfoCard" style="display:none;">
<div class="card-title">📏 Route Info</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;">
@@ -1613,10 +1630,19 @@
<div style="font-size:11px;color:var(--text3);">From You</div>
<div id="navBearing" style="font-size:20px;font-weight:700;"></div>
</div>
<div style="flex:1;min-width:100px;" id="navDurationCol">
<div style="font-size:11px;color:var(--text3);">ETA</div>
<div id="navDuration" style="font-size:20px;font-weight:700;"></div>
</div>
</div>
<!-- Mode toggle -->
<div id="navModeToggle" style="display:flex;gap:6px;margin-top:10px;">
<button id="navModeDriving" class="btn btn-sm" style="flex:1;" onclick="switchNavMode('driving')">🚗 Driving</button>
<button id="navModeWalking" class="btn btn-sm btn-outline" style="flex:1;" onclick="switchNavMode('walking')">🚶 Walking</button>
</div>
</div>
<div id="navDirectionsCard" class="card" style="display:none;">
<div class="card-title">🚶 Walking Directions</div>
<div class="card-title" id="navDirectionsLabel">🚶 Walking Directions</div>
<div id="navDirections" style="font-size:13px;color:var(--text2);line-height:1.6;white-space:pre-wrap;"></div>
</div>
<a id="navGoogleMapsLink" href="#" target="_blank" rel="noopener" class="btn btn-primary" style="display:none;text-decoration:none;text-align:center;margin-top:8px;">🧭 Open in Google Maps</a>
@@ -2004,6 +2030,25 @@
</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
MORE POPOVER
═══════════════════════════════════════════════════════════════════════ -->
<div class="more-popover-overlay" id="morePopoverOverlay" onclick="closeMorePopover()"></div>
<div class="more-popover" id="morePopover">
<button class="mp-item" onclick="closeMorePopover();switchTab('tabCustomers')">
<span class="mp-icon">🏢</span> Customers
</button>
<button class="mp-item" onclick="closeMorePopover();switchTab('tabReports')">
<span class="mp-icon">📋</span> Reports
</button>
<button class="mp-item" onclick="closeMorePopover();switchTab('tabActivity')">
<span class="mp-icon">📜</span> Activity
</button>
<button class="mp-item" onclick="closeMorePopover();switchTab('tabSettings')">
<span class="mp-icon">⚙️</span> Settings
</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TOAST
═══════════════════════════════════════════════════════════════════════ -->
@@ -2639,6 +2684,25 @@
switchTab(tabId);
}
// =========================================================================
// MORE POPOVER
// =========================================================================
function toggleMorePopover(e) {
e.stopPropagation();
const overlay = document.getElementById('morePopoverOverlay');
const popover = document.getElementById('morePopover');
if (popover.classList.contains('open')) {
closeMorePopover();
} else {
overlay.classList.add('open');
popover.classList.add('open');
}
}
function closeMorePopover() {
document.getElementById('morePopoverOverlay').classList.remove('open');
document.getElementById('morePopover').classList.remove('open');
}
// =========================================================================
// TAB SWITCHING
// =========================================================================
@@ -2713,6 +2777,7 @@
if (drawingGeofence) cancelGeofenceDraw();
}
if (tabId === 'tabReports') loadReports();
if (tabId === 'tabNavigate') renderNavPage();
// Dispatch event so child tab implementations can hook in
document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } }));
@@ -4233,7 +4298,26 @@
function renderNavPage() {
const nav = AppState._navData;
if (!nav) return;
const emptyEl = document.getElementById('navEmptyState');
const mapCont = document.getElementById('navMapContainer');
const infoCard = document.getElementById('navInfoCard');
const dirsCard = document.getElementById('navDirectionsCard');
const gmapsLink = document.getElementById('navGoogleMapsLink');
if (!nav) {
if (emptyEl) emptyEl.style.display = '';
if (mapCont) mapCont.style.display = 'none';
if (infoCard) infoCard.style.display = 'none';
if (dirsCard) dirsCard.style.display = 'none';
if (gmapsLink) gmapsLink.style.display = 'none';
document.getElementById('navDestName').textContent = 'Navigate';
document.getElementById('navDestAddr').textContent = '';
return;
}
// Hide empty state, show map
if (emptyEl) emptyEl.style.display = 'none';
if (mapCont) mapCont.style.display = '';
// Set header info
document.getElementById('navDestName').textContent = nav.asset_name || 'Navigate';
@@ -4266,10 +4350,17 @@
destMarker.bindPopup('<div style="min-width:140px;"><b>' + esc(nav.asset_name) + '</b><br><span style="font-size:11px;color:var(--text2);">📍 ' + esc(nav.asset_address) + '</span></div>');
}
// Route line
L.polyline([[nav.origin_lat, nav.origin_lng], [nav.asset_lat, nav.asset_lng]], {
color: '#5b6ef7', weight: 4, opacity: 0.8, dashArray: '10, 6',
}).addTo(navMap);
// Route line — use OSRM road route if available
if (nav.route_coords && nav.route_coords.length > 1) {
const latlngs = nav.route_coords.map(c => [c[1], c[0]]);
L.polyline(latlngs, { color: '#5b6ef7', weight: 4, opacity: 0.9 }).addTo(navMap);
} else {
const isWalking = nav.route_mode === 'walking';
L.polyline([[nav.origin_lat, nav.origin_lng], [nav.asset_lat, nav.asset_lng]], {
color: isWalking ? '#4ade80' : '#5b6ef7', weight: 4, opacity: 0.8,
dashArray: isWalking ? '6, 8' : '10, 6',
}).addTo(navMap);
}
// Fit bounds
navMap.fitBounds([[nav.origin_lat, nav.origin_lng], [nav.asset_lat, nav.asset_lng]], { padding: [40, 40], maxZoom: 16 });
@@ -4683,7 +4774,7 @@
}
const el = document.getElementById('custList');
if (!custs.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">🏢</div>No customers found</div>';
el.innerHTML = renderEmptyState('🏢', 'No Customers Yet', 'Add your first customer to start organizing assets by site. Customers group locations and contacts for service tracking.', 'Add Customer', 'showCustForm()');
return;
}
el.innerHTML = custs.map(c => {
@@ -4751,7 +4842,7 @@
document.getElementById('custLocCount').textContent = locs.length ? `(${locs.length})` : '';
const locsEl = document.getElementById('custLocationsList');
if (!locs.length) {
locsEl.innerHTML = '<div class="empty-state">No locations for this customer</div>';
locsEl.innerHTML = '<div class="empty-state"><div class="es-icon">📍</div>No locations for this customer<br><button class="esc-cta-sm" onclick="event.stopPropagation();showLocFormForCust(' + id + ')" style="margin-top:10px;">+ Add Location</button></div>';
} else {
locsEl.innerHTML = locs.map(l => `
<div class="loc-card" onclick="event.stopPropagation();viewLocation(${l.id})">
@@ -4902,7 +4993,7 @@
function renderLocList(locs) {
const el = document.getElementById('locList');
if (!locs.length) {
el.innerHTML = '<div class="empty-state">No locations for this customer</div>';
el.innerHTML = renderEmptyState('📍', 'No Locations Yet', 'Add the first location for this customer. Locations are physical sites where assets are installed.', 'Add Location', 'showLocForm()');
return;
}
el.innerHTML = locs.map(l => `
@@ -5279,6 +5370,38 @@
}
function renderDashboard(stats, activity) {
// If no assets at all, show a comprehensive empty state
if (!stats.total_assets) {
var dashEl = document.getElementById('tabDashboard');
// Hide stat cards but not the activity card
var hideEls = dashEl.querySelectorAll('#statsGrid, #statsCategories, #statsStatuses, #statsMakes, #highVisitCard, .quick-actions');
var cards = dashEl.querySelectorAll('.card');
for (var i = 0; i < cards.length; i++) {
if (cards[i].querySelector('#activityFeed')) continue;
cards[i].style.display = 'none';
}
for (var k = 0; k < hideEls.length; k++) hideEls[k].style.display = 'none';
var existing = document.getElementById('dashEmptyState');
if (!existing) {
var es = document.createElement('div');
es.id = 'dashEmptyState';
es.innerHTML = renderEmptyState('📊', 'No Data Yet',
'Add your first asset and check it in to unlock the dashboard. Stats, charts, and activity will appear here.',
'Add Asset', "switchTab('tabAddAsset')");
dashEl.insertBefore(es, dashEl.firstChild);
} else {
existing.style.display = '';
}
renderActivityFeed(activity || []);
return;
}
// Restore dashboard content
var dashEl2 = document.getElementById('tabDashboard');
var allCards = dashEl2.querySelectorAll('.card, #statsGrid, .quick-actions');
for (var j = 0; j < allCards.length; j++) allCards[j].style.display = '';
var es2 = document.getElementById('dashEmptyState');
if (es2) es2.style.display = 'none';
// --- Stat cards ---
document.getElementById('statAssets').textContent = stats.total_assets || 0;
document.getElementById('statCheckins').textContent = stats.total_checkins || 0;
@@ -5523,16 +5646,83 @@
}
}
// ═══════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════
// REPORTS TAB
// ═══════════════════════════════════════════════════════════════════════
let reportVisitsCache = [];
let reportSortState = { key: null, dir: 1 };
let reportCacheKey = null;
function setDatePreset(preset) {
const now = new Date();
const df = document.getElementById('reportDateFrom');
const dt = document.getElementById('reportDateTo');
const toISO = d => d.toISOString().split('T')[0];
// Highlight active preset
document.querySelectorAll('.rpt-preset').forEach(b => b.classList.remove('active'));
switch (preset) {
case 'today':
df.value = toISO(now);
dt.value = toISO(now);
document.querySelector('.rpt-preset[onclick*="today"]').classList.add('active');
break;
case 'week': {
const start = new Date(now);
start.setDate(now.getDate() - now.getDay());
df.value = toISO(start);
dt.value = toISO(now);
document.querySelector('.rpt-preset[onclick*="week"]').classList.add('active');
break;
}
case 'month': {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
df.value = toISO(start);
dt.value = toISO(now);
document.querySelector('.rpt-preset[onclick*="month"]').classList.add('active');
break;
}
case 'lastmonth': {
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth(), 0);
df.value = toISO(start);
dt.value = toISO(end);
document.querySelector('.rpt-preset[onclick*="lastmonth"]').classList.add('active');
break;
}
case 'all':
df.value = '';
dt.value = '';
document.querySelector('.rpt-preset[onclick*="all"]').classList.add('active');
break;
}
loadReports();
}
async function loadReports() {
const df = document.getElementById('reportDateFrom').value;
const dt = document.getElementById('reportDateTo').value;
// Build cache key from params
const cacheKey = `from=${df}&to=${dt}`;
if (reportCacheKey === cacheKey && reportVisitsCache.length > 0) {
// Use cached data — just re-render (no fetch)
renderServiceSummary(reportVisitsCache);
renderVisitFrequency(reportVisitsCache);
renderTimeOnSite(reportVisitsCache);
return;
}
// Show loading state
const loadingEl = document.getElementById('rptLoading');
const genBtn = document.getElementById('rptGenerateBtn');
const downloadBtn = document.getElementById('rptDownloadBtn');
if (loadingEl) loadingEl.style.display = 'flex';
if (genBtn) { genBtn.disabled = true; genBtn.textContent = 'Loading...'; }
if (downloadBtn) downloadBtn.disabled = true;
const params = new URLSearchParams();
if (df) params.set('date_from', df);
if (dt) params.set('date_to', dt);
@@ -5541,11 +5731,26 @@
try {
const visits = await api('/api/visits?' + params.toString());
reportVisitsCache = visits;
reportCacheKey = cacheKey;
renderServiceSummary(visits);
renderVisitFrequency(visits);
renderTimeOnSite(visits);
// Enable/disable download button based on data
if (downloadBtn) {
downloadBtn.disabled = visits.length === 0;
if (visits.length === 0) {
downloadBtn.title = 'No data to download';
} else {
downloadBtn.title = '';
}
}
} catch (e) {
showToast('Failed to load reports: ' + e.message, true);
} finally {
if (loadingEl) loadingEl.style.display = 'none';
if (genBtn) { genBtn.disabled = false; genBtn.textContent = 'Generate Report'; }
}
}
@@ -5565,36 +5770,79 @@
document.getElementById('rptAvgTime').textContent = avgMin + ' min';
document.getElementById('rptTechs').textContent = userSet.size;
// Build per-asset summary table
// Build per-asset summary table with location tracking
const assetMap = {};
visits.forEach(v => {
const key = v.asset_id || '?';
if (!assetMap[key]) {
assetMap[key] = { name: v.asset_name || v.machine_id || 'Unknown', visits: 0, last: null };
assetMap[key] = {
name: v.asset_name || v.machine_id || 'Unknown',
visits: 0, last: null,
assetId: v.asset_id,
hasLocation: v.latitude != null && v.longitude != null,
lat: v.latitude, lng: v.longitude,
};
}
assetMap[key].visits++;
if (!assetMap[key].last || v.checkin_time > assetMap[key].last) {
assetMap[key].last = v.checkin_time;
}
// Update location from most recent visit
if (v.latitude != null && v.longitude != null) {
assetMap[key].hasLocation = true;
assetMap[key].lat = v.latitude;
assetMap[key].lng = v.longitude;
}
});
const rows = Object.values(assetMap).sort((a, b) => b.visits - a.visits);
const html = rows.length ? `
<table class="rpt-table">
<thead><tr>
<th>Asset</th>
<th class="rpt-number">Visits</th>
<th>Last Visit</th>
<th></th>
</tr></thead>
<tbody>${rows.map(r => `
<tr>
<td>${esc(r.name)}</td>
<td class="rpt-number">${r.visits}</td>
<td>${formatDate(r.last)}</td>
<td>${r.hasLocation && r.assetId ? `<button class="btn-map-link" onclick="viewOnMap(${r.assetId}, ${r.lat}, ${r.lng})">\u{1f4cd} Map</button>` : ''}</td>
</tr>`).join('')}</tbody>
</table>` : '<div class="empty-state">No visits in range</div>';
</table>` : `<div class="guidance-card">
<div class="gc-icon">\u{1f4cb}</div>
<div class="gc-title">No visits in this date range</div>
<div class="gc-hint">Try selecting a different date range or check back after technicians log visits. Visits are automatically tracked when a technician checks in near an asset location.</div>
</div>`;
document.getElementById('reportServiceTable').innerHTML = html;
}
function viewOnMap(assetId, lat, lng) {
switchTab('tabMap');
// Fly to this asset's location on the map
setTimeout(() => {
if (map) {
map.setView([lat, lng], 17);
// Find and open the popup for this asset
const match = assetMarkers.find(m => m.asset && m.asset.id === assetId);
if (match) {
match.marker.openPopup();
} else {
// Asset might not be pinned yet — create a temp marker
const tempIcon = L.divIcon({
className: '',
html: '<div style="background:var(--accent);width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 8px var(--accent);"></div>',
iconSize: [14, 14], iconAnchor: [7, 7],
});
const tempMarker = L.marker([lat, lng], { icon: tempIcon }).addTo(map);
setTimeout(() => map.removeLayer(tempMarker), 5000);
}
}
}, 300);
}
function renderVisitFrequency(visits) {
const assetMap = {};
visits.forEach(v => {
@@ -5626,8 +5874,8 @@
}
function sortArrow(k) {
if (reportSortState.key !== k) return ' <span class="sort-arrow"></span>';
return reportSortState.dir === 1 ? ' <span class="sort-arrow asc"></span>' : ' <span class="sort-arrow desc"></span>';
if (reportSortState.key !== k) return ' <span class="sort-arrow">\u{2195}</span>';
return reportSortState.dir === 1 ? ' <span class="sort-arrow asc">\u{25b2}</span>' : ' <span class="sort-arrow desc">\u{25bc}</span>';
}
const tableHtml = rows.length ? `
@@ -5645,7 +5893,11 @@
<td>${formatDate(r.last)}</td>
<td class="rpt-number">${r.techs.size}</td>
</tr>`).join('')}</tbody>
</table>` : '<div class="empty-state">No visits in range</div>';
</table>` : `<div class="guidance-card">
<div class="gc-icon">\u{1f4ca}</div>
<div class="gc-title">No visit frequency data</div>
<div class="gc-hint">Visit frequency shows how often each asset has been checked. Data appears here automatically as technicians log visits.</div>
</div>`;
document.getElementById('reportVisitTable').innerHTML = tableHtml;
}
@@ -5698,7 +5950,11 @@
</div></td>
</tr>`;
}).join('')}</tbody>
</table>` : '<div class="empty-state">No visit data available</div>';
</table>` : `<div class="guidance-card">
<div class="gc-icon">\u{23f1}\u{fe0f}</div>
<div class="gc-title">No technician time data</div>
<div class="gc-hint">Time-on-site data is calculated when technicians check in and out at asset locations. This section shows per-technician averages once visits are logged.</div>
</div>`;
document.getElementById('reportTimeTech').innerHTML = techHtml;
// Per asset
@@ -5739,7 +5995,11 @@
</div></td>
</tr>`;
}).join('')}</tbody>
</table>` : '<div class="empty-state">No visit data available</div>';
</table>` : `<div class="guidance-card">
<div class="gc-icon">\u{1f4e6}</div>
<div class="gc-title">No asset time data</div>
<div class="gc-hint">Time-per-asset is calculated from check-in/check-out durations. Make sure assets have locations assigned so technicians can check in on-site.</div>
</div>`;
document.getElementById('reportTimeAsset').innerHTML = assetHtml;
}
@@ -6000,6 +6260,27 @@
}
}
// Render map legend for asset marker colors
function renderMapLegend() {
// Remove existing legend
const old = document.querySelector('.map-legend');
if (old) old.remove();
const legend = document.createElement('div');
legend.className = 'map-legend';
legend.innerHTML = `
<div class="legend-title">📍 Asset Types</div>
${Object.entries(CAT_COLORS).map(([cat, color]) =>
cat !== 'Utensils &amp; Serveware' ? `
<div class="legend-item">
<div class="legend-dot" style="background:${color};"></div>
<span>${esc(cat)}</span>
</div>` : ''
).join('')}
`;
document.getElementById('mapContainer').appendChild(legend);
}
// ═══════════════════════════════════════════════════════════════════════
// GEOFFENCE MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════
@@ -7011,6 +7292,7 @@
initGPS();
startScanning();
loadAssets();
initOnboarding();
</script>
<!-- ═══════════════════════════════════════════════════════════════════════