// inventree - vanilla JS driving the inventory tree and AJAX panels. // No build step, no frameworks: plain DOM APIs + fetch. (function () { 'use strict'; var treePane = document.getElementById('tree-pane'); var mainPanel = document.getElementById('main-panel'); var isAdmin = document.body.classList.contains('role-admin'); var draggedNode = null; var formReturnContext = { type: 'root', id: '' }; // --------------------------------------------------------------------- // Generic helpers // --------------------------------------------------------------------- function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } async function fetchText(url) { var res = await fetch(url); if (!res.ok) throw new Error('Request failed (' + res.status + ')'); return res.text(); } async function apiFetch(url, opts) { opts = opts || {}; var fetchOpts = { method: opts.method || 'GET', headers: {} }; if (opts.json !== undefined) { fetchOpts.headers['Content-Type'] = 'application/json'; fetchOpts.body = JSON.stringify(opts.json); } var res = await fetch(url, fetchOpts); var body = null; var ct = res.headers.get('content-type') || ''; if (ct.indexOf('application/json') !== -1) { body = await res.json(); } if (!res.ok) { var msg = (body && body.error) ? body.error : ('Request failed (' + res.status + ')'); throw new Error(msg); } return body ? body.data : null; } // --------------------------------------------------------------------- // Main panel navigation // --------------------------------------------------------------------- async function loadPanel(type, id) { var url; if (type === 'root') url = '/app/panel/root'; else if (type === 'folder') url = '/app/panel/folders/' + id; else if (type === 'asset') url = '/app/panel/assets/' + id; else return; var html = await fetchText(url); mainPanel.innerHTML = html; mainPanel.dataset.contextType = type; mainPanel.dataset.contextId = id || ''; } async function reloadCurrentPanel() { var type = mainPanel.dataset.contextType || 'root'; var id = mainPanel.dataset.contextId || ''; if (type !== 'root' && type !== 'folder' && type !== 'asset') return; await loadPanel(type, id); } function selectTreeRowVisually(type, id) { document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); }); var row; if (type === 'root') { row = document.querySelector('.tree-row[data-type="root"]'); } else { var node = document.querySelector('.tree-node[data-type="' + type + '"][data-id="' + id + '"]'); row = node ? node.querySelector(':scope > .tree-row') : null; } if (row) row.classList.add('selected'); } async function navigateAndSelect(type, id) { await loadPanel(type, id); selectTreeRowVisually(type, id); } async function selectNode(row) { document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); }); row.classList.add('selected'); var type = row.dataset.type; var id = row.dataset.id || ''; await loadPanel(type, id); } // --------------------------------------------------------------------- // Tree: refresh / expand / collapse // --------------------------------------------------------------------- async function refreshWholeTree() { var ul = document.getElementById('top-level-children'); if (!ul) return; var html = await fetchText('/app/tree/children'); ul.innerHTML = html; } async function toggleFolder(toggleBtn) { var li = toggleBtn.closest('.tree-node'); var childUl = li.querySelector(':scope > ul.tree-children'); var id = toggleBtn.dataset.id; var expanded = toggleBtn.classList.contains('expanded'); if (expanded) { toggleBtn.classList.remove('expanded'); if (childUl) childUl.classList.add('d-none'); fetch('/app/tree/collapse', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'folder_id=' + encodeURIComponent(id), }); } else { toggleBtn.classList.add('expanded'); if (childUl) { childUl.classList.remove('d-none'); var html = await fetchText('/app/tree/children?parent_id=' + encodeURIComponent(id) + '&persist=1'); childUl.innerHTML = html; } } } async function expandAllUnder(ul) { var folderLis = Array.prototype.filter.call(ul.children, function (li) { return li.dataset && li.dataset.type === 'folder'; }); for (var i = 0; i < folderLis.length; i++) { var li = folderLis[i]; var toggle = li.querySelector(':scope > .tree-row > .tree-toggle'); if (toggle && !toggle.classList.contains('expanded')) { await toggleFolder(toggle); } var childUl = li.querySelector(':scope > ul.tree-children'); if (childUl) await expandAllUnder(childUl); } } async function expandAllHandler() { var top = document.getElementById('top-level-children'); if (top) await expandAllUnder(top); } async function collapseAllHandler() { document.querySelectorAll('.tree-toggle.expanded').forEach(function (t) { t.classList.remove('expanded'); var li = t.closest('.tree-node'); var childUl = li ? li.querySelector(':scope > ul.tree-children') : null; if (childUl) childUl.classList.add('d-none'); }); try { await fetch('/app/tree/collapse-all', { method: 'POST' }); } catch (e) { /* ignore */ } } // --------------------------------------------------------------------- // Tree context menu ("...") // --------------------------------------------------------------------- var ctxMenu = null; function ensureCtxMenu() { if (ctxMenu) return ctxMenu; ctxMenu = document.createElement('div'); ctxMenu.className = 'dropdown-menu tree-ctx-menu'; ctxMenu.style.position = 'fixed'; ctxMenu.style.display = 'none'; ctxMenu.innerHTML = '' + '' + '' + ''; document.body.appendChild(ctxMenu); return ctxMenu; } function closeCtxMenu() { if (ctxMenu) ctxMenu.style.display = 'none'; } function openTreeContextMenu(btn) { var menu = ensureCtxMenu(); var rect = btn.getBoundingClientRect(); menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px'; menu.style.top = rect.bottom + 'px'; menu.style.display = 'block'; menu.dataset.folderId = btn.dataset.id; } document.addEventListener('click', function (e) { if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target) && !e.target.closest('.btn-tree-action')) { closeCtxMenu(); } var menuAction = e.target.closest('[data-menu-action]'); if (menuAction && ctxMenu) { var id = ctxMenu.dataset.folderId; var action = menuAction.dataset.menuAction; closeCtxMenu(); if (action === 'new-subfolder') openFolderModal({ parentId: id }); else if (action === 'new-asset') loadAssetForm({ folderId: id }); else if (action === 'rename') openRenameFolder(id); else if (action === 'delete') startDeleteFolder(id); } }); // --------------------------------------------------------------------- // Folder create / rename modal // --------------------------------------------------------------------- var folderModalEl = document.getElementById('folderModal'); var folderModal = (folderModalEl && window.bootstrap) ? new bootstrap.Modal(folderModalEl) : null; function openFolderModal(opts) { if (!folderModal) return; document.getElementById('folderFormId').value = ''; document.getElementById('folderFormParentId').value = (opts && opts.parentId) || ''; document.getElementById('folderFormName').value = ''; document.getElementById('folderFormDescription').value = ''; document.getElementById('folderFormIcon').value = ''; document.getElementById('folderFormSortOrder').value = '0'; document.getElementById('folderModalTitle').textContent = 'New Folder'; document.getElementById('folderFormError').classList.add('d-none'); folderModal.show(); } async function openRenameFolder(id) { if (!folderModal) return; try { var f = await apiFetch('/app/folders/' + id); document.getElementById('folderFormId').value = f.ID; document.getElementById('folderFormParentId').value = ''; document.getElementById('folderFormName').value = f.Name; document.getElementById('folderFormDescription').value = f.Description; document.getElementById('folderFormIcon').value = f.Icon; document.getElementById('folderFormSortOrder').value = f.SortOrder; document.getElementById('folderModalTitle').textContent = 'Rename Folder'; document.getElementById('folderFormError').classList.add('d-none'); folderModal.show(); } catch (err) { alert('Could not load folder: ' + err.message); } } var folderFormEl = document.getElementById('folderForm'); if (folderFormEl) { folderFormEl.addEventListener('submit', async function (e) { e.preventDefault(); var id = document.getElementById('folderFormId').value; var payload = { name: document.getElementById('folderFormName').value, description: document.getElementById('folderFormDescription').value, icon: document.getElementById('folderFormIcon').value, sort_order: Number(document.getElementById('folderFormSortOrder').value || 0), }; var errorBox = document.getElementById('folderFormError'); errorBox.classList.add('d-none'); try { if (id) { await apiFetch('/app/folders/' + id, { method: 'PATCH', json: payload }); } else { var parentId = document.getElementById('folderFormParentId').value; payload.parent_id = parentId ? Number(parentId) : null; await apiFetch('/app/folders', { method: 'POST', json: payload }); } folderModal.hide(); await refreshWholeTree(); await reloadCurrentPanel(); } catch (err) { errorBox.textContent = err.message; errorBox.classList.remove('d-none'); } }); } // --------------------------------------------------------------------- // Folder delete modal // --------------------------------------------------------------------- var deleteFolderModalEl = document.getElementById('deleteFolderModal'); var deleteFolderModal = (deleteFolderModalEl && window.bootstrap) ? new bootstrap.Modal(deleteFolderModalEl) : null; async function startDeleteFolder(id) { if (!deleteFolderModal) return; try { var info = await apiFetch('/app/folders/' + id + '/delete-info'); deleteFolderModalEl.dataset.folderId = id; document.getElementById('deleteFolderName').textContent = info.folder_name; var empty = info.folder_count === 0 && info.asset_count === 0; document.getElementById('deleteFolderEmptyNotice').classList.toggle('d-none', !empty); document.getElementById('deleteFolderNotEmptyNotice').classList.toggle('d-none', empty); if (!empty) { document.getElementById('deleteFolderCounts').textContent = info.folder_count + ' folder(s) and ' + info.asset_count + ' asset(s)'; var sel = document.getElementById('deleteFolderDestination'); sel.innerHTML = info.destinations.map(function (d) { return ''; }).join(''); document.getElementById('modeRelocate').checked = true; } deleteFolderModal.show(); } catch (err) { alert('Could not load folder info: ' + err.message); } } var confirmDeleteFolderBtn = document.getElementById('confirmDeleteFolderBtn'); if (confirmDeleteFolderBtn) { confirmDeleteFolderBtn.addEventListener('click', async function () { var id = deleteFolderModalEl.dataset.folderId; var notEmpty = !document.getElementById('deleteFolderNotEmptyNotice').classList.contains('d-none'); var url = '/app/folders/' + id; if (notEmpty) { var mode = document.querySelector('input[name="delete_mode"]:checked').value; if (mode === 'cascade') { url += '?mode=cascade'; } else { var dest = document.getElementById('deleteFolderDestination').value; url += '?mode=relocate&dest=' + encodeURIComponent(dest); } } try { await apiFetch(url, { method: 'DELETE' }); deleteFolderModal.hide(); await refreshWholeTree(); await navigateAndSelect('root', ''); } catch (err) { alert('Delete failed: ' + err.message); } }); } // --------------------------------------------------------------------- // Asset form (create / edit) // --------------------------------------------------------------------- async function loadAssetForm(opts) { formReturnContext = { type: mainPanel.dataset.contextType || 'root', id: mainPanel.dataset.contextId || '' }; var params = new URLSearchParams(); if (opts && opts.folderId) params.set('folder_id', opts.folderId); var qs = params.toString(); var html = await fetchText('/app/panel/assets/new' + (qs ? '?' + qs : '')); mainPanel.innerHTML = html; mainPanel.dataset.contextType = 'asset-form'; mainPanel.dataset.contextId = ''; } async function loadAssetEditForm(id) { var html = await fetchText('/app/panel/assets/' + id + '/edit'); mainPanel.innerHTML = html; mainPanel.dataset.contextType = 'asset-form'; mainPanel.dataset.contextId = id; } function cancelAssetForm(btn) { if (btn.dataset.id) { navigateAndSelect('asset', btn.dataset.id); } else { navigateAndSelect(formReturnContext.type, formReturnContext.id); } } async function submitAssetForm(form) { var errorBox = document.getElementById('assetFormError'); errorBox.classList.add('d-none'); var fd = new FormData(form); var payload = { fields: {} }; for (var pair of fd.entries()) { var key = pair[0], value = pair[1]; if (key.indexOf('field_') === 0) { payload.fields[key.slice(6)] = value; } else if (key === 'item_type_id') { payload.item_type_id = Number(value); } else if (key === 'folder_id') { payload.folder_id = value ? Number(value) : null; } else if (key === 'status_id') { payload.status_id = Number(value); } else if (key === 'assigned_user_id') { payload.assigned_user_id = value ? Number(value) : null; } else { payload[key] = value; } } var mode = form.dataset.mode; var url = mode === 'edit' ? '/app/assets/' + form.dataset.assetId : '/app/assets'; var method = mode === 'edit' ? 'PATCH' : 'POST'; try { var res = await apiFetch(url, { method: method, json: payload }); await refreshWholeTree(); await navigateAndSelect('asset', res.id); } catch (err) { errorBox.textContent = err.message; errorBox.classList.remove('d-none'); } } async function duplicateAsset(id) { try { var res = await apiFetch('/app/assets/' + id + '/duplicate', { method: 'POST' }); await refreshWholeTree(); await navigateAndSelect('asset', res.id); } catch (err) { alert('Duplicate failed: ' + err.message); } } async function deleteAsset(id, name) { if (!confirm('Delete asset "' + name + '"? This cannot be undone.')) return; try { var res = await apiFetch('/app/assets/' + id, { method: 'DELETE' }); await refreshWholeTree(); if (res.folder_id) await navigateAndSelect('folder', res.folder_id); else await navigateAndSelect('root', ''); } catch (err) { alert('Delete failed: ' + err.message); } } async function confirmMoveAsset(id, selectId) { var select = document.getElementById(selectId); var val = select.value; try { await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } }); var modalEl = select.closest('.modal'); if (modalEl && window.bootstrap) { var inst = bootstrap.Modal.getInstance(modalEl); if (inst) inst.hide(); } await refreshWholeTree(); await navigateAndSelect('asset', id); } catch (err) { alert('Move failed: ' + err.message); } } // --------------------------------------------------------------------- // Folder listing filters / pagination // --------------------------------------------------------------------- async function loadFolderPanelWithFilters(folderId, overrides) { var form = mainPanel.querySelector('.panel-filter-form'); var params = new URLSearchParams(); if (form) { var fd = new FormData(form); for (var pair of fd.entries()) { if (pair[1]) params.set(pair[0], pair[1]); } } if (overrides) { for (var k in overrides) params.set(k, overrides[k]); } var qs = params.toString(); var html = await fetchText('/app/panel/folders/' + folderId + (qs ? '?' + qs : '')); mainPanel.innerHTML = html; mainPanel.dataset.contextType = 'folder'; mainPanel.dataset.contextId = folderId; } // --------------------------------------------------------------------- // Search // --------------------------------------------------------------------- async function runSearch(q) { if (!q || !q.trim()) return; var html = await fetchText('/app/search?q=' + encodeURIComponent(q)); mainPanel.innerHTML = html; mainPanel.dataset.contextType = 'search'; mainPanel.dataset.contextId = ''; document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); }); } var treeSearchEl = document.getElementById('tree-search'); if (treeSearchEl) { var searchDebounce; treeSearchEl.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); runSearch(treeSearchEl.value); } }); treeSearchEl.addEventListener('input', function () { clearTimeout(searchDebounce); var q = treeSearchEl.value; if (!q) return; searchDebounce = setTimeout(function () { runSearch(q); }, 400); }); } // --------------------------------------------------------------------- // CSV import // --------------------------------------------------------------------- var importFormEl = document.getElementById('importForm'); if (importFormEl) { importFormEl.addEventListener('submit', async function (e) { e.preventDefault(); var resultBox = document.getElementById('importResult'); resultBox.innerHTML = '