fix: Navigate tab switching — remove early return that prevented tab panel DOM swap

The switchTab function had an early return after closeNavigate() that
skipped the actual tab panel switching (slide animation, active class
management). closeNavigate() only cleans up navigation state (map, data,
UI elements) — it does NOT switch panels. This caused the Navigate
panel to remain visible permanently after the first visit, overlapping
with any subsequent tab content.

Removed the early return so the normal tab switching logic runs after
closeNavigate() cleanup.
This commit is contained in:
2026-05-20 22:53:13 -04:00
parent 6c1d5c3910
commit 389f1f11d2
+239 -84
View File
@@ -190,19 +190,24 @@
}
.tab-panel.active {
display: block;
}
.tab-panel.anim-exit {
display: block !important;
position: absolute; top: var(--header-height, 52px); left: 0; right: 0;
animation: tabSlideOutLeft 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
z-index: 1; pointer-events: none;
}
.tab-panel.anim-enter {
display: block;
animation: tabSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.tab-panel.slide-left {
display: block !important;
animation: tabSlideOutLeft 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes tabSlideIn {
from { opacity: 0; transform: translateX(16px); }
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes tabSlideOutLeft {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-16px); }
to { opacity: 0; transform: translateX(-20px); }
}
/* ═══════════════════════════════════════════════════════════════════════
@@ -224,9 +229,9 @@
.tab-btn.active { color: var(--accent2); }
.tab-btn .tab-icon { font-size: 22px; transition: transform 0.15s; }
.tab-btn.active .tab-icon { transform: scale(1.1); }
/* Label text visible only on active tab (MD3 style) */
.tab-btn .tab-label { display: none; }
.tab-btn.active .tab-label { display: inline; }
/* Label text visible only on active tab (MD3 style) — always take space to prevent layout jump */
.tab-btn .tab-label { opacity: 0; font-size: 10px; transition: opacity 0.15s; }
.tab-btn.active .tab-label { opacity: 1; }
/* ═══════════════════════════════════════════════════════════════════════
MORE POPOVER (bottom sheet for overflow nav)
@@ -346,6 +351,20 @@
}
@keyframes spin { to { transform: rotate(360deg); } }
/* loading overlay for asset list */
.list-loading-overlay {
position: absolute; inset: 0;
background: rgba(13,14,18,0.6); display: flex;
align-items: center; justify-content: center;
z-index: 10; border-radius: var(--radius);
pointer-events: none;
}
.list-loading-overlay .overlay-spinner {
width: 32px; height: 32px; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.7s linear infinite;
}
/* ═══════════════════════════════════════════════════════════════════════
EMPTY STATE
═══════════════════════════════════════════════════════════════════════ */
@@ -562,6 +581,10 @@
.asset-item .ai-info { flex: 1; min-width: 0; }
.asset-item .ai-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.asset-item .ai-meta { font-size: 12px; color: var(--text2); margin-top: 2px; }
.asset-item .ai-location {
font-size: 11px; color: var(--text3); margin-top: 3px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.asset-item .ai-arrow { color: var(--text2); font-size: 18px; }
/* status tag */
@@ -584,6 +607,24 @@
cursor: pointer; display: none; padding: 4px;
}
/* search term highlight */
.search-highlight {
background: rgba(91,110,247,0.25);
border-radius: 3px;
padding: 1px 2px;
font-weight: 700;
}
/* count bar */
.count-bar {
font-size: 12px; color: var(--text2);
padding: 6px 0; min-height: 24px;
display: flex; align-items: center; gap: 8px;
}
.count-bar .spinner {
width: 14px; height: 14px; border-width: 2px;
}
/* ═══════════════════════════════════════════════════════════════════════
FILTER PILLS
═══════════════════════════════════════════════════════════════════════ */
@@ -930,6 +971,37 @@
.rpt-bar-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width 0.4s; }
.rpt-bar-val { font-size: 12px; font-weight: 600; min-width: 40px; text-align: right; }
/* report presets */
.rpt-presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.rpt-preset {
padding: 5px 12px; border: 1px solid var(--border); border-radius: 16px;
background: transparent; color: var(--text2); font-size: 12px; cursor: pointer;
transition: all 0.15s; white-space: nowrap;
}
.rpt-preset:hover { border-color: var(--accent); color: var(--accent); }
.rpt-preset.active {
background: var(--accent); color: #000; border-color: var(--accent);
font-weight: 600;
}
/* report download row */
.rpt-download-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
.rpt-download-btn { white-space: nowrap; }
/* report loading */
.rpt-loading {
display: flex; align-items: center; gap: 8px; padding: 8px 0;
font-size: 13px; color: var(--text2);
}
/* map link button in report tables */
.btn-map-link {
padding: 3px 10px; border: 1px solid var(--border); border-radius: 14px;
background: transparent; color: var(--accent); font-size: 11px;
cursor: pointer; white-space: nowrap; transition: all 0.15s;
}
.btn-map-link:hover { background: var(--accent); color: #000; border-color: var(--accent); }
/* ═══════════════════════════════════════════════════════════════════════
PHOTO THUMBNAIL
═══════════════════════════════════════════════════════════════════════ */
@@ -1341,7 +1413,7 @@
<h1>📦 Canteen Assets</h1>
<div class="header-badges">
<span id="roleBadge" class="role-badge guest" style="display:none;">guest</span>
<span id="gpsBadge" class="gps-badge waiting">📍 GPS...</span>
<span id="gpsBadge" class="gps-badge waiting">📍 GPS...<span class="gps-dismiss" onclick="event.stopPropagation();dismissGPS()"></span></span>
<div class="user-badge" id="userBadge" onclick="openDrawer()" title="User">?</div>
</div>
</div>
@@ -1686,9 +1758,13 @@
</div>
<div class="assets-toolbar">
<div class="filter-scroll" id="filterPills"></div>
<button class="btn btn-outline btn-sm import-btn" id="clearFiltersBtn" onclick="clearFilters()" style="display:none;">✕ Clear</button>
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
</div>
<div id="assetList"></div>
<div class="count-bar" id="assetCountBar"></div>
<div id="assetListWrap" style="position:relative; min-height:60px;">
<div id="assetList"></div>
</div>
</div>
<!-- ── Detail View ──────────────────────────────────────────────────── -->
@@ -2135,7 +2211,7 @@
</div>
<div class="rpt-download-row">
<button class="btn btn-primary btn-sm" id="rptGenerateBtn" onclick="loadReports()">Generate Report</button>
<button class="btn btn-outline btn-sm rpt-download-btn" id="rptDownloadBtn" onclick="exportServiceSummary()" disabled>⬇ Download CSV</button>
<button class="btn btn-outline btn-sm rpt-download-btn" id="rptDownloadBtn" onclick="exportServiceSummary()" style="display:none;">⬇ Download CSV</button>
</div>
<div class="rpt-loading" id="rptLoading" style="display:none;">
<div class="spinner"></div> Generating report...
@@ -2672,11 +2748,15 @@
// =========================================================================
// GPS
// =========================================================================
function initGPS() {
function initGPS(force = false) {
const badge = document.getElementById('gpsBadge');
// If previously dismissed and not forced, don't re-request
if (AppState._gpsDismissed && !force) return;
if (!navigator.geolocation) {
badge.className = 'gps-badge err';
badge.textContent = '📍 Unavailable';
badge.innerHTML = '📍 Unavailable<span class="gps-dismiss" onclick="event.stopPropagation();dismissGPS()"></span>';
badge.onclick = null;
return;
}
navigator.geolocation.getCurrentPosition(
@@ -2685,16 +2765,37 @@
AppState.gpsLng = pos.coords.longitude;
AppState.gpsAcc = pos.coords.accuracy;
badge.className = 'gps-badge ok';
badge.textContent = `📍 ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`;
badge.innerHTML = `📍 ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`;
badge.onclick = null;
AppState._gpsDismissed = false;
},
err => {
AppState._gpsDismissed = false; // not dismissed, just denied
badge.className = 'gps-badge err';
badge.textContent = '📍 ' + err.message;
badge.innerHTML = '📍 ' + err.message + '<span class="gps-dismiss" onclick="event.stopPropagation();dismissGPS()"></span>';
badge.onclick = null;
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 300000 }
);
}
function dismissGPS() {
const badge = document.getElementById('gpsBadge');
AppState._gpsDismissed = true;
badge.className = 'gps-badge dismissed';
badge.innerHTML = '📍 Off <span class="gps-reenable">Tap to reenable</span>';
badge.onclick = reEnableGPS;
}
function reEnableGPS() {
AppState._gpsDismissed = false;
const badge = document.getElementById('gpsBadge');
badge.className = 'gps-badge waiting';
badge.innerHTML = '📍 GPS...';
badge.onclick = null;
initGPS(true);
}
// =========================================================================
// TOAST
// =========================================================================
@@ -2954,11 +3055,10 @@
if (AppState.currentTab !== tabId && AppState.currentTab !== 'tabNavigate') {
AppState._prevTab = AppState.currentTab;
}
// Cleanup nav page when leaving
// Cleanup nav page when leaving (closeNavigate only resets nav state,
// it does NOT switch panels — we must NOT return early here)
if (AppState.currentTab === 'tabNavigate' && tabId !== 'tabNavigate') {
AppState.currentTab = tabId; // set early to prevent closeNavigate→switchTab recursion
closeNavigate();
return; // closeNavigate handles the full tab switch
}
AppState.currentTab = tabId;
@@ -2966,16 +3066,21 @@
const oldPanel = document.querySelector('.tab-panel.active');
const newPanel = document.getElementById(tabId);
if (oldPanel && oldPanel !== newPanel) {
// Play exit animation on old panel, keeping it visible
oldPanel.classList.add('slide-left');
// Start entrance animation on new panel immediately
if (newPanel) {
newPanel.classList.add('active', 'anim-enter');
newPanel.addEventListener('animationend', function handler() {
newPanel.removeEventListener('animationend', handler);
newPanel.classList.remove('anim-enter');
}, { once: true });
}
// Animate old panel out while new slides in
oldPanel.classList.add('anim-exit');
oldPanel.classList.remove('active');
oldPanel.addEventListener('animationend', function handler() {
oldPanel.removeEventListener('animationend', handler);
oldPanel.classList.remove('active', 'slide-left');
oldPanel.classList.remove('anim-exit');
}, { once: true });
// Small delay then show new panel with slide-in
setTimeout(() => {
if (newPanel) newPanel.classList.add('active');
}, 50);
} else if (newPanel) {
newPanel.classList.add('active');
}
@@ -3089,11 +3194,14 @@
// EMPTY STATE RENDERER
// ═══════════════════════════════════════════════════════════════════════
function renderEmptyState(icon, heading, desc, btnLabel, btnAction) {
const cta = btnLabel
? `<button class="esc-cta" onclick="${btnAction}">${esc(btnLabel)}</button>`
: '';
return `<div class="empty-state-card">
<div class="esc-icon">${icon}</div>
<div class="esc-heading">${esc(heading)}</div>
<div class="esc-desc">${esc(desc)}</div>
<button class="esc-cta" onclick="${btnAction}">${esc(btnLabel)}</button>
${cta}
</div>`;
}
@@ -3158,17 +3266,6 @@
initKeyBadges();
}, 100);
}
// T2: Load draft when switching to manual mode
if (mode === 'manual') {
setTimeout(() => {
if (!loadDraft()) {
// No draft - just ensure categories are populated
populateCategorySelect('manCatSelect');
}
initKeyBadges();
}, 100);
}
}
// ── Shared: Load settings dropdowns ─────────────────────────────────────
@@ -4165,13 +4262,28 @@
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
updateClearFiltersBtn();
// Show spinner in count bar immediately
// Show loading state immediately
isLoading = true;
document.getElementById('assetCountBar').innerHTML = '<div class="spinner"></div> Loading...';
showListLoading(true);
document.getElementById('assetCountBar').innerHTML = '<div class="spinner"></div> Searching...';
clearTimeout(assetDebounce);
assetDebounce = setTimeout(_loadAssets, 200);
}
function showListLoading(show) {
const wrap = document.getElementById('assetListWrap');
if (!wrap) return;
// Remove existing overlay
const existing = wrap.querySelector('.list-loading-overlay');
if (existing) existing.remove();
if (show) {
const overlay = document.createElement('div');
overlay.className = 'list-loading-overlay';
overlay.innerHTML = '<div class="overlay-spinner"></div>';
wrap.appendChild(overlay);
}
}
async function _loadAssets() {
// Fetch categories master list once
if (masterCategories.length === 0) {
@@ -4188,6 +4300,7 @@
var params = new URLSearchParams();
if (assetFilters.category) params.set('category', assetFilters.category);
if (assetFilters.status) params.set('status', assetFilters.status);
if (assetFilters.make) params.set('make', assetFilters.make);
if (assetFilters.q) params.set('q', assetFilters.q);
if (assetFilters.assigned_to) params.set('assigned_to', assetFilters.assigned_to);
params.set('limit', '100');
@@ -4203,6 +4316,7 @@
renderFilterPills(assets);
updateCountBar(filtered.length);
} catch (e) {
showListLoading(false);
document.getElementById('assetList').innerHTML =
'<div class="empty-state">Failed to load assets</div>';
document.getElementById('assetCountBar').innerHTML = '';
@@ -4212,9 +4326,10 @@
function updateCountBar(filteredCount) {
isLoading = false;
showListLoading(false);
const bar = document.getElementById('assetCountBar');
if (totalAssetCount > 0) {
const hasFilters = assetFilters.category || assetFilters.status || assetFilters.q || assetFilters.assigned_to;
const hasFilters = assetFilters.category || assetFilters.status || assetFilters.make || assetFilters.q || assetFilters.assigned_to;
if (hasFilters) {
bar.innerHTML = filteredCount + ' of ' + totalAssetCount + ' assets';
} else {
@@ -4286,21 +4401,23 @@
? masterCategories.map(c => (typeof c === 'string') ? c : c.name || c.value || c.label || '').filter(Boolean).sort()
: [...new Set(assets.map(a => a.category).filter(Boolean))].sort();
const escJs = s => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
let html = '<span class="pill' + (!assetFilters.category && !assetFilters.status && !assetFilters.make ? ' active' : '') + '" onclick="setFilter(null,null,null)">All</span>';
// Category pills from master list
catNames.forEach(c => {
html += `<span class="pill${assetFilters.category === c ? ' active' : ''}" onclick="setFilter('${esc(c)}',null,null)">📂 ${esc(c)}</span>`;
html += `<span class="pill${assetFilters.category === c ? ' active' : ''}" onclick="setFilter('${escJs(c)}',null,null)">📂 ${esc(c)}</span>`;
});
// Status pills
[...stats].sort().forEach(s => {
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${esc(s)}',null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${s}</span>`;
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${escJs(s)}',null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${esc(s)}</span>`;
});
// Make pills
[...makes].sort().forEach(m => {
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${esc(m)}')">🏭 ${esc(m)}</span>`;
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${escJs(m)}')">🏭 ${esc(m)}</span>`;
});
document.getElementById('filterPills').innerHTML = html;
@@ -5193,7 +5310,7 @@
document.getElementById('custLocCount').textContent = locs.length ? `(${locs.length})` : '';
const locsEl = document.getElementById('custLocationsList');
if (!locs.length) {
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>';
locsEl.innerHTML = renderEmptyState('📍', 'No Locations Yet', 'Add a location to this customer to track where assets are placed. Locations include addresses, buildings, and room details.', 'Add Location', 'showLocFormForCust(' + id + ')');
} else {
locsEl.innerHTML = locs.map(l => `
<div class="loc-card" onclick="event.stopPropagation();viewLocation(${l.id})">
@@ -5771,7 +5888,7 @@
<span class="sb-count">${cnt}</span>
</div>
`).join('')
: '<div class="empty-state">No data</div>';
: '<div class="empty-state" style="padding:16px;"><span style="font-size:28px;display:block;margin-bottom:6px;opacity:0.4;">📂</span>After adding assets, category breakdowns will appear here</div>';
// --- By Status (interactive) ---
const statEntries = Object.entries(stats.by_status || {});
@@ -5788,7 +5905,7 @@
<span class="sb-count">${cnt}</span>
</div>`;
}).join('')
: '<div class="empty-state">No data</div>';
: '<div class="empty-state" style="padding:16px;"><span style="font-size:28px;display:block;margin-bottom:6px;opacity:0.4;">🏷️</span>When assets are added, status breakdowns (active, maintenance, retired) will appear here</div>';
// --- By Make (interactive) ---
const makeEntries = Object.entries(stats.by_make || {});
@@ -5830,13 +5947,9 @@
<div class="hv-visits">${v.visit_count}</div>
</div>`;
}).join('')
: `<div class="empty-state" style="padding:20px;">
<div class="es-icon">📍</div>
<div style="font-size:14px;margin-bottom:8px;">No visit data yet</div>
<button class="btn btn-primary btn-sm" onclick="switchTab('tabAddAsset')" style="margin-top:4px;">
Add a Check-in
</button>
</div>`;
: renderEmptyState('📍', 'No visit data yet',
'Most-visited assets will appear here once technicians start checking in at locations. Add a check-in to begin tracking.',
'Add Check-in', "switchTab('tabAddAsset')");
// --- Recent Activity ---
renderActivityFeed(activity || []);
@@ -5919,7 +6032,7 @@
function renderActivityFeed(activities) {
const el = document.getElementById('activityFeed');
if (!activities.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📝</div>No recent activity.</div>';
el.innerHTML = '<div class="empty-state"><div class="es-icon">📝</div>No recent activity.<br><span style="font-size:11px;">Check-ins, asset changes, and updates will appear here.</span></div>';
return;
}
const ICON_MAP = { created: '✨', updated: '✏️', deleted: '🗑️', checked_in: '📍', checked_out: '🚪', login: '🔑' };
@@ -6088,13 +6201,13 @@
renderVisitFrequency(visits);
renderTimeOnSite(visits);
// Enable/disable download button based on data
// Show/hide download button based on data
if (downloadBtn) {
downloadBtn.disabled = visits.length === 0;
if (visits.length === 0) {
downloadBtn.title = 'No data to download';
downloadBtn.style.display = 'none';
} else {
downloadBtn.title = '';
downloadBtn.style.display = '';
downloadBtn.disabled = false;
}
}
} catch (e) {
@@ -6162,11 +6275,9 @@
<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="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>`;
</table>` : renderEmptyState('📋', 'No visits in this date range',
'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.',
'Add Asset', "switchTab('tabAddAsset')");
document.getElementById('reportServiceTable').innerHTML = html;
}
@@ -6244,11 +6355,9 @@
<td>${formatDate(r.last)}</td>
<td class="rpt-number">${r.techs.size}</td>
</tr>`).join('')}</tbody>
</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>`;
</table>` : renderEmptyState('📊', 'No visit frequency data',
'Visit frequency shows how often each asset has been checked. Data appears here automatically as technicians log visits.',
'Add Asset', "switchTab('tabAddAsset')");
document.getElementById('reportVisitTable').innerHTML = tableHtml;
}
@@ -6301,11 +6410,9 @@
</div></td>
</tr>`;
}).join('')}</tbody>
</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>`;
</table>` : renderEmptyState('⏱️', 'No technician time data',
'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.',
'Add Asset', "switchTab('tabAddAsset')");
document.getElementById('reportTimeTech').innerHTML = techHtml;
// Per asset
@@ -6346,11 +6453,9 @@
</div></td>
</tr>`;
}).join('')}</tbody>
</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>`;
</table>` : renderEmptyState('📦', 'No asset time data',
'Time-per-asset is calculated from check-in/check-out durations. Make sure assets have locations assigned so technicians can check in on-site.',
'Add Asset', "switchTab('tabAddAsset')");
document.getElementById('reportTimeAsset').innerHTML = assetHtml;
}
@@ -6403,6 +6508,7 @@
let map = null;
let assetMarkers = []; // { asset, marker }
let markerCluster = null; // Leaflet.markercluster group
let geofenceLayers = []; // { geofence, polygon }
let heatLayer = null;
let heatVisible = false;
@@ -6473,6 +6579,15 @@
maxZoom: 19,
}).addTo(map);
// Marker cluster group for asset pins
markerCluster = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
});
map.addLayer(markerCluster);
// FeatureGroup for drawn geofences
drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
@@ -6523,10 +6638,25 @@
try {
const assets = await api('/api/assets?limit=1000');
clearAssetMarkers();
// Assets that already have lat/lng
const withCoords = assets.filter(a => a.latitude != null && a.longitude != null);
withCoords.forEach(asset => {
addAssetMarker(asset);
});
// Assets with addresses but no coordinates — geocode them
const needsGeocode = assets.filter(a =>
(a.latitude == null || a.longitude == null) &&
(a.address || a.building_name)
);
if (needsGeocode.length > 0) {
await geocodeAndPin(needsGeocode);
}
// Render legend after pins are on the map
renderMapLegend();
// Update heatmap layer if visible
if (heatVisible) await loadHeatmapData();
} catch (e) {
@@ -6534,17 +6664,42 @@
}
}
// Geocode assets that have address fields but no GPS coordinates
async function geocodeAndPin(assets) {
let geocoded = 0;
for (const asset of assets) {
try {
const addr = [asset.address, asset.building_name,
asset.building_number, asset.city, asset.state, asset.zip
].filter(Boolean).join(', ');
if (!addr) continue;
const result = await api(`/api/geocode-address?address=${encodeURIComponent(addr)}`);
if (result && result.lat && result.lng) {
asset.latitude = result.lat;
asset.longitude = result.lng;
addAssetMarker(asset);
geocoded++;
}
} catch (e) {
// Skip individual geocoding failures — not all addresses resolve
}
}
if (geocoded > 0) {
showToast(`📍 Geocoded ${geocoded} address${geocoded !== 1 ? 'es' : ''}`);
}
}
function clearAssetMarkers() {
assetMarkers.forEach(({ marker }) => {
if (map) map.removeLayer(marker);
});
if (markerCluster) markerCluster.clearLayers();
assetMarkers = [];
}
function addAssetMarker(asset) {
if (!map) return;
if (!map || !markerCluster) return;
const icon = makeAssetIcon(asset);
const marker = L.marker([asset.latitude, asset.longitude], { icon }).addTo(map);
const marker = L.marker([asset.latitude, asset.longitude], { icon });
markerCluster.addLayer(marker);
const addrParts = [
asset.address, asset.building_name,