1105 lines
43 KiB
JavaScript
Executable file
1105 lines
43 KiB
JavaScript
Executable file
// 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 = `<div class="sb-loading" style="color:var(--red-500)">Failed to load clients</div>`;
|
|
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 = `<div class="sb-asset-loading"><div class="sb-spinner"></div> Loading…</div>`;
|
|
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 = `<div class="sb-asset-empty" style="color:var(--red-500)">Failed to load assets</div>`;
|
|
}
|
|
}
|
|
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 = `<div class="sb-empty">No clients found</div>`;
|
|
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 || `<div class="sb-empty">No matches</div>`;
|
|
|
|
// 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 += `<span class="sb-customer-count">${_filteredAssetCount(assets)}</span>`;
|
|
if (_displayPrefs.showBillable) countBadge += `<span class="sb-customer-billable">${_filteredBillableCount(assets)}</span>`;
|
|
}
|
|
|
|
// Building block row
|
|
const row = `
|
|
<div class="sb-customer${isExpanded ? ' expanded' : ''}" data-customer-id="${id}">
|
|
<svg class="sb-customer-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<polyline points="9 18 15 12 9 6"/>
|
|
</svg>
|
|
<svg class="sb-customer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
|
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
|
</svg>
|
|
<span class="sb-customer-name">${_esc(name)}</span>
|
|
${countBadge}
|
|
</div>
|
|
<div class="sb-asset-list${isExpanded ? ' visible' : ''}" data-customer-id="${id}">
|
|
${isExpanded && !_assetCache.has(id) ? `<div class="sb-asset-loading"><div class="sb-spinner"></div> Loading…</div>` : ''}
|
|
</div>`;
|
|
|
|
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 = `<div class="sb-asset-empty">${hasActiveFilter ? 'No matches' : 'No assets'}</div>`;
|
|
return;
|
|
}
|
|
|
|
_sortAssets(filtered);
|
|
|
|
list.innerHTML = filtered.map(a => {
|
|
const isActive = a.id === _activeAssetId;
|
|
const assetType = _assetDisplayType(a);
|
|
const badges = _assetBadgesHTML(a);
|
|
return `
|
|
<div class="sb-asset${isActive ? ' active' : ''}" data-asset-id="${a.id}" data-customer-id="${customerId}">
|
|
<span class="sb-asset-name">${_esc(a.name ?? `Asset ${a.id}`)}</span>
|
|
<div class="sb-asset-meta-row">
|
|
${badges}${assetType ? `<span class="sb-asset-type">${_esc(assetType)}</span>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}).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 += `<span class="sb-badge sb-infra">Infra</span>`;
|
|
}
|
|
|
|
if (_badgeVis.possession) {
|
|
if (possession === 'In IT Possession') {
|
|
html += `<span class="sb-badge sb-poss-it">IT</span>`;
|
|
} else if (possession === 'Deployed' || possession === 'In User Possession') {
|
|
html += `<span class="sb-badge sb-poss-user">Deployed</span>`;
|
|
}
|
|
}
|
|
|
|
if (_badgeVis.user && !isInfra) {
|
|
if (sameUser) {
|
|
// Show contact name (properly formatted from Syncro) with tooltip for long names
|
|
html += `<span class="sb-badge sb-user-same" title="${_esc(assignedUser)}">${_esc(assignedUser)}</span>`;
|
|
} else {
|
|
if (lastUser) html += `<span class="sb-badge sb-user-last" title="${_esc(lastUser)}">${_esc(lastUser)}</span>`;
|
|
if (assignedUser) html += `<span class="sb-badge sb-user-assigned" title="${_esc(assignedUser)}">${_esc(assignedUser)}</span>`;
|
|
}
|
|
}
|
|
|
|
if (_badgeVis.lifecycle && lifecycle && _LIFECYCLE_SHORT[lifecycle]) {
|
|
const cls = _LIFECYCLE_CSS[lifecycle] ?? 'sb-lc-unknown';
|
|
const label = _LIFECYCLE_SHORT[lifecycle];
|
|
html += `<span class="sb-badge ${cls}">${label}</span>`;
|
|
}
|
|
|
|
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, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function _loadingHTML(msg) {
|
|
return `<div class="sb-loading"><div class="sb-spinner"></div>${_esc(msg)}</div>`;
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|