// inventree - vanilla JS driving the inventory tree and AJAX panels. // No build step, no frameworks: plain DOM APIs + fetch. // // Interaction model: the tree and panels behave like a native file manager. // Left click selects/opens. Right click (or the keyboard Menu key, which // browsers also fire as a "contextmenu" event) opens a context menu with // the actions available for whatever was clicked - a folder, an asset, or // empty background (which offers "New Folder" / "Add Asset" for wherever // you right-clicked). There are intentionally very few standalone buttons. (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; // --------------------------------------------------------------------- // 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 */ } } // --------------------------------------------------------------------- // Generic right-click context menu // --------------------------------------------------------------------- var ctxMenu = null; function ensureCtxMenu() { if (ctxMenu) return ctxMenu; ctxMenu = document.createElement('div'); ctxMenu.className = 'dropdown-menu ctx-menu'; ctxMenu.style.display = 'none'; document.body.appendChild(ctxMenu); return ctxMenu; } function closeCtxMenu() { if (ctxMenu) ctxMenu.style.display = 'none'; } // showContextMenu renders `items` (each {label, icon, danger, action} or // {divider:true}) as a floating menu at the given viewport coordinates, // clamped so it never runs off-screen. function showContextMenu(x, y, items) { items = items.filter(function (item, i) { // Collapse a divider that would be first, last, or doubled. if (!item.divider) return true; var prevIsDivider = i === 0 || items[i - 1].divider; var isLast = i === items.length - 1; return !prevIsDivider && !isLast; }); if (items.length === 0) return; var menu = ensureCtxMenu(); menu.innerHTML = items.map(function (item) { if (item.divider) return ''; var cls = 'dropdown-item' + (item.danger ? ' text-danger' : ''); return ''; }).join(''); var buttons = menu.querySelectorAll('button.dropdown-item'); var actionable = items.filter(function (i) { return !i.divider; }); buttons.forEach(function (btn, i) { btn.addEventListener('click', function () { closeCtxMenu(); actionable[i].action(); }); }); menu.style.display = 'block'; menu.style.left = '0px'; menu.style.top = '0px'; var rect = menu.getBoundingClientRect(); var left = Math.min(x, window.innerWidth - rect.width - 8); var top = Math.min(y, window.innerHeight - rect.height - 8); menu.style.left = Math.max(4, left) + 'px'; menu.style.top = Math.max(4, top) + 'px'; } document.addEventListener('click', function (e) { if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target)) closeCtxMenu(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeCtxMenu(); }); window.addEventListener('scroll', closeCtxMenu, true); window.addEventListener('resize', closeCtxMenu); // ---- Menu builders per target kind ----------------------------------- function folderMenuItems(id) { var items = []; if (isAdmin) { items.push({ label: 'New Subfolder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: id }); } }); items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: id }); } }); items.push({ divider: true }); items.push({ label: 'Rename', icon: 'bi-pencil', action: function () { openRenameFolder(id); } }); items.push({ divider: true }); items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(id); } }); } items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?folder_id=' + id + '&recursive=1'; } }); items.push({ label: 'Print Labels…', icon: 'bi-qr-code', action: function () { window.open('/labels?folder_id=' + id + '&recursive=1', '_blank'); } }); if (isAdmin) { items.push({ divider: true }); items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { startDeleteFolder(id); } }); } return items; } function assetMenuItems(id) { var items = [{ label: 'Open', icon: 'bi-box-arrow-up-right', action: function () { navigateAndSelect('asset', id); } }]; items.push({ label: 'Print Label', icon: 'bi-qr-code', action: function () { window.open('/labels?asset_id=' + id, '_blank'); } }); if (isAdmin) { items.push({ label: 'Edit', icon: 'bi-pencil', action: function () { loadAssetEditForm(id); } }); items.push({ label: 'Check Out…', icon: 'bi-person-check', action: function () { openCheckoutModal(id); } }); items.push({ label: 'Duplicate', icon: 'bi-files', action: function () { duplicateAsset(id); } }); items.push({ label: 'Move to…', icon: 'bi-arrows-move', action: function () { openMoveAssetModal(id); } }); items.push({ divider: true }); items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { deleteAsset(id); } }); } return items; } // Import is intentionally folder-scoped only (see openImportModal) - it // never appears at the top level, since a global admin import with // folder-path resolution lives under Settings > Import / Export instead. function treeBackgroundMenuItems() { var items = []; if (isAdmin) { items.push({ label: 'New Folder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: null }); } }); items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: null }); } }); items.push({ divider: true }); } items.push({ label: 'Expand All', icon: 'bi-arrows-expand', action: expandAllHandler }); items.push({ label: 'Collapse All', icon: 'bi-arrows-collapse', action: collapseAllHandler }); items.push({ divider: true }); items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv'; } }); return items; } function panelBackgroundMenuItems(folderId) { var items = []; if (isAdmin) { items.push({ label: folderId ? 'New Subfolder' : 'New Folder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: folderId }); } }); items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: folderId }); } }); if (folderId) { items.push({ divider: true }); items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(folderId); } }); } } var exportUrl = folderId ? ('/export/assets.csv?folder_id=' + folderId + '&recursive=1') : '/export/assets.csv'; items.push({ divider: true }); items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = exportUrl; } }); return items; } // ---- Wiring: tree pane ------------------------------------------------- if (treePane) { treePane.addEventListener('contextmenu', function (e) { if (e.target.closest('input, button')) return; // let native menus work on the search box etc. var row = e.target.closest('.tree-row'); if (!row || row.dataset.type === 'root') { e.preventDefault(); showContextMenu(e.clientX, e.clientY, treeBackgroundMenuItems()); return; } var type = row.dataset.type; if (type !== 'folder' && type !== 'asset') return; // e.g. the "N more assets" row: no menu e.preventDefault(); document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); }); row.classList.add('selected'); showContextMenu(e.clientX, e.clientY, type === 'folder' ? folderMenuItems(row.dataset.id) : assetMenuItems(row.dataset.id)); }); } // ---- Wiring: main panel ------------------------------------------------- if (mainPanel) { mainPanel.addEventListener('contextmenu', function (e) { var folderLink = e.target.closest('.folder-link'); if (folderLink) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, folderMenuItems(folderLink.dataset.id)); return; } var assetRow = e.target.closest('.asset-link, .asset-row'); if (assetRow) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, assetMenuItems(assetRow.dataset.id)); return; } var assetHeader = e.target.closest('[data-context="asset-header"]'); if (assetHeader) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, assetMenuItems(assetHeader.dataset.id)); return; } var folderHeader = e.target.closest('[data-context="folder-header"]'); if (folderHeader) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, folderMenuItems(folderHeader.dataset.id)); return; } var rootCrumb = e.target.closest('.breadcrumb [data-type="root"]'); if (rootCrumb) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, panelBackgroundMenuItems(null)); return; } var folderCrumb = e.target.closest('.breadcrumb [data-type="folder"]'); if (folderCrumb) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, folderMenuItems(folderCrumb.dataset.id)); return; } if (e.target.closest('a, button, input, select, textarea, form, .list-group-item')) return; var panelContent = e.target.closest('.panel-content'); var panelType = panelContent && panelContent.dataset.panelType; if (panelType === 'root' || panelType === 'folder') { e.preventDefault(); var folderId = panelType === 'folder' ? panelContent.dataset.folderId : null; showContextMenu(e.clientX, e.clientY, panelBackgroundMenuItems(folderId)); } }); } // --------------------------------------------------------------------- // Icon picker: a searchable dropdown of common Bootstrap Icons standing // in for a free-text icon-class input (used by the folder modal and the // item type form). // --------------------------------------------------------------------- var COMMON_ICONS = [ 'bi-folder-fill', 'bi-folder', 'bi-archive', 'bi-box-seam', 'bi-boxes', 'bi-laptop', 'bi-pc-display', 'bi-pc', 'bi-display', 'bi-tv', 'bi-projector', 'bi-phone', 'bi-telephone', 'bi-tablet', 'bi-smartwatch', 'bi-camera-video', 'bi-camera', 'bi-printer', 'bi-keyboard', 'bi-mouse', 'bi-hdd', 'bi-hdd-rack', 'bi-hdd-stack', 'bi-server', 'bi-cpu', 'bi-memory', 'bi-disc', 'bi-usb-drive', 'bi-usb-plug', 'bi-ethernet', 'bi-router', 'bi-wifi', 'bi-shield-lock', 'bi-shield-check', 'bi-lock', 'bi-unlock', 'bi-plug', 'bi-battery', 'bi-battery-charging', 'bi-gear', 'bi-gear-fill', 'bi-tools', 'bi-wrench', 'bi-tag', 'bi-tags', 'bi-bookmark', 'bi-house-door-fill', 'bi-building', 'bi-person', 'bi-people', ]; function setIconPickerValue(input, val) { input.value = val || ''; var container = input.closest('.icon-picker'); if (!container) return; var preview = container.querySelector('.icon-picker-preview'); var label = container.querySelector('.icon-picker-label'); if (val) { preview.className = 'bi ' + val + ' icon-picker-preview'; label.textContent = val; label.classList.remove('text-body-secondary'); } else { preview.className = 'bi icon-picker-preview'; label.textContent = 'Choose icon…'; label.classList.add('text-body-secondary'); } } function closeAllIconPickers() { document.querySelectorAll('.icon-picker-menu.show').forEach(function (m) { m.classList.remove('show'); }); } function initIconPickers() { document.querySelectorAll('[data-icon-picker]').forEach(function (container) { if (container.dataset.iconPickerInit) return; container.dataset.iconPickerInit = '1'; var input = container.querySelector('input[type="hidden"]'); var toggle = container.querySelector('.icon-picker-toggle'); var menu = container.querySelector('.icon-picker-menu'); var search = container.querySelector('.icon-picker-search'); var grid = container.querySelector('.icon-picker-grid'); var clearBtn = container.querySelector('.icon-picker-clear'); function renderGrid(filter) { filter = (filter || '').toLowerCase(); var list = COMMON_ICONS.filter(function (ic) { return !filter || ic.indexOf(filter) !== -1; }); grid.innerHTML = list.map(function (ic) { return ''; }).join('') || '
No matches
'; } toggle.addEventListener('click', function (e) { e.stopPropagation(); var isOpen = menu.classList.contains('show'); closeAllIconPickers(); if (!isOpen) { menu.classList.add('show'); search.value = ''; renderGrid(''); search.focus(); } }); search.addEventListener('input', function () { renderGrid(search.value.trim()); }); grid.addEventListener('click', function (e) { var btn = e.target.closest('.icon-picker-item'); if (!btn) return; setIconPickerValue(input, btn.dataset.icon); menu.classList.remove('show'); }); if (clearBtn) { clearBtn.addEventListener('click', function () { setIconPickerValue(input, ''); menu.classList.remove('show'); }); } renderGrid(''); setIconPickerValue(input, input.value); }); } document.addEventListener('click', function (e) { if (!e.target.closest('.icon-picker')) closeAllIconPickers(); }); initIconPickers(); // --------------------------------------------------------------------- // 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 = ''; setIconPickerValue(document.getElementById('folderFormIcon'), ''); 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; setIconPickerValue(document.getElementById('folderFormIcon'), 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); } }); } // --------------------------------------------------------------------- // Move asset modal // --------------------------------------------------------------------- var moveAssetModalEl = document.getElementById('moveAssetModal'); var moveAssetModal = (moveAssetModalEl && window.bootstrap) ? new bootstrap.Modal(moveAssetModalEl) : null; async function openMoveAssetModal(id) { if (!moveAssetModal) return; moveAssetModalEl.dataset.assetId = id; var sel = document.getElementById('moveAssetDestination'); sel.innerHTML = ''; try { var opts = await apiFetch('/app/folders/options'); (opts || []).forEach(function (o) { var indent = ''; for (var i = 0; i < o.depth; i++) indent += '   '; var opt = document.createElement('option'); opt.value = o.id; opt.innerHTML = indent + escapeHtml(o.path); sel.appendChild(opt); }); } catch (err) { /* fall back to top-level-only */ } moveAssetModal.show(); } var confirmMoveAssetBtn = document.getElementById('confirmMoveAssetBtn'); if (confirmMoveAssetBtn) { confirmMoveAssetBtn.addEventListener('click', async function () { var id = moveAssetModalEl.dataset.assetId; var val = document.getElementById('moveAssetDestination').value; try { await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } }); moveAssetModal.hide(); await refreshWholeTree(); await navigateAndSelect('asset', id); } catch (err) { alert('Move failed: ' + err.message); } }); } // --------------------------------------------------------------------- // Check out / check in modal // --------------------------------------------------------------------- var checkoutModalEl = document.getElementById('checkoutModal'); var checkoutModal = (checkoutModalEl && window.bootstrap) ? new bootstrap.Modal(checkoutModalEl) : null; async function openCheckoutModal(id) { if (!checkoutModal) return; checkoutModalEl.dataset.assetId = id; var sel = document.getElementById('checkoutUserSelect'); var currentText = document.getElementById('checkoutCurrent'); sel.innerHTML = ''; currentText.textContent = 'Loading…'; try { var asset = await apiFetch('/app/assets/' + id); var users = await apiFetch('/app/users/options'); (users || []).forEach(function (u) { var opt = document.createElement('option'); opt.value = u.id; opt.textContent = u.username; if (asset.AssignedUserID === u.id) opt.selected = true; sel.appendChild(opt); }); currentText.textContent = asset.AssignedUsername ? 'Currently checked out to ' + asset.AssignedUsername + '.' : 'Not currently checked out to anyone.'; document.getElementById('checkoutModalTitle').textContent = 'Check Out "' + asset.Name + '"'; } catch (err) { currentText.textContent = ''; } checkoutModal.show(); } var confirmCheckoutBtn = document.getElementById('confirmCheckoutBtn'); if (confirmCheckoutBtn) { confirmCheckoutBtn.addEventListener('click', async function () { var id = checkoutModalEl.dataset.assetId; var val = document.getElementById('checkoutUserSelect').value; try { await apiFetch('/app/assets/' + id + '/checkout', { method: 'POST', json: { assigned_user_id: val ? Number(val) : null } }); checkoutModal.hide(); await navigateAndSelect('asset', id); } catch (err) { alert('Checkout failed: ' + err.message); } }); } // --------------------------------------------------------------------- // Asset form (create / edit) - shown in a modal // --------------------------------------------------------------------- var assetModalEl = document.getElementById('assetModal'); var assetModal = (assetModalEl && window.bootstrap) ? new bootstrap.Modal(assetModalEl) : null; function showAssetModal(html) { if (!assetModal) return; document.getElementById('assetModalBody').innerHTML = html; var form = document.getElementById('assetForm'); document.getElementById('assetModalTitle').textContent = form.dataset.title; document.getElementById('assetModalSubmit').textContent = form.dataset.mode === 'edit' ? 'Save Changes' : 'Create Asset'; assetModal.show(); } async function loadAssetForm(opts) { 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 : '')); showAssetModal(html); } async function loadAssetEditForm(id) { var html = await fetchText('/app/panel/assets/' + id + '/edit'); showAssetModal(html); } 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 }); if (assetModal) assetModal.hide(); 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) { if (!confirm('Delete this asset? 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); } } // adjustAssetQuantity handles the +/- steppers shown next to non-unique // assets (in the asset detail panel and folder asset tables), updating // the displayed number in place without reloading the whole panel. async function adjustAssetQuantity(stepBtn) { var wrapper = stepBtn.closest('[data-quantity-stepper]'); if (!wrapper) return; var id = wrapper.dataset.assetId; var valueEl = wrapper.querySelector('[data-quantity-value]'); var delta = parseInt(stepBtn.dataset.step, 10) || 0; try { var res = await apiFetch('/app/assets/' + id + '/quantity', { method: 'POST', json: { delta: delta } }); if (valueEl) valueEl.textContent = res.quantity; } catch (err) { alert('Could not update quantity: ' + 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 importModalEl = document.getElementById('importModal'); var importModal = (importModalEl && window.bootstrap) ? new bootstrap.Modal(importModalEl) : null; function openImportModal(folderId) { if (!importModal) return; document.getElementById('importFolderId').value = folderId || ''; document.getElementById('importResult').innerHTML = ''; var fileInput = document.getElementById('importFile'); if (fileInput) fileInput.value = ''; importModal.show(); } var importFormEl = document.getElementById('importForm'); if (importFormEl) { importFormEl.addEventListener('submit', async function (e) { e.preventDefault(); var resultBox = document.getElementById('importResult'); resultBox.innerHTML = '
Importing…
'; var fd = new FormData(importFormEl); try { var res = await fetch('/import/assets.csv', { method: 'POST', body: fd }); var body = await res.json(); if (!res.ok || !body.ok) throw new Error(body.error || 'Import failed'); var data = body.data; var html = '
Created ' + data.Created + ', updated ' + data.Updated + '.
'; if (data.Errors && data.Errors.length) { html += '
' + data.Errors.length + ' note(s):
'; } resultBox.innerHTML = html; await refreshWholeTree(); await reloadCurrentPanel(); } catch (err) { resultBox.innerHTML = '
' + escapeHtml(err.message) + '
'; } }); } // --------------------------------------------------------------------- // Tree pane: click + drag/drop delegation // --------------------------------------------------------------------- if (treePane) { treePane.addEventListener('click', function (e) { if (e.target.closest('#btn-expand-all')) { expandAllHandler(); return; } if (e.target.closest('#btn-collapse-all')) { collapseAllHandler(); return; } var toggle = e.target.closest('.tree-toggle'); if (toggle) { e.stopPropagation(); toggleFolder(toggle); return; } var row = e.target.closest('.tree-row'); if (row && row.dataset.type && !row.classList.contains('tree-row-more')) { selectNode(row); } }); // Drag and drop (admin only) treePane.addEventListener('dragstart', function (e) { if (!isAdmin) { e.preventDefault(); return; } var li = e.target.closest('.tree-node[draggable="true"]'); if (!li) return; draggedNode = { type: li.dataset.type, id: li.dataset.id }; li.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', draggedNode.type + ':' + draggedNode.id); }); treePane.addEventListener('dragend', function () { document.querySelectorAll('.tree-node.dragging').forEach(function (n) { n.classList.remove('dragging'); }); document.querySelectorAll('.tree-row.drop-target').forEach(function (n) { n.classList.remove('drop-target'); }); draggedNode = null; }); treePane.addEventListener('dragover', function (e) { if (!draggedNode) return; var row = e.target.closest('.tree-row'); if (!row) return; var type = row.dataset.type; if (type !== 'folder' && type !== 'root') return; if (type === 'folder' && draggedNode.type === 'folder' && draggedNode.id === row.dataset.id) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; row.classList.add('drop-target'); }); treePane.addEventListener('dragleave', function (e) { var row = e.target.closest('.tree-row'); if (row) row.classList.remove('drop-target'); }); treePane.addEventListener('drop', async function (e) { if (!draggedNode) return; var row = e.target.closest('.tree-row'); if (!row) return; var type = row.dataset.type; if (type !== 'folder' && type !== 'root') return; e.preventDefault(); row.classList.remove('drop-target'); var destId = type === 'root' ? null : Number(row.dataset.id); var dragged = draggedNode; draggedNode = null; try { if (dragged.type === 'folder') { await apiFetch('/app/folders/' + dragged.id + '/move', { method: 'POST', json: { parent_id: destId } }); } else if (dragged.type === 'asset') { await apiFetch('/app/assets/' + dragged.id + '/move', { method: 'POST', json: { folder_id: destId } }); } await refreshWholeTree(); await reloadCurrentPanel(); } catch (err) { alert('Move failed: ' + err.message); } }); } // --------------------------------------------------------------------- // Main panel: click / change / submit delegation // --------------------------------------------------------------------- if (mainPanel) { mainPanel.addEventListener('click', function (e) { var stepBtn = e.target.closest('[data-action="panel-quantity-step"], [data-action="table-quantity-step"]'); if (stepBtn) { e.preventDefault(); e.stopPropagation(); adjustAssetQuantity(stepBtn); return; } var folderLink = e.target.closest('.folder-link'); if (folderLink) { e.preventDefault(); navigateAndSelect('folder', folderLink.dataset.id); return; } var assetLink = e.target.closest('.asset-link, .asset-row'); if (assetLink) { e.preventDefault(); navigateAndSelect('asset', assetLink.dataset.id); return; } var crumb = e.target.closest('.breadcrumb-link'); if (crumb) { e.preventDefault(); navigateAndSelect(crumb.dataset.type, crumb.dataset.id || ''); return; } var pageLink = e.target.closest('.panel-page-link'); if (pageLink) { e.preventDefault(); loadFolderPanelWithFilters(pageLink.dataset.folderId, { page: pageLink.dataset.page }); return; } }); mainPanel.addEventListener('change', function (e) { var filterForm = e.target.closest('.panel-filter-form'); if (filterForm) { loadFolderPanelWithFilters(filterForm.dataset.folderId, { page: 1 }); } }); var filterDebounce; mainPanel.addEventListener('input', function (e) { if (e.target.matches && e.target.matches('.panel-filter-form input[name="q"]')) { clearTimeout(filterDebounce); var form = e.target.closest('.panel-filter-form'); filterDebounce = setTimeout(function () { loadFolderPanelWithFilters(form.dataset.folderId, { page: 1 }); }, 350); } }); mainPanel.addEventListener('submit', function (e) { if (e.target.matches && e.target.matches('.panel-filter-form')) { e.preventDefault(); loadFolderPanelWithFilters(e.target.dataset.folderId, { page: 1 }); } }); // Draggable rows within the panel (subfolder list / asset tables) drop // onto the tree only; the tree pane is the primary drag surface. } // The asset create/edit form lives inside #assetModal (injected fresh // each time it's opened), so its dynamic bits are delegated from the // modal element itself rather than from #main-panel. if (assetModalEl) { assetModalEl.addEventListener('change', async function (e) { if (e.target.id === 'assetFormItemType') { var html = await fetchText('/app/forms/asset-fields?item_type_id=' + e.target.value); var container = document.getElementById('asset-fields-container'); if (container) container.innerHTML = html; } }); assetModalEl.addEventListener('submit', function (e) { if (e.target.id === 'assetForm') { e.preventDefault(); submitAssetForm(e.target); } }); assetModalEl.addEventListener('click', function (e) { var stepBtn = e.target.closest('[data-action="quantity-step"]'); if (!stepBtn) return; var input = document.getElementById('assetFormQuantity'); if (!input) return; var next = (parseInt(input.value, 10) || 0) + parseInt(stepBtn.dataset.step, 10); input.value = Math.max(0, next); }); } // --------------------------------------------------------------------- // Item type custom field admin form (item-types/{id}/edit page) // --------------------------------------------------------------------- var fieldForm = document.getElementById('fieldForm'); if (fieldForm) { document.addEventListener('click', function (e) { var editBtn = e.target.closest('.field-edit-btn'); if (editBtn) { document.getElementById('fieldFormTitle').textContent = 'Edit Field'; document.getElementById('fieldFormName').value = editBtn.dataset.name; document.getElementById('fieldFormType').value = editBtn.dataset.type; document.getElementById('fieldFormDefault').value = editBtn.dataset.default; document.getElementById('fieldFormOptions').value = editBtn.dataset.options || ''; document.getElementById('fieldFormRequired').checked = editBtn.dataset.required === 'true'; document.getElementById('fieldFormSortOrder').value = editBtn.dataset.sortOrder; document.getElementById('fieldFormSubmit').textContent = 'Save'; fieldForm.action = window.itemTypeFieldsBaseAction + '/' + editBtn.dataset.id; var cancelBtn = document.getElementById('fieldFormCancel'); cancelBtn.classList.remove('d-none'); fieldForm.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } var cancelBtn2 = e.target.closest('#fieldFormCancel'); if (cancelBtn2) { document.getElementById('fieldFormTitle').textContent = 'Add Field'; fieldForm.reset(); document.getElementById('fieldFormSubmit').textContent = 'Add'; fieldForm.action = window.itemTypeFieldsBaseAction; cancelBtn2.classList.add('d-none'); } }); } // --------------------------------------------------------------------- // QR/barcode scan landing: /scan/ redirects here with ?asset= // (or ?scan_error= if the tag didn't match anything). // --------------------------------------------------------------------- var inventoryShell = document.querySelector('.inventory-shell'); if (inventoryShell) { var autoOpenId = inventoryShell.dataset.autoOpenAsset; if (autoOpenId) { navigateAndSelect('asset', autoOpenId); } var scanError = inventoryShell.dataset.scanError; if (scanError) { alert('No asset found for scanned tag "' + scanError + '".'); } if (autoOpenId || scanError) { // Drop the query string so a page refresh doesn't re-trigger this. window.history.replaceState({}, '', '/inventory'); } } })();