asset_browser/public/modules/assetBrowser.js
2026-03-27 09:18:39 -04:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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);
}
}
});
});
}