diff --git a/static/index.html b/static/index.html index 0937be7..fea7dba 100644 --- a/static/index.html +++ b/static/index.html @@ -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 @@ + + + + + @@ -2004,6 +2030,25 @@ + +
+
+ + + + +
+ @@ -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('
' + esc(nav.asset_name) + '
📍 ' + esc(nav.asset_address) + '
'); } - // 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 = '
🏢
No customers found
'; + 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 = '
No locations for this customer
'; + locsEl.innerHTML = '
📍
No locations for this customer
'; } else { locsEl.innerHTML = locs.map(l => `
@@ -4902,7 +4993,7 @@ function renderLocList(locs) { const el = document.getElementById('locList'); if (!locs.length) { - el.innerHTML = '
No locations for this customer
'; + 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 ? ` + ${rows.map(r => ` + `).join('')} -
Asset Visits Last Visit
${esc(r.name)} ${r.visits} ${formatDate(r.last)}${r.hasLocation && r.assetId ? `` : ''}
` : '
No visits in range
'; + ` : `
+
\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.
+
`; 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: '
', + 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 ' '; - return reportSortState.dir === 1 ? ' ' : ' '; + if (reportSortState.key !== k) return ' \u{2195}'; + return reportSortState.dir === 1 ? ' \u{25b2}' : ' \u{25bc}'; } const tableHtml = rows.length ? ` @@ -5645,7 +5893,11 @@ ${formatDate(r.last)} ${r.techs.size} `).join('')} - ` : '
No visits in range
'; + ` : `
+
\u{1f4ca}
+
No visit frequency data
+
Visit frequency shows how often each asset has been checked. Data appears here automatically as technicians log visits.
+
`; document.getElementById('reportVisitTable').innerHTML = tableHtml; } @@ -5698,7 +5950,11 @@
`; }).join('')} - ` : '
No visit data available
'; + ` : `
+
\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.
+
`; document.getElementById('reportTimeTech').innerHTML = techHtml; // Per asset @@ -5739,7 +5995,11 @@ `; }).join('')} - ` : '
No visit data available
'; + ` : `
+
\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.
+
`; 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 = ` +
📍 Asset Types
+ ${Object.entries(CAT_COLORS).map(([cat, color]) => + cat !== 'Utensils & Serveware' ? ` +
+
+ ${esc(cat)} +
` : '' + ).join('')} + `; + document.getElementById('mapContainer').appendChild(legend); + } + // ═══════════════════════════════════════════════════════════════════════ // GEOFFENCE MANAGEMENT // ═══════════════════════════════════════════════════════════════════════ @@ -7011,6 +7292,7 @@ initGPS(); startScanning(); loadAssets(); + initOnboarding();