- go.mod module path (and every internal/vibetree import) inventree -> vibetree - cmd/inventree/ -> cmd/vibetree/, deploy/inventree.service -> vibetree.service - Default site name (seed data + the siteName template func's fallback) is now "vibetree", shown in the navbar, <title>, and login page - Session cookie renamed inventree_session -> vibetree_session - Default DB path data/inventree.db -> data/vibetree.db, CLI/log text, Dockerfile, docker-compose.yml, README, .gitignore/.dockerignore all updated to match Existing installs: this changes the session cookie name (users will be signed out once) and the default DB filename (point -db at your existing file, or rename it, to keep your data).
1280 lines
53 KiB
JavaScript
1280 lines
53 KiB
JavaScript
// vibetree - 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 '<div class="dropdown-divider"></div>';
|
|
var cls = 'dropdown-item' + (item.danger ? ' text-danger' : '');
|
|
return '<button type="button" class="' + cls + '"><i class="bi ' + (item.icon || '') + '"></i> ' + escapeHtml(item.label) + '</button>';
|
|
}).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_ids=' + id, '_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_ids=' + 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;
|
|
}
|
|
|
|
// ---- Multi-selection: Ctrl/Cmd+click toggles, Shift+click selects a
|
|
// range, right-clicking a member of an active (2+) selection shows bulk
|
|
// actions instead of that one row's normal menu. -----------------------
|
|
|
|
var multiSelected = new Map(); // "type:id" -> {type, id, row}
|
|
var selectionAnchorRow = null;
|
|
|
|
function selKey(type, id) { return type + ':' + id; }
|
|
|
|
function clearMultiSelection() {
|
|
multiSelected.forEach(function (item) { item.row.classList.remove('multi-selected'); });
|
|
multiSelected.clear();
|
|
}
|
|
|
|
function addToMultiSelection(row) {
|
|
var key = selKey(row.dataset.type, row.dataset.id);
|
|
if (!multiSelected.has(key)) {
|
|
multiSelected.set(key, { type: row.dataset.type, id: row.dataset.id, row: row });
|
|
row.classList.add('multi-selected');
|
|
}
|
|
}
|
|
|
|
function toggleMultiSelection(row) {
|
|
var key = selKey(row.dataset.type, row.dataset.id);
|
|
if (multiSelected.has(key)) {
|
|
multiSelected.delete(key);
|
|
row.classList.remove('multi-selected');
|
|
} else {
|
|
addToMultiSelection(row);
|
|
}
|
|
}
|
|
|
|
function visibleSelectableRows() {
|
|
return Array.prototype.slice.call(
|
|
document.querySelectorAll('#tree-root .tree-row[data-type="folder"], #tree-root .tree-row[data-type="asset"]')
|
|
);
|
|
}
|
|
|
|
function selectRange(fromRow, toRow) {
|
|
var rows = visibleSelectableRows();
|
|
var i1 = rows.indexOf(fromRow), i2 = rows.indexOf(toRow);
|
|
if (i1 === -1 || i2 === -1) { addToMultiSelection(toRow); return; }
|
|
var start = Math.min(i1, i2), end = Math.max(i1, i2);
|
|
for (var i = start; i <= end; i++) addToMultiSelection(rows[i]);
|
|
}
|
|
|
|
function multiSelectionMenuItems() {
|
|
var assetIds = [], folderIds = [];
|
|
multiSelected.forEach(function (item) {
|
|
if (item.type === 'asset') assetIds.push(item.id);
|
|
else if (item.type === 'folder') folderIds.push(item.id);
|
|
});
|
|
var count = multiSelected.size;
|
|
var idParams = function () {
|
|
var params = [];
|
|
if (assetIds.length) params.push('asset_ids=' + assetIds.join(','));
|
|
if (folderIds.length) params.push('folder_ids=' + folderIds.join(','));
|
|
return params.join('&');
|
|
};
|
|
|
|
var items = [];
|
|
items.push({ label: 'Print Labels (' + count + ')', icon: 'bi-qr-code', action: function () { window.open('/labels?' + idParams(), '_blank'); } });
|
|
items.push({ label: 'Export CSV (' + count + ')', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?' + idParams(); } });
|
|
if (isAdmin && assetIds.length) {
|
|
items.push({ divider: true });
|
|
items.push({ label: 'Edit Status… (' + assetIds.length + ')', icon: 'bi-pencil-square', action: function () { openBulkStatusModal(assetIds); } });
|
|
items.push({ label: 'Check Out… (' + assetIds.length + ')', icon: 'bi-person-check', action: function () { openCheckoutModal(assetIds); } });
|
|
items.push({ label: 'Duplicate (' + assetIds.length + ')', icon: 'bi-files', action: function () { duplicateAssets(assetIds); } });
|
|
items.push({ label: 'Move to… (' + assetIds.length + ')', icon: 'bi-arrows-move', action: function () { openMoveAssetModal(assetIds); } });
|
|
items.push({ divider: true });
|
|
items.push({ label: 'Delete (' + assetIds.length + ')', icon: 'bi-trash', danger: true, action: function () { deleteAssets(assetIds); } });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') clearMultiSelection();
|
|
});
|
|
|
|
// ---- 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();
|
|
|
|
var key = selKey(type, row.dataset.id);
|
|
if (multiSelected.size > 1 && multiSelected.has(key)) {
|
|
showContextMenu(e.clientX, e.clientY, multiSelectionMenuItems());
|
|
return;
|
|
}
|
|
clearMultiSelection();
|
|
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
|
|
row.classList.add('selected');
|
|
selectionAnchorRow = row;
|
|
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 '<button type="button" class="icon-picker-item" data-icon="' + ic + '" title="' + ic + '"><i class="bi ' + ic + '"></i></button>';
|
|
}).join('') || '<div class="text-body-secondary small p-2">No matches</div>';
|
|
}
|
|
|
|
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 '<option value="' + d.id + '">' + escapeHtml(d.path) + '</option>';
|
|
}).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;
|
|
|
|
// openMoveAssetModal accepts either a single id or an array of ids, so
|
|
// the same modal serves both the single-asset context menu and the
|
|
// multi-selection bulk menu.
|
|
async function openMoveAssetModal(idOrIds) {
|
|
if (!moveAssetModal) return;
|
|
var ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
|
moveAssetModalEl.dataset.assetIds = ids.join(',');
|
|
document.getElementById('moveAssetModalTitle').textContent = ids.length > 1 ? 'Move ' + ids.length + ' Assets' : 'Move Asset';
|
|
var sel = document.getElementById('moveAssetDestination');
|
|
sel.innerHTML = '<option value="">Inventory (top level)</option>';
|
|
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 ids = moveAssetModalEl.dataset.assetIds.split(',');
|
|
var val = document.getElementById('moveAssetDestination').value;
|
|
var folderId = val ? Number(val) : null;
|
|
try {
|
|
await Promise.all(ids.map(function (id) {
|
|
return apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: folderId } });
|
|
}));
|
|
moveAssetModal.hide();
|
|
await refreshWholeTree();
|
|
if (ids.length === 1) await navigateAndSelect('asset', ids[0]);
|
|
else await reloadCurrentPanel();
|
|
clearMultiSelection();
|
|
} 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;
|
|
|
|
// openCheckoutModal accepts either a single id or an array of ids. For a
|
|
// single asset it shows who currently has it; for a batch it skips that
|
|
// (no single "current" to show) and just checks all of them out/in
|
|
// together.
|
|
async function openCheckoutModal(idOrIds) {
|
|
if (!checkoutModal) return;
|
|
var ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
|
checkoutModalEl.dataset.assetIds = ids.join(',');
|
|
var sel = document.getElementById('checkoutUserSelect');
|
|
var currentText = document.getElementById('checkoutCurrent');
|
|
sel.innerHTML = '<option value="">Unassigned (check in)</option>';
|
|
|
|
if (ids.length > 1) {
|
|
currentText.textContent = 'Checking out ' + ids.length + ' assets.';
|
|
document.getElementById('checkoutModalTitle').textContent = 'Check Out ' + ids.length + ' Assets';
|
|
try {
|
|
var users = await apiFetch('/app/users/options');
|
|
(users || []).forEach(function (u) {
|
|
var opt = document.createElement('option');
|
|
opt.value = u.id;
|
|
opt.textContent = u.username;
|
|
sel.appendChild(opt);
|
|
});
|
|
} catch (err) { /* leave just "Unassigned" */ }
|
|
checkoutModal.show();
|
|
return;
|
|
}
|
|
|
|
currentText.textContent = 'Loading…';
|
|
try {
|
|
var asset = await apiFetch('/app/assets/' + ids[0]);
|
|
var usersList = await apiFetch('/app/users/options');
|
|
(usersList || []).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 ids = checkoutModalEl.dataset.assetIds.split(',');
|
|
var val = document.getElementById('checkoutUserSelect').value;
|
|
var userId = val ? Number(val) : null;
|
|
try {
|
|
await Promise.all(ids.map(function (id) {
|
|
return apiFetch('/app/assets/' + id + '/checkout', { method: 'POST', json: { assigned_user_id: userId } });
|
|
}));
|
|
checkoutModal.hide();
|
|
if (ids.length === 1) await navigateAndSelect('asset', ids[0]);
|
|
else await reloadCurrentPanel();
|
|
clearMultiSelection();
|
|
} catch (err) {
|
|
alert('Checkout failed: ' + err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Bulk status change modal (multi-selection > Edit Status...)
|
|
// ---------------------------------------------------------------------
|
|
|
|
var bulkStatusModalEl = document.getElementById('bulkStatusModal');
|
|
var bulkStatusModal = (bulkStatusModalEl && window.bootstrap) ? new bootstrap.Modal(bulkStatusModalEl) : null;
|
|
|
|
function openBulkStatusModal(ids) {
|
|
if (!bulkStatusModal) return;
|
|
bulkStatusModalEl.dataset.assetIds = ids.join(',');
|
|
document.getElementById('bulkStatusModalTitle').textContent = 'Edit Status (' + ids.length + ')';
|
|
bulkStatusModal.show();
|
|
}
|
|
|
|
var confirmBulkStatusBtn = document.getElementById('confirmBulkStatusBtn');
|
|
if (confirmBulkStatusBtn) {
|
|
confirmBulkStatusBtn.addEventListener('click', async function () {
|
|
var ids = bulkStatusModalEl.dataset.assetIds.split(',');
|
|
var statusId = Number(document.getElementById('bulkStatusSelect').value);
|
|
try {
|
|
await Promise.all(ids.map(function (id) {
|
|
return apiFetch('/app/assets/' + id + '/status', { method: 'POST', json: { status_id: statusId } });
|
|
}));
|
|
bulkStatusModal.hide();
|
|
await reloadCurrentPanel();
|
|
clearMultiSelection();
|
|
} catch (err) {
|
|
alert('Status update failed: ' + err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function duplicateAssets(ids) {
|
|
try {
|
|
await Promise.all(ids.map(function (id) {
|
|
return apiFetch('/app/assets/' + id + '/duplicate', { method: 'POST' });
|
|
}));
|
|
await refreshWholeTree();
|
|
await reloadCurrentPanel();
|
|
clearMultiSelection();
|
|
} catch (err) {
|
|
alert('Duplicate failed: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function deleteAssets(ids) {
|
|
if (!confirm('Delete ' + ids.length + ' assets? This cannot be undone.')) return;
|
|
try {
|
|
await Promise.all(ids.map(function (id) {
|
|
return apiFetch('/app/assets/' + id, { method: 'DELETE' });
|
|
}));
|
|
await refreshWholeTree();
|
|
clearMultiSelection();
|
|
await navigateAndSelect('root', '');
|
|
} catch (err) {
|
|
alert('Delete 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 = '<div class="text-body-secondary">Importing…</div>';
|
|
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 = '<div class="alert alert-success py-2">Created ' + data.Created + ', updated ' + data.Updated + '.</div>';
|
|
if (data.Errors && data.Errors.length) {
|
|
html += '<div class="alert alert-warning py-2"><strong>' + data.Errors.length + ' note(s):</strong><ul class="mb-0">' +
|
|
data.Errors.map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('') + '</ul></div>';
|
|
}
|
|
resultBox.innerHTML = html;
|
|
await refreshWholeTree();
|
|
await reloadCurrentPanel();
|
|
} catch (err) {
|
|
resultBox.innerHTML = '<div class="alert alert-danger py-2">' + escapeHtml(err.message) + '</div>';
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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')) {
|
|
if (row.dataset.type === 'root') {
|
|
clearMultiSelection();
|
|
selectionAnchorRow = null;
|
|
selectNode(row);
|
|
return;
|
|
}
|
|
if (e.shiftKey && selectionAnchorRow) {
|
|
e.preventDefault();
|
|
selectRange(selectionAnchorRow, row);
|
|
return;
|
|
}
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
toggleMultiSelection(row);
|
|
selectionAnchorRow = row;
|
|
return;
|
|
}
|
|
clearMultiSelection();
|
|
selectionAnchorRow = row;
|
|
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/<tag> redirects here with ?asset=<id>
|
|
// (or ?scan_error=<tag> 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');
|
|
}
|
|
}
|
|
})();
|