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,