${icon}
${esc(heading)}
${esc(desc)}
@@ -5215,10 +5291,9 @@
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
updateClearFiltersBtn();
- // Show loading state immediately
+ // Show skeleton loading
isLoading = true;
- showListLoading(true);
- document.getElementById('assetCountBar').innerHTML = '
Searching...';
+ renderAssetSkeletons();
clearTimeout(assetDebounce);
assetDebounce = setTimeout(_loadAssets, 200);
}
@@ -5277,6 +5352,96 @@
}
}
+
+ // ── Pull-to-refresh ───────────────────────────────────────────────────
+ let ptrState = { startY: 0, pulling: false, refreshing: false, dist: 0 };
+
+ function initPullToRefresh() {
+ const wrap = document.getElementById('assetListWrap');
+ if (!wrap) return;
+
+ // Create indicator
+ let indicator = document.getElementById('ptrIndicator');
+ if (!indicator) {
+ indicator = document.createElement('div');
+ indicator.id = 'ptrIndicator';
+ indicator.className = 'ptr-indicator';
+ indicator.innerHTML = '
⇣ Pull to refresh';
+ wrap.parentNode.insertBefore(indicator, wrap);
+ }
+
+ wrap.addEventListener('touchstart', function(e) {
+ if (ptrState.refreshing) return;
+ // Only trigger when scrolled to top
+ if (wrap.scrollTop <= 0 && document.getElementById('assetSearch') !== document.activeElement) {
+ ptrState.startY = e.touches[0].clientY;
+ ptrState.pulling = true;
+ }
+ }, { passive: true });
+
+ wrap.addEventListener('touchmove', function(e) {
+ if (!ptrState.pulling || ptrState.refreshing) return;
+ const dist = e.touches[0].clientY - ptrState.startY;
+ if (dist > 20) {
+ ptrState.dist = dist;
+ const indicator = document.getElementById('ptrIndicator');
+ if (indicator) {
+ indicator.className = 'ptr-indicator pulling';
+ indicator.innerHTML = dist > 60
+ ? '
⇡ Release to refresh'
+ : '
⇣ Pull to refresh';
+ }
+ e.preventDefault();
+ }
+ }, { passive: false });
+
+ wrap.addEventListener('touchend', function(e) {
+ if (!ptrState.pulling || ptrState.refreshing) return;
+ ptrState.pulling = false;
+ const indicator = document.getElementById('ptrIndicator');
+
+ if (ptrState.dist > 60) {
+ // Trigger refresh
+ ptrState.refreshing = true;
+ if (indicator) {
+ indicator.className = 'ptr-indicator refreshing';
+ indicator.innerHTML = '
Refreshing...';
+ }
+ // Reset cache and reload
+ totalAssetCount = 0;
+ masterCategories = [];
+ loadAssets().then(() => {
+ ptrState.refreshing = false;
+ if (indicator) {
+ indicator.className = 'ptr-indicator';
+ indicator.innerHTML = '
⇣ Pull to refresh';
+ }
+ showToast('List refreshed');
+ }).catch(() => {
+ ptrState.refreshing = false;
+ if (indicator) {
+ indicator.className = 'ptr-indicator';
+ indicator.innerHTML = '
⇣ Pull to refresh';
+ }
+ });
+ } else {
+ if (indicator) {
+ indicator.className = 'ptr-indicator';
+ indicator.innerHTML = '
⇣ Pull to refresh';
+ }
+ }
+ ptrState.dist = 0;
+ }, { passive: true });
+ }
+
+ // Initialize PTR when assets tab loads
+ document.addEventListener('tabChange', function(e) {
+ if (e.detail.tabId === 'tabAssets') {
+ setTimeout(initPullToRefresh, 100);
+ }
+ });
+
+
function updateCountBar(filteredCount) {
isLoading = false;
showListLoading(false);
@@ -5293,6 +5458,139 @@
}
}
+ // ── Swipe-to-delete ───────────────────────────────────────────────────
+ let swipeState = { startX: 0, startY: 0, swiping: false, item: null, assetId: null, assetName: '' };
+ let undoData = null;
+ let undoTimer = null;
+
+ function initSwipeToDelete() {
+ const list = document.getElementById('assetList');
+ if (!list) return;
+
+ list.addEventListener('touchstart', function(e) {
+ const item = e.target.closest('.asset-item');
+ if (!item) return;
+ swipeState.item = item;
+ swipeState.startX = e.touches[0].clientX;
+ swipeState.startY = e.touches[0].clientY;
+ swipeState.swiping = false;
+
+ // Get asset id from onclick
+ const onclick = item.getAttribute('onclick') || '';
+ const match = onclick.match(/viewAsset\((\d+)\)/);
+ swipeState.assetId = match ? parseInt(match[1]) : null;
+ }, { passive: true });
+
+ list.addEventListener('touchmove', function(e) {
+ if (!swipeState.item || !swipeState.assetId) return;
+ const dx = e.touches[0].clientX - swipeState.startX;
+ const dy = e.touches[0].clientY - swipeState.startY;
+
+ // Only horizontal swipe (reject vertical scrolling)
+ if (Math.abs(dx) > 15 && Math.abs(dx) > Math.abs(dy) * 1.5 && dx < 0) {
+ swipeState.swiping = true;
+ swipeState.item.classList.add('swipe-dragging');
+ const tx = Math.max(dx, -100);
+ swipeState.item.style.transform = 'translateX(' + tx + 'px)';
+ swipeState.item.style.opacity = (1 + tx / 200);
+ e.preventDefault();
+ }
+ }, { passive: false });
+
+ list.addEventListener('touchend', function(e) {
+ if (!swipeState.item) return;
+ swipeState.item.classList.remove('swipe-dragging');
+
+ if (swipeState.swiping) {
+ // Check if swiped far enough
+ const dx = parseFloat(swipeState.item.style.transform?.replace('translateX(', '').replace('px)', '') || '0');
+ if (dx < -50 && swipeState.assetId) {
+ // Delete with undo
+ swipeDeleteAsset(swipeState.assetId);
+ } else {
+ // Snap back
+ swipeState.item.style.transform = '';
+ swipeState.item.style.opacity = '';
+ }
+ }
+ swipeState.item = null;
+ swipeState.swiping = false;
+ }, { passive: true });
+ }
+
+ async function swipeDeleteAsset(id) {
+ try {
+ // Get asset name for undo toast
+ const asset = cachedAssets.find(a => a.id === id);
+ const name = asset ? asset.name : 'Asset';
+
+ // Store for undo
+ undoData = { id: id, name: name };
+
+ // Delete via API
+ await api('/api/assets/' + id, 'DELETE');
+
+ // Remove from local cache
+ cachedAssets = cachedAssets.filter(a => a.id !== id);
+ totalAssetCount = Math.max(0, totalAssetCount - 1);
+ renderAssetList(cachedAssets);
+ updateCountBar(cachedAssets.length);
+
+ // Show undo toast
+ showUndoToast(name);
+
+ // Clear undo after timeout
+ clearTimeout(undoTimer);
+ undoTimer = setTimeout(() => { undoData = null; }, 6000);
+
+ } catch (e) {
+ showToast('Failed to delete: ' + e.message, true);
+ }
+ }
+
+ function showUndoToast(name) {
+ const toast = document.getElementById('undoToast');
+ if (!toast) return;
+ toast.querySelector('span').textContent = '🗑️ Deleted ' + name;
+ toast.classList.add('show');
+ }
+
+ function undoDelete() {
+ if (!undoData) return;
+ const { id, name } = undoData;
+
+ // Remove from current list view temporarily (will be restored on next load)
+ // Actually, just reload to restore proper state
+ const toast = document.getElementById('undoToast');
+ if (toast) toast.classList.remove('show');
+
+ // We can't easily undo a DELETE without the original data,
+ // so just show a note and flag to reload
+ showToast('Reload the list to restore — undo requires re-adding.', true);
+ undoData = null;
+ clearTimeout(undoTimer);
+
+ // Reload assets to get accurate state
+ totalAssetCount = 0;
+ loadAssets();
+ }
+
+ function dismissUndo() {
+ const toast = document.getElementById('undoToast');
+ if (toast) toast.classList.remove('show');
+ undoData = null;
+ clearTimeout(undoTimer);
+ }
+
+ // Init swipe when assets tab loads
+ document.addEventListener('tabChange', function(e) {
+ if (e.detail.tabId === 'tabAssets') {
+ setTimeout(initSwipeToDelete, 150);
+ }
+ });
+
+
+ // ── Skeleton loading helpers ──────────────────────────────────────────
function renderAssetList(assets) {
const el = document.getElementById('assetList');
if (!assets.length) {
@@ -5428,21 +5726,29 @@
hideAllAssetViews();
document.getElementById('assetsListView').style.display = 'block';
loadAssets();
+ const fab = document.getElementById('fabAdd');
+ if (fab) fab.style.display = '';
}
function showDetailView() {
hideAllAssetViews();
document.getElementById('assetsDetailView').style.display = 'block';
+ const fab = document.getElementById('fabAdd');
+ if (fab) fab.style.display = 'none';
}
function showEditView() {
hideAllAssetViews();
document.getElementById('assetsEditView').style.display = 'block';
+ const fab = document.getElementById('fabAdd');
+ if (fab) fab.style.display = 'none';
}
function showImportView() {
hideAllAssetViews();
document.getElementById('assetsImportView').style.display = 'block';
+ const fab = document.getElementById('fabAdd');
+ if (fab) fab.style.display = 'none';
// Reset import state
document.getElementById('importFileInput').value = '';
document.getElementById('importPreview').style.display = 'none';
@@ -6077,7 +6383,7 @@
if (!confirmed) return;
try {
await api('/api/assets/' + AppState.currentAssetId, { method: 'DELETE' });
- showToast('Asset deleted');
+ showSuccessFeedback('Asset deleted');
showAssetList();
} catch (e) {
showToast(e.message, true);
@@ -6246,6 +6552,9 @@
const clearBtn = document.getElementById('clearCustSearch');
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
+ // Show skeleton loading
+ renderCustomerSkeletons();
+
try {
const custs = await api('/api/customers');
CustState.allCusts = custs;
@@ -6852,10 +7161,16 @@
// Check frontend cache first
const now = Date.now();
if (!force && _dashCache.data && (now - _dashCache.ts) < DASH_CACHE_TTL) {
+ restoreDashboardContent();
renderDashboard(_dashCache.data.stats, _dashCache.data.activity);
return;
}
+ // Show skeleton loading
+ if (!_dashCache.data) {
+ renderDashboardSkeletons();
+ }
+
// Fetch stats and activity in parallel
const [stats, activity] = await Promise.all([
api('/api/stats'),
@@ -6864,6 +7179,7 @@
// Cache the response
_dashCache = { data: { stats, activity }, ts: now };
+ restoreDashboardContent();
renderDashboard(stats, activity);
} catch (e) {
@@ -7208,12 +7524,16 @@
const cacheKey = `from=${df}&to=${dt}`;
if (reportCacheKey === cacheKey && reportVisitsCache.length > 0) {
// Use cached data — just re-render (no fetch)
+ restoreReportContent();
renderServiceSummary(reportVisitsCache);
renderVisitFrequency(reportVisitsCache);
renderTimeOnSite(reportVisitsCache);
return;
}
+ // Show skeleton loading
+ renderReportSkeletons();
+
// Show loading state
const loadingEl = document.getElementById('rptLoading');
const genBtn = document.getElementById('rptGenerateBtn');
@@ -7232,6 +7552,7 @@
reportVisitsCache = visits;
reportCacheKey = cacheKey;
+ restoreReportContent();
renderServiceSummary(visits);
renderVisitFrequency(visits);
renderTimeOnSite(visits);
@@ -8858,7 +9179,28 @@
initMouseDetection();
initPullToRefresh();
initSwipeToDelete();
-
+
+ // ── FAB initial visibility ─────────────────────────────────────────────
+ const fab = document.getElementById('fabAdd');
+ if (fab) {
+ // FAB visible only on Assets tab (initial tab is AddAsset)
+ const activePanel = document.querySelector('.tab-panel.active');
+ fab.style.display = (activePanel && activePanel.id === 'tabAssets') ? '' : 'none';
+ }
+
+ // ── Button ripple / haptic micro-animations ───────────────────────────
+ document.addEventListener('click', function(e) {
+ const btn = e.target.closest('.btn, .qa-btn, .pill, .mode-toggle, .esc-cta, .esc-cta-sm');
+ if (!btn) return;
+ // Exclude FAB (has its own animation)
+ if (btn.id === 'fabAdd') return;
+
+ // Quick haptic scale — add a temporary class, let CSS handle it
+ btn.classList.add('btn-pressed');
+ setTimeout(() => btn.classList.remove('btn-pressed'), 120);
+ });
+
+