// 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 = `