// 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 if (key === 'quantity') {
payload.quantity = Number(value) || 0;
} 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 = '