// assetBrowser.js — asset tree sidebar (clients → assets) with search + filters import { getCustomers, getCustomerAssets, getContacts } from '../api/syncro.js'; import { normalizeUsername, usernameFuzzyMatch, usernameFirstNameMatch, usernameNameInitialMatch, usernameInitialLastNameMatch } from './usernameUtils.js'; // ── localStorage keys ───────────────────────────────────────────────────────── const LS_EXPANDED = 'assetBrowser_expanded'; // JSON number[] const LS_ASSET_CACHE = 'assetBrowser_assetCache'; // JSON { [customerId]: asset[] } const LS_CACHE_TS = 'assetBrowser_assetCacheTs'; // ms timestamp const LS_BADGE_VIS = 'assetBrowser_badgeVis'; // JSON { possession, user, lifecycle, infra } const LS_CONTACT_CACHE = 'assetBrowser_contactCache'; // JSON { [customerId]: contact[] } const LS_SORT_PREFS = 'assetBrowser_sortPrefs'; // JSON { clients, assets } const LS_DISPLAY_PREFS = 'assetBrowser_displayPrefs'; // JSON { showCount, showBillable, hideEmpty } const LS_REMEMBER_MENU = 'assetBrowser_rememberMenu'; // 'true'|'false', default true const LS_REMEMBER_FILTERS = 'assetBrowser_rememberFilters'; // 'true'|'false', default false const LS_FILTER_STATE = 'assetBrowser_filterState'; // JSON saved filter state const POLL_INTERVAL_MS = 5 * 60_000; // re-fetch all assets every 5 min // ── Module state ────────────────────────────────────────────────────────────── let _onAssetSelect = null; let _onAssetClose = null; let _customers = []; let _assetCache = new Map(); // customerId → asset[] let _contactCache = new Map(); // customerId → contact[] let _expandedIds = new Set(); // customer IDs whose subtrees are open let _activeAssetId = null; let _filterText = ''; let _filterTimer = null; let _preSearchExpandedIds = null; // snapshot saved when search begins let _badgeVis = { possession: true, user: true, lifecycle: true, infra: true }; let _sortPrefs = { clients: 'alpha', assets: 'default' }; let _displayPrefs = { showCount: true, showBillable: false, hideEmpty: 'none' }; // ── Filter state ────────────────────────────────────────────────────────────── const _filters = { lifecycle: new Set(), // empty = show all lifecycle stages possession: null, // null | 'IT' | 'Deployed' infra: null, // null | true | false }; let _filterPanelOpen = false; // ── DOM refs (set in initAssetBrowser) ─────────────────────────────────────── let _sidebar = null; let _tree = null; let _searchInput = null; let _filterBtn = null; let _filterPanel = null; // ── Public API ──────────────────────────────────────────────────────────────── export function initAssetBrowser({ onAssetSelect, onAssetClose }) { _onAssetSelect = onAssetSelect; _onAssetClose = onAssetClose ?? null; _sidebar = document.getElementById('asset-sidebar'); _tree = document.getElementById('sidebar-tree'); _searchInput = document.getElementById('sidebar-search'); _filterBtn = document.getElementById('sidebar-filter-btn'); _filterPanel = document.getElementById('sidebar-filter-panel'); const _searchClearBtn = document.getElementById('sidebar-search-clear'); if (!_sidebar || !_tree) return; // Restore persisted state (expanded customers + asset cache) before loading _restoreState(); _syncFilterChips(); // update chip UI to match any restored filter state // Wire manual refresh button document.getElementById('sidebar-refresh')?.addEventListener('click', _manualRefresh); // Wire menu button document.getElementById('sidebar-menu-btn')?.addEventListener('click', e => { e.stopPropagation(); const isOpen = document.getElementById('sidebar-menu-panel')?.classList.contains('open'); _openSidePanel(isOpen ? null : 'menu'); }); // Wire all menu panel controls _initMenuPanel(); // Close panels when clicking outside the sidebar header document.addEventListener('click', e => { if (!e.target.closest('.sidebar-header') && !e.target.closest('.sidebar-filter-panel') && !e.target.closest('.sidebar-menu-panel')) { _openSidePanel(null); } }); // Wire search filter (debounced) + clear button _searchInput?.addEventListener('input', () => { if (_searchClearBtn) _searchClearBtn.hidden = !_searchInput.value.trim(); clearTimeout(_filterTimer); _filterTimer = setTimeout(() => { const newFilter = _searchInput.value.trim().toLowerCase(); if (newFilter && _preSearchExpandedIds === null) { // Starting a search — snapshot current expansion state _preSearchExpandedIds = new Set(_expandedIds); } else if (!newFilter && _preSearchExpandedIds !== null) { // Search cleared — restore pre-search expansion state _expandedIds = new Set(_preSearchExpandedIds); _preSearchExpandedIds = null; } _filterText = newFilter; _renderTree(); }, 200); }); _searchClearBtn?.addEventListener('click', () => { _searchInput.value = ''; _searchClearBtn.hidden = true; clearTimeout(_filterTimer); if (_preSearchExpandedIds !== null) { _expandedIds = new Set(_preSearchExpandedIds); _preSearchExpandedIds = null; } _filterText = ''; _renderTree(); _searchInput.focus(); }); // Wire filter button + panel _filterBtn?.addEventListener('click', () => { _openSidePanel(_filterPanelOpen ? null : 'filter'); }); _filterPanel?.addEventListener('click', _handleFilterClick); document.getElementById('sf-clear-btn')?.addEventListener('click', _clearFilters); // Wire "remember filters" checkbox const filterRememberCb = document.getElementById('filter-remember'); if (filterRememberCb) { filterRememberCb.checked = localStorage.getItem(LS_REMEMBER_FILTERS) === 'true'; filterRememberCb.addEventListener('change', () => { localStorage.setItem(LS_REMEMBER_FILTERS, String(filterRememberCb.checked)); if (filterRememberCb.checked) { _saveFilterState(); // save current filters immediately when enabling } else { localStorage.removeItem(LS_FILTER_STATE); // clear saved state when disabling } }); } // Event delegation for tree clicks _tree.addEventListener('click', _handleTreeClick); // Show loading state then fetch customers _tree.innerHTML = _loadingHTML('Loading clients…'); _loadCustomers(); } export function updateCachedAsset(asset) { for (const [customerId, assets] of _assetCache) { const idx = assets.findIndex(a => a.id === asset.id); if (idx !== -1) { assets[idx] = asset; if (_expandedIds.has(customerId)) _renderAssetList(customerId); break; } } } export function getCustomerContactNames(customerId) { const assets = _assetCache.get(customerId) ?? []; const names = new Set(); for (const a of assets) { const name = a.contact_fullname ?? a.contact?.name; if (name) names.add(name); } return [...names]; } // Returns null when not yet loaded (callers can distinguish "loading" from "empty") export function getCustomerContacts(customerId) { return _contactCache.has(customerId) ? _contactCache.get(customerId) : null; } export function searchLocal(query) { const q = query.trim().toLowerCase(); if (q.length < 2) return []; const customerMap = new Map(_customers.map(c => [c.id, c.business_name ?? c.name ?? ''])); const results = []; for (const [customerId, assets] of _assetCache) { for (const asset of assets) { const serial = asset.asset_serial ?? asset.serial ?? asset.serial_number ?? ''; const lastUser = asset.properties?.kabuto_information?.last_user ?? ''; const contact = asset.contact_fullname ?? asset.contact?.name ?? ''; if ( asset.name?.toLowerCase().includes(q) || serial.toLowerCase().includes(q) || contact.toLowerCase().includes(q) || lastUser.toLowerCase().includes(q) || String(asset.id) === q ) { results.push({ asset, serial, contact, customerName: customerMap.get(customerId) ?? '' }); if (results.length >= 8) return results; } } } return results; } export function setActiveAsset(assetId) { _activeAssetId = assetId; if (assetId === null) { _tree.querySelectorAll('.sb-asset').forEach(el => el.classList.remove('active')); return; } // Find which customer owns this asset and auto-expand if needed for (const [customerId, assets] of _assetCache) { const found = assets.find(a => a.id === assetId); if (found && !_expandedIds.has(customerId)) { _expandedIds.add(customerId); _saveExpandedState(); } } // Update highlight in DOM _tree.querySelectorAll('.sb-asset').forEach(el => { el.classList.toggle('active', Number(el.dataset.assetId) === assetId); }); // If the customer row needs to be expanded in the DOM but isn't yet rendered, re-render const activeRow = _tree.querySelector(`.sb-asset[data-asset-id="${assetId}"]`); if (!activeRow && _customers.length > 0) { _renderTree(); } } // Programmatically set filters and re-render the tree (used by Quick View click-through) export function setFiltersAndRender({ lifecycle = [], possession = null } = {}) { _filters.lifecycle = new Set(lifecycle); _filters.possession = possession; // Auto-expand any customer that has at least one matching asset for (const [customerId, assets] of _assetCache) { if (assets.some(a => _assetMatchesFilters(a))) { _expandedIds.add(customerId); } } _renderTree(); } // ── Internal: data loading ──────────────────────────────────────────────────── async function _loadCustomers() { try { _customers = await getCustomers(); // Sort alphabetically _customers.sort((a, b) => (a.business_name ?? a.name ?? '').localeCompare(b.business_name ?? b.name ?? '')); // Auto-expand when there's only one customer (e.g. client role) if (_customers.length === 1) { _expandedIds.add(_customers[0].id); } _renderTree(); // Re-expand any customers that were open before the page loaded — // if already in cache they render instantly, otherwise lazy-load with spinner for (const id of _expandedIds) { if (!_assetCache.has(id)) { _loadCustomerAssets(id); } } // Background: fetch all asset lists silently, then contacts, then start polling _preloadAllAssets().then(() => { _preloadAllContacts(); // fire-and-forget _startPolling(); }); } catch (err) { _tree.innerHTML = `
Failed to load clients
`; console.error('[assetBrowser] getCustomers error:', err); } } async function _loadCustomerAssets(customerId, silent = false) { if (!silent) { // Show spinner only when the user explicitly expands a customer const list = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); if (list) { list.innerHTML = `
Loading…
`; list.classList.add('visible'); } } try { const assets = await getCustomerAssets(customerId); _assetCache.set(customerId, assets); if (!silent) { _renderAssetList(customerId); } _updateCustomerCount(customerId); } catch (err) { if (!silent) { const list = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); if (list) { list.innerHTML = `
Failed to load assets
`; } } console.error(`[assetBrowser] getCustomerAssets(${customerId}) error:`, err); } } // ── Internal: background preload + polling ──────────────────────────────────── async function _preloadAllAssets() { // Fetch every customer's assets silently in parallel await Promise.allSettled(_customers.map(c => _loadCustomerAssets(c.id, true))); _saveAssetCache(); // Refresh any expanded customer lists with fresh data for (const customerId of _expandedIds) { if (_assetCache.has(customerId)) _renderAssetList(customerId); } } async function _preloadAllContacts() { await Promise.allSettled(_customers.map(async c => { try { const contacts = await getContacts(c.id); contacts.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); _contactCache.set(c.id, contacts); } catch (_) {} })); _saveContactCache(); } function _saveContactCache() { try { const obj = {}; for (const [id, contacts] of _contactCache) obj[id] = contacts; localStorage.setItem(LS_CONTACT_CACHE, JSON.stringify(obj)); } catch (_) {} } function _startPolling() { setInterval(_preloadAllAssets, POLL_INTERVAL_MS); } function _manualRefresh() { const btn = document.getElementById('sidebar-refresh'); if (btn) { btn.classList.add('spinning'); btn.disabled = true; } // Tell the server to wipe its cache, then clear our own localStorage caches fetch('/api/cache/refresh', { method: 'POST', credentials: 'same-origin' }) .catch(() => {}) // best-effort — proceed regardless .finally(() => { _assetCache.clear(); _contactCache.clear(); localStorage.removeItem(LS_ASSET_CACHE); localStorage.removeItem(LS_CACHE_TS); localStorage.removeItem(LS_CONTACT_CACHE); Promise.all([_preloadAllAssets(), _preloadAllContacts()]).finally(() => { if (btn) { btn.classList.remove('spinning'); btn.disabled = false; } }); }); } function _saveAssetCache() { try { const obj = {}; for (const [id, assets] of _assetCache) obj[id] = assets; localStorage.setItem(LS_ASSET_CACHE, JSON.stringify(obj)); localStorage.setItem(LS_CACHE_TS, Date.now().toString()); } catch (_) { /* storage full — ignore */ } } // ── Internal: rendering ─────────────────────────────────────────────────────── function _renderTree() { if (!_customers.length) { _tree.innerHTML = `
No clients found
`; return; } const filter = _filterText; const hasFilter = filter || _filters.lifecycle.size > 0 || _filters.possession !== null || _filters.infra !== null; const visible = _customers.filter(c => { // hideEmpty filtering (skip when a search/filter is active or assets not loaded yet) if (_displayPrefs.hideEmpty !== 'none' && !hasFilter && _assetCache.has(c.id)) { const assets = _assetCache.get(c.id); if (_displayPrefs.hideEmpty === 'zero-assets' && assets.length === 0) return false; if (_displayPrefs.hideEmpty === 'zero-billable' && _billableCount(assets) === 0) return false; } if (!hasFilter) return true; const name = (c.business_name ?? c.name ?? '').toLowerCase(); if (filter && name.includes(filter) && _filters.lifecycle.size === 0 && _filters.possession === null && _filters.infra === null) return true; // Show customer only if at least one of its cached assets passes all filters const assets = _assetCache.get(c.id) ?? []; return assets.some(a => _assetMatchesSearch(a, filter) && _assetMatchesFilters(a)); }); const rows = _sortedCustomers(visible) .map(c => _customerRowHTML(c)) .join(''); _tree.innerHTML = rows || `
No matches
`; // When a search is active, auto-expand every customer that has matching assets if (_filterText) { for (const [customerId, assets] of _assetCache) { if (assets.some(a => _assetMatchesSearch(a, _filterText) && _assetMatchesFilters(a))) { _expandedIds.add(customerId); } } } // Re-render asset lists for expanded customers for (const customerId of _expandedIds) { if (_assetCache.has(customerId)) { _renderAssetList(customerId); } // Mark customer row and asset list as expanded/visible const customerEl = _tree.querySelector(`.sb-customer[data-customer-id="${customerId}"]`); const listEl = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); if (customerEl) customerEl.classList.add('expanded'); if (listEl) listEl.classList.add('visible'); } // Re-apply active asset highlight if (_activeAssetId) { const el = _tree.querySelector(`.sb-asset[data-asset-id="${_activeAssetId}"]`); if (el) el.classList.add('active'); } } function _customerRowHTML(customer) { const id = customer.id; const name = customer.business_name ?? customer.name ?? `Customer ${id}`; const isExpanded = _expandedIds.has(id); let countBadge = ''; if (_assetCache.has(id)) { const assets = _assetCache.get(id); if (_displayPrefs.showCount) countBadge += `${_filteredAssetCount(assets)}`; if (_displayPrefs.showBillable) countBadge += `${_filteredBillableCount(assets)}`; } // Building block row const row = `
${_esc(name)} ${countBadge}
${isExpanded && !_assetCache.has(id) ? `
Loading…
` : ''}
`; return row; } function _renderAssetList(customerId) { const list = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); if (!list) return; const assets = _assetCache.get(customerId) ?? []; const filter = _filterText; const hasActiveFilter = filter || _filters.lifecycle.size > 0 || _filters.possession !== null || _filters.infra !== null; let filtered = hasActiveFilter ? assets.filter(a => _assetMatchesSearch(a, filter) && _assetMatchesFilters(a)) : assets.slice(); if (!filtered.length) { list.innerHTML = `
${hasActiveFilter ? 'No matches' : 'No assets'}
`; return; } _sortAssets(filtered); list.innerHTML = filtered.map(a => { const isActive = a.id === _activeAssetId; const assetType = _assetDisplayType(a); const badges = _assetBadgesHTML(a); return `
${_esc(a.name ?? `Asset ${a.id}`)}
${badges}${assetType ? `${_esc(assetType)}` : ''}
`; }).join(''); } // ── Billable helper ─────────────────────────────────────────────────────────── const _DEAD_STAGES = new Set(['For Parts', 'Decommissioned', 'Disposed of']); function _billableCount(assets) { return assets.filter(a => !_DEAD_STAGES.has(a.properties?.['Lifecycle Stage'])).length; } // Returns count of assets passing the currently active filters (falls back to total when no filter) function _filteredAssetCount(assets) { const hasFilter = _filterText || _filters.lifecycle.size > 0 || _filters.possession !== null || _filters.infra !== null; if (!hasFilter) return assets.length; return assets.filter(a => _assetMatchesSearch(a, _filterText) && _assetMatchesFilters(a)).length; } function _filteredBillableCount(assets) { const hasFilter = _filterText || _filters.lifecycle.size > 0 || _filters.possession !== null || _filters.infra !== null; if (!hasFilter) return _billableCount(assets); return assets.filter(a => _assetMatchesSearch(a, _filterText) && _assetMatchesFilters(a) && !_DEAD_STAGES.has(a.properties?.['Lifecycle Stage']) ).length; } // ── Sort + badge helpers ─────────────────────────────────────────────────────── const _POSSESSION_ORDER = { 'In IT Possession': 0, 'Deployed': 1, 'In User Possession': 1, // legacy value }; const _LIFECYCLE_ORDER = { 'Active': 0, 'Inventory': 1, 'Pre-Deployment': 2, 'For Repair': 3, 'For Upgrade': 4, 'For Parts': 5, 'Decommissioned': 6, 'Disposed of': 7, }; const _LIFECYCLE_SHORT = { 'Pre-Deployment': 'Pre-Deploy', 'Active': 'Active', 'Inventory': 'Inventory', 'For Repair': 'Repair', 'For Upgrade': 'Upgrade', 'For Parts': 'Parts', 'Decommissioned': 'Decomm.', 'Disposed of': 'Disposed', }; const _LIFECYCLE_CSS = { 'Pre-Deployment': 'sb-lc-predeployment', 'Active': 'sb-lc-active', 'Inventory': 'sb-lc-inventory', 'For Repair': 'sb-lc-repair', 'For Upgrade': 'sb-lc-upgrade', 'For Parts': 'sb-lc-parts', 'Decommissioned': 'sb-lc-decommissioned', 'Disposed of': 'sb-lc-disposed', }; function _sortAssets(arr) { if (_sortPrefs.assets === 'alpha') { arr.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); return; } if (_sortPrefs.assets === 'user') { arr.sort((a, b) => { const ua = (a.contact_fullname ?? a.contact?.name ?? '').toLowerCase(); const ub = (b.contact_fullname ?? b.contact?.name ?? '').toLowerCase(); if (!ua && ub) return 1; if (ua && !ub) return -1; if (ua !== ub) return ua.localeCompare(ub); return (a.name ?? '').localeCompare(b.name ?? ''); }); return; } if (_sortPrefs.assets === 'last-sync') { arr.sort((a, b) => { const ta = a.properties?.kabuto_information?.last_synced_at; const tb = b.properties?.kabuto_information?.last_synced_at; if (!ta && !tb) return (a.name ?? '').localeCompare(b.name ?? ''); if (!ta) return 1; if (!tb) return -1; return new Date(tb) - new Date(ta); // most recent first }); return; } // default: possession → lifecycle → name arr.sort((a, b) => { const pa = _POSSESSION_ORDER[a.properties?.['Possession Status']] ?? 2; const pb = _POSSESSION_ORDER[b.properties?.['Possession Status']] ?? 2; if (pa !== pb) return pa - pb; const la = _LIFECYCLE_ORDER[a.properties?.['Lifecycle Stage']] ?? 7; const lb = _LIFECYCLE_ORDER[b.properties?.['Lifecycle Stage']] ?? 7; if (la !== lb) return la - lb; return (a.name ?? '').localeCompare(b.name ?? ''); }); } function _sortedCustomers(arr) { if (_sortPrefs.clients === 'alpha') return arr; const copy = arr.slice(); if (_sortPrefs.clients === 'most-assets') { copy.sort((a, b) => { const d = (_assetCache.get(b.id)?.length ?? 0) - (_assetCache.get(a.id)?.length ?? 0); return d !== 0 ? d : (a.business_name ?? a.name ?? '').localeCompare(b.business_name ?? b.name ?? ''); }); } else if (_sortPrefs.clients === 'most-billable') { copy.sort((a, b) => { const d = _billableCount(_assetCache.get(b.id) ?? []) - _billableCount(_assetCache.get(a.id) ?? []); return d !== 0 ? d : (a.business_name ?? a.name ?? '').localeCompare(b.business_name ?? b.name ?? ''); }); } else if (_sortPrefs.clients === 'most-users') { copy.sort((a, b) => { const ua = new Set((_assetCache.get(a.id) ?? []).map(x => x.contact_fullname ?? x.contact?.name).filter(Boolean)).size; const ub = new Set((_assetCache.get(b.id) ?? []).map(x => x.contact_fullname ?? x.contact?.name).filter(Boolean)).size; return ub !== ua ? ub - ua : (a.business_name ?? a.name ?? '').localeCompare(b.business_name ?? b.name ?? ''); }); } return copy; } function _assetBadgesHTML(asset) { const possession = asset.properties?.['Possession Status']; const lifecycle = asset.properties?.['Lifecycle Stage']; const isInfra = asset.properties?.['Infrastructure'] === 'Yes'; const rawLastUser = asset.properties?.kabuto_information?.last_user ?? ''; const lastUser = normalizeUsername(rawLastUser); const assignedUser = asset.contact_fullname ?? null; const allContactNames = getCustomerContactNames(asset.customer_id); const sameUser = !!(lastUser && assignedUser && ( usernameFuzzyMatch(rawLastUser, assignedUser) || usernameFirstNameMatch(rawLastUser, assignedUser, allContactNames) || usernameNameInitialMatch(rawLastUser, assignedUser, allContactNames) || usernameInitialLastNameMatch(rawLastUser, assignedUser, allContactNames) )); let html = ''; if (_badgeVis.infra && isInfra) { html += `Infra`; } if (_badgeVis.possession) { if (possession === 'In IT Possession') { html += `IT`; } else if (possession === 'Deployed' || possession === 'In User Possession') { html += `Deployed`; } } if (_badgeVis.user && !isInfra) { if (sameUser) { // Show contact name (properly formatted from Syncro) with tooltip for long names html += `${_esc(assignedUser)}`; } else { if (lastUser) html += `${_esc(lastUser)}`; if (assignedUser) html += `${_esc(assignedUser)}`; } } if (_badgeVis.lifecycle && lifecycle && _LIFECYCLE_SHORT[lifecycle]) { const cls = _LIFECYCLE_CSS[lifecycle] ?? 'sb-lc-unknown'; const label = _LIFECYCLE_SHORT[lifecycle]; html += `${label}`; } return html; } function _assetDisplayType(asset) { // Use form_factor (set by Kabuto/RMM agent) first; fall back to asset_type // but skip generic "Syncro Device" placeholder const formFactor = asset.properties?.form_factor ?? asset.properties?.kabuto_information?.general?.form_factor; if (formFactor) return formFactor; const type = asset.asset_type ?? ''; return type === 'Syncro Device' ? '' : type; } function _updateCustomerCount(customerId) { const row = _tree.querySelector(`.sb-customer[data-customer-id="${customerId}"]`); if (!row) return; const assets = _assetCache.get(customerId) ?? []; // Total count badge if (_displayPrefs.showCount) { let b = row.querySelector('.sb-customer-count'); if (!b) { b = document.createElement('span'); b.className = 'sb-customer-count'; row.appendChild(b); } b.textContent = _filteredAssetCount(assets); } else { row.querySelector('.sb-customer-count')?.remove(); } // Billable count badge if (_displayPrefs.showBillable) { let b = row.querySelector('.sb-customer-billable'); if (!b) { b = document.createElement('span'); b.className = 'sb-customer-billable'; row.appendChild(b); } b.textContent = _filteredBillableCount(assets); } else { row.querySelector('.sb-customer-billable')?.remove(); } } // ── Internal: interaction ───────────────────────────────────────────────────── function _handleTreeClick(e) { const customerRow = e.target.closest('.sb-customer'); const assetRow = e.target.closest('.sb-asset'); if (customerRow) { _toggleCustomer(Number(customerRow.dataset.customerId)); } else if (assetRow) { const assetId = Number(assetRow.dataset.assetId); const customerId = Number(assetRow.dataset.customerId); const asset = _assetCache.get(customerId)?.find(a => a.id === assetId); if (asset) { if (asset.id === _activeAssetId && _onAssetClose) { _onAssetClose(); } else if (_onAssetSelect) { _onAssetSelect(asset); } } } } function _toggleCustomer(customerId) { if (_expandedIds.has(customerId)) { _expandedIds.delete(customerId); } else { _expandedIds.add(customerId); } _saveExpandedState(); // Toggle expanded class on customer row const customerEl = _tree.querySelector(`.sb-customer[data-customer-id="${customerId}"]`); const listEl = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); if (_expandedIds.has(customerId)) { customerEl?.classList.add('expanded'); listEl?.classList.add('visible'); if (_assetCache.has(customerId)) { // Already cached (preload or prior expand) — just render the list _renderAssetList(customerId); } else { // Not yet fetched — load with spinner _loadCustomerAssets(customerId); } } else { customerEl?.classList.remove('expanded'); listEl?.classList.remove('visible'); } } // ── Internal: panel open/close ──────────────────────────────────────────────── function _openSidePanel(which) { // which: 'filter' | 'menu' | null (close all) const filterPanel = _filterPanel; const menuPanel = document.getElementById('sidebar-menu-panel'); const menuBtn = document.getElementById('sidebar-menu-btn'); const openFilter = which === 'filter'; const openMenu = which === 'menu'; filterPanel?.classList.toggle('open', openFilter); _filterBtn?.classList.toggle('active', openFilter); _filterPanelOpen = openFilter; menuPanel?.classList.toggle('open', openMenu); menuBtn?.classList.toggle('active', openMenu); // Add .ready after the panel open animation completes so subsection body // transitions don't fire spuriously while the outer panel is animating. if (menuPanel) { menuPanel.classList.remove('ready'); if (openMenu) { // 230ms = panel max-height transition duration (0.22s) + small buffer setTimeout(() => menuPanel.classList.add('ready'), 230); } } } // ── Internal: filter handlers ───────────────────────────────────────────────── function _handleFilterClick(e) { const chip = e.target.closest('.sf-chip'); if (!chip) return; const section = chip.closest('[data-filter-type]'); const type = section?.dataset.filterType; if (type === 'lifecycle') { const val = chip.dataset.value; if (_filters.lifecycle.has(val)) { _filters.lifecycle.delete(val); chip.classList.remove('active'); } else { _filters.lifecycle.add(val); chip.classList.add('active'); } } else if (type === 'possession') { const val = chip.dataset.value; _filters.possession = val === '' ? null : val; section.querySelectorAll('.sf-chip').forEach(c => c.classList.toggle('active', c.dataset.value === val) ); } else if (type === 'infra') { const val = chip.dataset.value; _filters.infra = val === '' ? null : val === 'true'; section.querySelectorAll('.sf-chip').forEach(c => c.classList.toggle('active', c.dataset.value === val) ); } _saveFilterState(); _updateFilterBadge(); _renderTree(); } function _clearFilters() { _filters.lifecycle.clear(); _filters.possession = null; _filters.infra = null; if (_filterPanel) { // Reset lifecycle: clear all active chips _filterPanel.querySelectorAll('[data-filter-type="lifecycle"] .sf-chip').forEach(c => c.classList.remove('active') ); // Reset single-select sections: re-activate "All" chip _filterPanel.querySelectorAll('[data-filter-type="possession"] .sf-chip, [data-filter-type="infra"] .sf-chip').forEach(c => c.classList.toggle('active', c.dataset.value === '') ); } _saveFilterState(); _updateFilterBadge(); _renderTree(); } function _syncFilterChips() { if (!_filterPanel) return; _filterPanel.querySelectorAll('[data-filter-type="lifecycle"] .sf-chip').forEach(c => c.classList.toggle('active', _filters.lifecycle.has(c.dataset.value)) ); const possStr = _filters.possession === null ? '' : _filters.possession; _filterPanel.querySelectorAll('[data-filter-type="possession"] .sf-chip').forEach(c => c.classList.toggle('active', c.dataset.value === possStr) ); const infraStr = _filters.infra === null ? '' : String(_filters.infra); _filterPanel.querySelectorAll('[data-filter-type="infra"] .sf-chip').forEach(c => c.classList.toggle('active', c.dataset.value === infraStr) ); _updateFilterBadge(); } function _updateFilterBadge() { const count = _filters.lifecycle.size + (_filters.possession !== null ? 1 : 0) + (_filters.infra !== null ? 1 : 0); const badge = document.getElementById('sidebar-filter-badge'); if (badge) { badge.textContent = count; badge.hidden = count === 0; } } // ── Internal: search + filter predicates ────────────────────────────────────── function _assetMatchesSearch(asset, filter) { if (!filter) return true; const serial = asset.asset_serial ?? asset.serial ?? asset.serial_number ?? ''; const lastUser = asset.properties?.kabuto_information?.last_user ?? ''; const contact = asset.contact_fullname ?? asset.contact?.name ?? ''; return ( (asset.name ?? '').toLowerCase().includes(filter) || serial.toLowerCase().includes(filter) || contact.toLowerCase().includes(filter) || lastUser.toLowerCase().includes(filter) || String(asset.id) === filter ); } function _assetMatchesFilters(asset) { if (_filters.lifecycle.size > 0) { const lc = asset.properties?.['Lifecycle Stage'] ?? ''; if (!_filters.lifecycle.has(lc)) return false; } if (_filters.possession !== null) { const poss = asset.properties?.['Possession Status'] ?? ''; if (_filters.possession === 'IT' && poss !== 'In IT Possession') return false; if (_filters.possession === 'Deployed' && poss !== 'Deployed' && poss !== 'In User Possession') return false; } if (_filters.infra !== null) { const isInfra = asset.properties?.['Infrastructure'] === 'Yes'; if (_filters.infra !== isInfra) return false; } return true; } // ── Internal: persistence ───────────────────────────────────────────────────── function _restoreState() { // Expanded customer IDs try { const saved = localStorage.getItem(LS_EXPANDED); if (saved) { const ids = JSON.parse(saved); if (Array.isArray(ids)) _expandedIds = new Set(ids); } } catch (_) { /* ignore corrupt localStorage */ } // Badge vis, sort, display prefs — only if "remember menu" is on (default: true) if (localStorage.getItem(LS_REMEMBER_MENU) !== 'false') { try { const raw = localStorage.getItem(LS_BADGE_VIS); if (raw) _badgeVis = { ..._badgeVis, ...JSON.parse(raw) }; } catch (_) {} try { const r = localStorage.getItem(LS_SORT_PREFS); if (r) _sortPrefs = { ..._sortPrefs, ...JSON.parse(r) }; } catch (_) {} try { const r = localStorage.getItem(LS_DISPLAY_PREFS); if (r) _displayPrefs = { ..._displayPrefs, ...JSON.parse(r) }; } catch (_) {} } // Asset cache — populate immediately so tree renders with counts + assets on first paint try { const raw = localStorage.getItem(LS_ASSET_CACHE); if (raw) { const data = JSON.parse(raw); for (const [idStr, assets] of Object.entries(data)) { _assetCache.set(Number(idStr), assets); } } } catch (_) { /* ignore corrupt localStorage */ } // Contact cache try { const raw = localStorage.getItem(LS_CONTACT_CACHE); if (raw) { const data = JSON.parse(raw); for (const [idStr, contacts] of Object.entries(data)) { _contactCache.set(Number(idStr), contacts); } } } catch (_) {} // Filter state — only if "remember filters" is on (default: false) if (localStorage.getItem(LS_REMEMBER_FILTERS) === 'true') { try { const raw = localStorage.getItem(LS_FILTER_STATE); if (raw) { const saved = JSON.parse(raw); if (Array.isArray(saved.lifecycle)) saved.lifecycle.forEach(v => _filters.lifecycle.add(v)); if (saved.possession !== undefined) _filters.possession = saved.possession; if (saved.infra !== undefined) _filters.infra = saved.infra; } } catch (_) {} } } function _saveExpandedState() { localStorage.setItem(LS_EXPANDED, JSON.stringify([..._expandedIds])); } function _saveBadgeVis() { if (localStorage.getItem(LS_REMEMBER_MENU) !== 'false') localStorage.setItem(LS_BADGE_VIS, JSON.stringify(_badgeVis)); } function _saveSortPrefs() { if (localStorage.getItem(LS_REMEMBER_MENU) !== 'false') localStorage.setItem(LS_SORT_PREFS, JSON.stringify(_sortPrefs)); } function _saveDisplayPrefs() { if (localStorage.getItem(LS_REMEMBER_MENU) !== 'false') localStorage.setItem(LS_DISPLAY_PREFS, JSON.stringify(_displayPrefs)); } function _saveFilterState() { if (localStorage.getItem(LS_REMEMBER_FILTERS) !== 'true') return; try { localStorage.setItem(LS_FILTER_STATE, JSON.stringify({ lifecycle: [..._filters.lifecycle], possession: _filters.possession, infra: _filters.infra, })); } catch (_) {} } // ── Helpers ─────────────────────────────────────────────────────────────────── function _esc(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function _loadingHTML(msg) { return `
${_esc(msg)}
`; } // ── Menu panel wiring ───────────────────────────────────────────────────────── function _initMenuPanel() { // ── Sub-section collapse/expand ────────────────────────────────────────── document.querySelectorAll('.sidebar-menu-subsection-header').forEach(header => { header.addEventListener('click', () => { header.closest('.sidebar-menu-subsection')?.classList.toggle('open'); }); }); // ── Badge visibility checkboxes ────────────────────────────────────────── ['possession', 'user', 'lifecycle', 'infra'].forEach(key => { const cb = document.getElementById(`badge-vis-${key}`); if (!cb) return; cb.checked = _badgeVis[key]; cb.addEventListener('change', () => { _badgeVis[key] = cb.checked; _saveBadgeVis(); for (const customerId of _expandedIds) { if (_assetCache.has(customerId)) _renderAssetList(customerId); } }); }); // ── Display: show count ────────────────────────────────────────────────── const showCountCb = document.getElementById('disp-show-count'); if (showCountCb) { showCountCb.checked = _displayPrefs.showCount; showCountCb.addEventListener('change', () => { _displayPrefs.showCount = showCountCb.checked; _saveDisplayPrefs(); _renderTree(); }); } // ── Display: show billable ─────────────────────────────────────────────── const showBillableCb = document.getElementById('disp-show-billable'); if (showBillableCb) { showBillableCb.checked = _displayPrefs.showBillable; showBillableCb.addEventListener('change', () => { _displayPrefs.showBillable = showBillableCb.checked; _saveDisplayPrefs(); _renderTree(); }); } // ── Display: hide empty ────────────────────────────────────────────────── const hideEmptySel = document.getElementById('disp-hide-empty'); if (hideEmptySel) { hideEmptySel.value = _displayPrefs.hideEmpty; hideEmptySel.addEventListener('change', () => { _displayPrefs.hideEmpty = hideEmptySel.value; _saveDisplayPrefs(); _renderTree(); }); } // ── Remember menu settings ─────────────────────────────────────────────── const rememberMenuCb = document.getElementById('menu-remember'); if (rememberMenuCb) { rememberMenuCb.checked = localStorage.getItem(LS_REMEMBER_MENU) !== 'false'; rememberMenuCb.addEventListener('change', () => { localStorage.setItem(LS_REMEMBER_MENU, String(rememberMenuCb.checked)); if (rememberMenuCb.checked) { // Save current state immediately so it'll be there on next reload _saveBadgeVis(); _saveSortPrefs(); _saveDisplayPrefs(); } else { // Clear saved state so next reload uses defaults localStorage.removeItem(LS_BADGE_VIS); localStorage.removeItem(LS_SORT_PREFS); localStorage.removeItem(LS_DISPLAY_PREFS); } }); } // ── Sort chips ─────────────────────────────────────────────────────────── document.querySelectorAll('[data-menu-sort]').forEach(chip => { const sortType = chip.dataset.menuSort; // 'clients' or 'assets' const value = chip.dataset.value; // Set initial active state const currentVal = sortType === 'clients' ? _sortPrefs.clients : _sortPrefs.assets; chip.classList.toggle('active', value === currentVal); chip.addEventListener('click', () => { if (sortType === 'clients') { _sortPrefs.clients = value; _saveSortPrefs(); // Sync .active on sibling chips document.querySelectorAll('[data-menu-sort="clients"]').forEach(c => c.classList.toggle('active', c.dataset.value === value) ); _renderTree(); } else if (sortType === 'assets') { _sortPrefs.assets = value; _saveSortPrefs(); document.querySelectorAll('[data-menu-sort="assets"]').forEach(c => c.classList.toggle('active', c.dataset.value === value) ); // Re-render all currently expanded asset lists for (const customerId of _expandedIds) { if (_assetCache.has(customerId)) _renderAssetList(customerId); } } }); }); }