';
+ 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 ? `
Asset
Visits
Last Visit
+
${rows.map(r => `
${esc(r.name)}
${r.visits}
${formatDate(r.last)}
+
${r.hasLocation && r.assetId ? `` : ''}
`).join('')}
-
` : '
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.
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.