From 389f1f11d2c0129b67aa0754c232c2ecd4735226 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 20 May 2026 22:53:13 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20Navigate=20tab=20switching=20=E2=80=94?= =?UTF-8?q?=20remove=20early=20return=20that=20prevented=20tab=20panel=20D?= =?UTF-8?q?OM=20swap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- static/index.html | 323 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 239 insertions(+), 84 deletions(-) diff --git a/static/index.html b/static/index.html index 16329aa..eb2225f 100644 --- a/static/index.html +++ b/static/index.html @@ -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 @@

📦 Canteen Assets

- 📍 GPS... + 📍 GPS...
?
@@ -1686,9 +1758,13 @@
+
-
+
+
+
+
@@ -2135,7 +2211,7 @@
- +
`; }).join('') - : '
No data
'; + : '
🏷️When assets are added, status breakdowns (active, maintenance, retired) will appear here
'; // --- By Make (interactive) --- const makeEntries = Object.entries(stats.by_make || {}); @@ -5830,13 +5947,9 @@
${v.visit_count}
`; }).join('') - : `
-
📍
-
No visit data yet
- -
`; + : 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 = '
📝
No recent activity.
'; + el.innerHTML = '
📝
No recent activity.
Check-ins, asset changes, and updates will appear here.
'; 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 @@ ${formatDate(r.last)} ${r.hasLocation && r.assetId ? `` : ''} `).join('')} - ` : `
-
\u{1f4cb}
-
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.
-
`; + ` : 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 @@ ${formatDate(r.last)} ${r.techs.size} `).join('')} - ` : `
-
\u{1f4ca}
-
No visit frequency data
-
Visit frequency shows how often each asset has been checked. Data appears here automatically as technicians log visits.
-
`; + ` : 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 @@ `; }).join('')} - ` : `
-
\u{23f1}\u{fe0f}
-
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.
-
`; + ` : 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 @@ `; }).join('')} - ` : `
-
\u{1f4e6}
-
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.
-
`; + ` : 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,