T6 - Map Page Improvements: clustering, real GPS heatmap, dismissible GPS warning, address geocoding fallback, legend
- Add Leaflet.markercluster CDN + cluster group for dense asset pins - Replace placebo heatmap with real check-in GPS data (/api/checkins/heatmap) - GPS warning dismissible with ✕ button + re-enable option - GPS-denied fallback: geocode asset addresses via /api/geocode-address when lat/lng missing - Add map legend showing category colors for asset markers - Fix modal HTML: add prompt area + modalActions id for prompt/modals - Add modal prompt CSS, legend CSS, GPS badge dismiss styles
This commit is contained in:
+300
-18
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="navMapContainer" style="height:280px;border-radius:12px;overflow:hidden;margin-bottom:12px;"></div>
|
||||
<!-- Empty state — shown when no route is active -->
|
||||
<div id="navEmptyState" class="card" style="text-align:center;">
|
||||
<div style="font-size:40px;margin-bottom:8px;opacity:0.5;">🧭</div>
|
||||
<div style="font-size:14px;color:var(--text);margin-bottom:8px;">Find your way</div>
|
||||
<div style="font-size:13px;color:var(--text2);margin-bottom:12px;">
|
||||
Go to an asset and tap <b>Navigate</b>, or pick a destination below
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input id="navSearchInput" type="text" class="input-field" oninput="searchNavAssets()"
|
||||
placeholder="Search assets to navigate to..." style="margin-bottom:0;">
|
||||
<button class="clear-btn" id="clearNavSearch" onclick="clearNavSearch()">✕</button>
|
||||
</div>
|
||||
<div id="navSearchResults" style="text-align:left;"></div>
|
||||
</div>
|
||||
<!-- Route info — shown when a route is active -->
|
||||
<div class="card" id="navInfoCard" style="display:none;">
|
||||
<div class="card-title">📏 Route Info</div>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;">
|
||||
@@ -1613,10 +1630,19 @@
|
||||
<div style="font-size:11px;color:var(--text3);">From You</div>
|
||||
<div id="navBearing" style="font-size:20px;font-weight:700;">—</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:100px;" id="navDurationCol">
|
||||
<div style="font-size:11px;color:var(--text3);">ETA</div>
|
||||
<div id="navDuration" style="font-size:20px;font-weight:700;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mode toggle -->
|
||||
<div id="navModeToggle" style="display:flex;gap:6px;margin-top:10px;">
|
||||
<button id="navModeDriving" class="btn btn-sm" style="flex:1;" onclick="switchNavMode('driving')">🚗 Driving</button>
|
||||
<button id="navModeWalking" class="btn btn-sm btn-outline" style="flex:1;" onclick="switchNavMode('walking')">🚶 Walking</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="navDirectionsCard" class="card" style="display:none;">
|
||||
<div class="card-title">🚶 Walking Directions</div>
|
||||
<div class="card-title" id="navDirectionsLabel">🚶 Walking Directions</div>
|
||||
<div id="navDirections" style="font-size:13px;color:var(--text2);line-height:1.6;white-space:pre-wrap;"></div>
|
||||
</div>
|
||||
<a id="navGoogleMapsLink" href="#" target="_blank" rel="noopener" class="btn btn-primary" style="display:none;text-decoration:none;text-align:center;margin-top:8px;">🧭 Open in Google Maps</a>
|
||||
@@ -2004,6 +2030,25 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||||
MORE POPOVER
|
||||
═══════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="more-popover-overlay" id="morePopoverOverlay" onclick="closeMorePopover()"></div>
|
||||
<div class="more-popover" id="morePopover">
|
||||
<button class="mp-item" onclick="closeMorePopover();switchTab('tabCustomers')">
|
||||
<span class="mp-icon">🏢</span> Customers
|
||||
</button>
|
||||
<button class="mp-item" onclick="closeMorePopover();switchTab('tabReports')">
|
||||
<span class="mp-icon">📋</span> Reports
|
||||
</button>
|
||||
<button class="mp-item" onclick="closeMorePopover();switchTab('tabActivity')">
|
||||
<span class="mp-icon">📜</span> Activity
|
||||
</button>
|
||||
<button class="mp-item" onclick="closeMorePopover();switchTab('tabSettings')">
|
||||
<span class="mp-icon">⚙️</span> Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||||
TOAST
|
||||
═══════════════════════════════════════════════════════════════════════ -->
|
||||
@@ -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('<div style="min-width:140px;"><b>' + esc(nav.asset_name) + '</b><br><span style="font-size:11px;color:var(--text2);">📍 ' + esc(nav.asset_address) + '</span></div>');
|
||||
}
|
||||
|
||||
// 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 = '<div class="empty-state"><div class="es-icon">🏢</div>No customers found</div>';
|
||||
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 = '<div class="empty-state">No locations for this customer</div>';
|
||||
locsEl.innerHTML = '<div class="empty-state"><div class="es-icon">📍</div>No locations for this customer<br><button class="esc-cta-sm" onclick="event.stopPropagation();showLocFormForCust(' + id + ')" style="margin-top:10px;">+ Add Location</button></div>';
|
||||
} else {
|
||||
locsEl.innerHTML = locs.map(l => `
|
||||
<div class="loc-card" onclick="event.stopPropagation();viewLocation(${l.id})">
|
||||
@@ -4902,7 +4993,7 @@
|
||||
function renderLocList(locs) {
|
||||
const el = document.getElementById('locList');
|
||||
if (!locs.length) {
|
||||
el.innerHTML = '<div class="empty-state">No locations for this customer</div>';
|
||||
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 ? `
|
||||
<table class="rpt-table">
|
||||
<thead><tr>
|
||||
<th>Asset</th>
|
||||
<th class="rpt-number">Visits</th>
|
||||
<th>Last Visit</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map(r => `
|
||||
<tr>
|
||||
<td>${esc(r.name)}</td>
|
||||
<td class="rpt-number">${r.visits}</td>
|
||||
<td>${formatDate(r.last)}</td>
|
||||
<td>${r.hasLocation && r.assetId ? `<button class="btn-map-link" onclick="viewOnMap(${r.assetId}, ${r.lat}, ${r.lng})">\u{1f4cd} Map</button>` : ''}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>` : '<div class="empty-state">No visits in range</div>';
|
||||
</table>` : `<div class="guidance-card">
|
||||
<div class="gc-icon">\u{1f4cb}</div>
|
||||
<div class="gc-title">No visits in this date range</div>
|
||||
<div class="gc-hint">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.</div>
|
||||
</div>`;
|
||||
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: '<div style="background:var(--accent);width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 8px var(--accent);"></div>',
|
||||
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 ' <span class="sort-arrow">↕</span>';
|
||||
return reportSortState.dir === 1 ? ' <span class="sort-arrow asc">▲</span>' : ' <span class="sort-arrow desc">▼</span>';
|
||||
if (reportSortState.key !== k) return ' <span class="sort-arrow">\u{2195}</span>';
|
||||
return reportSortState.dir === 1 ? ' <span class="sort-arrow asc">\u{25b2}</span>' : ' <span class="sort-arrow desc">\u{25bc}</span>';
|
||||
}
|
||||
|
||||
const tableHtml = rows.length ? `
|
||||
@@ -5645,7 +5893,11 @@
|
||||
<td>${formatDate(r.last)}</td>
|
||||
<td class="rpt-number">${r.techs.size}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>` : '<div class="empty-state">No visits in range</div>';
|
||||
</table>` : `<div class="guidance-card">
|
||||
<div class="gc-icon">\u{1f4ca}</div>
|
||||
<div class="gc-title">No visit frequency data</div>
|
||||
<div class="gc-hint">Visit frequency shows how often each asset has been checked. Data appears here automatically as technicians log visits.</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('reportVisitTable').innerHTML = tableHtml;
|
||||
}
|
||||
@@ -5698,7 +5950,11 @@
|
||||
</div></td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>` : '<div class="empty-state">No visit data available</div>';
|
||||
</table>` : `<div class="guidance-card">
|
||||
<div class="gc-icon">\u{23f1}\u{fe0f}</div>
|
||||
<div class="gc-title">No technician time data</div>
|
||||
<div class="gc-hint">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.</div>
|
||||
</div>`;
|
||||
document.getElementById('reportTimeTech').innerHTML = techHtml;
|
||||
|
||||
// Per asset
|
||||
@@ -5739,7 +5995,11 @@
|
||||
</div></td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>` : '<div class="empty-state">No visit data available</div>';
|
||||
</table>` : `<div class="guidance-card">
|
||||
<div class="gc-icon">\u{1f4e6}</div>
|
||||
<div class="gc-title">No asset time data</div>
|
||||
<div class="gc-hint">Time-per-asset is calculated from check-in/check-out durations. Make sure assets have locations assigned so technicians can check in on-site.</div>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="legend-title">📍 Asset Types</div>
|
||||
${Object.entries(CAT_COLORS).map(([cat, color]) =>
|
||||
cat !== 'Utensils & Serveware' ? `
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot" style="background:${color};"></div>
|
||||
<span>${esc(cat)}</span>
|
||||
</div>` : ''
|
||||
).join('')}
|
||||
`;
|
||||
document.getElementById('mapContainer').appendChild(legend);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// GEOFFENCE MANAGEMENT
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -7011,6 +7292,7 @@
|
||||
initGPS();
|
||||
startScanning();
|
||||
loadAssets();
|
||||
initOnboarding();
|
||||
</script>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user