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✕';
+ 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 + '✕';
+ 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 Tap to re‑enable';
+ 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
+ ? ``
+ : '';
return `
${icon}
${esc(heading)}
${esc(desc)}
-
+ ${cta}
`;
}
@@ -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 = ' Loading...';
+ showListLoading(true);
+ document.getElementById('assetCountBar').innerHTML = ' 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 = '';
+ 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 =
'
Failed to load assets
';
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 = 'All';
// Category pills from master list
catNames.forEach(c => {
- html += `📂 ${esc(c)}`;
+ html += `📂 ${esc(c)}`;
});
// Status pills
[...stats].sort().forEach(s => {
- html += `${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${s}`;
+ html += `${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${esc(s)}`;
});
// Make pills
[...makes].sort().forEach(m => {
- html += `🏭 ${esc(m)}`;
+ html += `🏭 ${esc(m)}`;
});
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 = '
📍
No locations for this customer
';
+ 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 => `
@@ -5771,7 +5888,7 @@
${cnt}
`).join('')
- : '
No data
';
+ : '
📂After adding assets, category breakdowns will appear here
';
// --- By Status (interactive) ---
const statEntries = Object.entries(stats.by_status || {});
@@ -5788,7 +5905,7 @@
${cnt}
`;
}).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,