fix: Navigate tab switching — remove early return that prevented tab panel DOM swap
The switchTab function had an early return after closeNavigate() that skipped the actual tab panel switching (slide animation, active class management). closeNavigate() only cleans up navigation state (map, data, UI elements) — it does NOT switch panels. This caused the Navigate panel to remain visible permanently after the first visit, overlapping with any subsequent tab content. Removed the early return so the normal tab switching logic runs after closeNavigate() cleanup.
This commit is contained in:
+239
-84
@@ -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 @@
|
||||
<h1>📦 Canteen Assets</h1>
|
||||
<div class="header-badges">
|
||||
<span id="roleBadge" class="role-badge guest" style="display:none;">guest</span>
|
||||
<span id="gpsBadge" class="gps-badge waiting">📍 GPS...</span>
|
||||
<span id="gpsBadge" class="gps-badge waiting">📍 GPS...<span class="gps-dismiss" onclick="event.stopPropagation();dismissGPS()">✕</span></span>
|
||||
<div class="user-badge" id="userBadge" onclick="openDrawer()" title="User">?</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1686,9 +1758,13 @@
|
||||
</div>
|
||||
<div class="assets-toolbar">
|
||||
<div class="filter-scroll" id="filterPills"></div>
|
||||
<button class="btn btn-outline btn-sm import-btn" id="clearFiltersBtn" onclick="clearFilters()" style="display:none;">✕ Clear</button>
|
||||
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
|
||||
</div>
|
||||
<div id="assetList"></div>
|
||||
<div class="count-bar" id="assetCountBar"></div>
|
||||
<div id="assetListWrap" style="position:relative; min-height:60px;">
|
||||
<div id="assetList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Detail View ──────────────────────────────────────────────────── -->
|
||||
@@ -2135,7 +2211,7 @@
|
||||
</div>
|
||||
<div class="rpt-download-row">
|
||||
<button class="btn btn-primary btn-sm" id="rptGenerateBtn" onclick="loadReports()">Generate Report</button>
|
||||
<button class="btn btn-outline btn-sm rpt-download-btn" id="rptDownloadBtn" onclick="exportServiceSummary()" disabled>⬇ Download CSV</button>
|
||||
<button class="btn btn-outline btn-sm rpt-download-btn" id="rptDownloadBtn" onclick="exportServiceSummary()" style="display:none;">⬇ Download CSV</button>
|
||||
</div>
|
||||
<div class="rpt-loading" id="rptLoading" style="display:none;">
|
||||
<div class="spinner"></div> 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<span class="gps-dismiss" onclick="event.stopPropagation();dismissGPS()">✕</span>';
|
||||
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 + '<span class="gps-dismiss" onclick="event.stopPropagation();dismissGPS()">✕</span>';
|
||||
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 <span class="gps-reenable">Tap to re‑enable</span>';
|
||||
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
|
||||
? `<button class="esc-cta" onclick="${btnAction}">${esc(btnLabel)}</button>`
|
||||
: '';
|
||||
return `<div class="empty-state-card">
|
||||
<div class="esc-icon">${icon}</div>
|
||||
<div class="esc-heading">${esc(heading)}</div>
|
||||
<div class="esc-desc">${esc(desc)}</div>
|
||||
<button class="esc-cta" onclick="${btnAction}">${esc(btnLabel)}</button>
|
||||
${cta}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -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 = '<div class="spinner"></div> Loading...';
|
||||
showListLoading(true);
|
||||
document.getElementById('assetCountBar').innerHTML = '<div class="spinner"></div> 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 = '<div class="overlay-spinner"></div>';
|
||||
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 =
|
||||
'<div class="empty-state">Failed to load assets</div>';
|
||||
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 = '<span class="pill' + (!assetFilters.category && !assetFilters.status && !assetFilters.make ? ' active' : '') + '" onclick="setFilter(null,null,null)">All</span>';
|
||||
|
||||
// Category pills from master list
|
||||
catNames.forEach(c => {
|
||||
html += `<span class="pill${assetFilters.category === c ? ' active' : ''}" onclick="setFilter('${esc(c)}',null,null)">📂 ${esc(c)}</span>`;
|
||||
html += `<span class="pill${assetFilters.category === c ? ' active' : ''}" onclick="setFilter('${escJs(c)}',null,null)">📂 ${esc(c)}</span>`;
|
||||
});
|
||||
|
||||
// Status pills
|
||||
[...stats].sort().forEach(s => {
|
||||
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${esc(s)}',null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${s}</span>`;
|
||||
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${escJs(s)}',null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${esc(s)}</span>`;
|
||||
});
|
||||
|
||||
// Make pills
|
||||
[...makes].sort().forEach(m => {
|
||||
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${esc(m)}')">🏭 ${esc(m)}</span>`;
|
||||
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${escJs(m)}')">🏭 ${esc(m)}</span>`;
|
||||
});
|
||||
|
||||
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 = '<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>';
|
||||
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 => `
|
||||
<div class="loc-card" onclick="event.stopPropagation();viewLocation(${l.id})">
|
||||
@@ -5771,7 +5888,7 @@
|
||||
<span class="sb-count">${cnt}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="empty-state">No data</div>';
|
||||
: '<div class="empty-state" style="padding:16px;"><span style="font-size:28px;display:block;margin-bottom:6px;opacity:0.4;">📂</span>After adding assets, category breakdowns will appear here</div>';
|
||||
|
||||
// --- By Status (interactive) ---
|
||||
const statEntries = Object.entries(stats.by_status || {});
|
||||
@@ -5788,7 +5905,7 @@
|
||||
<span class="sb-count">${cnt}</span>
|
||||
</div>`;
|
||||
}).join('')
|
||||
: '<div class="empty-state">No data</div>';
|
||||
: '<div class="empty-state" style="padding:16px;"><span style="font-size:28px;display:block;margin-bottom:6px;opacity:0.4;">🏷️</span>When assets are added, status breakdowns (active, maintenance, retired) will appear here</div>';
|
||||
|
||||
// --- By Make (interactive) ---
|
||||
const makeEntries = Object.entries(stats.by_make || {});
|
||||
@@ -5830,13 +5947,9 @@
|
||||
<div class="hv-visits">${v.visit_count}</div>
|
||||
</div>`;
|
||||
}).join('')
|
||||
: `<div class="empty-state" style="padding:20px;">
|
||||
<div class="es-icon">📍</div>
|
||||
<div style="font-size:14px;margin-bottom:8px;">No visit data yet</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="switchTab('tabAddAsset')" style="margin-top:4px;">
|
||||
➕ Add a Check-in
|
||||
</button>
|
||||
</div>`;
|
||||
: 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 = '<div class="empty-state"><div class="es-icon">📝</div>No recent activity.</div>';
|
||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📝</div>No recent activity.<br><span style="font-size:11px;">Check-ins, asset changes, and updates will appear here.</span></div>';
|
||||
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 @@
|
||||
<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="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>`;
|
||||
</table>` : 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 @@
|
||||
<td>${formatDate(r.last)}</td>
|
||||
<td class="rpt-number">${r.techs.size}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</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>`;
|
||||
</table>` : 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 @@
|
||||
</div></td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</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>`;
|
||||
</table>` : 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 @@
|
||||
</div></td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</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>`;
|
||||
</table>` : 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,
|
||||
|
||||
Reference in New Issue
Block a user