Bulk multi-select actions, barcode format, batch printing, site name on login

Multi-select (Ctrl/Cmd+click toggle, Shift+click range, right-click the
active selection for bulk actions - same conventions as a native file
manager):
- Print Labels (N) / Export CSV (N) for any mix of individually-picked
  assets and whole folders. Labels/export now take asset_ids/folder_ids
  instead of a single asset_id/folder_id.
- Edit Status..., Check Out..., Duplicate, Move to..., and Delete, bulk
  versions of the existing single-asset actions. Checkout/Move modals
  were generalized to accept an array of ids instead of just one; status
  editing got its own lightweight POST /app/assets/{id}/status endpoint
  (assets.SetStatus) so a bulk status change doesn't need to resend every
  other field the way a full PATCH would.

Labels:
- Choose QR (a /scan/<tag> URL, for a phone camera) or Code128 barcode
  (the bare Asset ID, for a USB scanner typing into the search box) via a
  format selector on the print page. New barcodeDataURI using
  github.com/boombuler/barcode alongside the existing QR generator.
- "Start at #" lets printing resume partway into an already-used sheet
  instead of wasting it on a fresh one.
- Added the Avery 5260 label template (same grid as 5160, rounded
  corners, so it isn't flagged approximate).

Also: the configured Site Name now shows on the login page too, not just
the authenticated navbar.
This commit is contained in:
Owen
2026-08-17 17:12:17 -05:00
parent 4e4313723e
commit 14e2f3bb77
15 changed files with 573 additions and 113 deletions
+9
View File
@@ -77,6 +77,15 @@ ul.tree-children {
font-weight: 500;
}
/* Ctrl/Shift-click multi-selection (right-click one of these for bulk
actions). Distinct from .selected, which marks the single item shown in
the main panel - a row can carry both at once. */
.tree-row.multi-selected {
background-color: #fff3cd;
outline: 1px solid #ffe69c;
outline-offset: -1px;
}
.tree-row.drop-target {
outline: 2px dashed #0d6efd;
outline-offset: -2px;
+217 -15
View File
@@ -243,7 +243,7 @@
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'); } });
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); } });
@@ -253,7 +253,7 @@
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'); } });
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); } });
@@ -298,6 +298,85 @@
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) {
@@ -312,8 +391,16 @@
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));
});
}
@@ -583,9 +670,14 @@
var moveAssetModalEl = document.getElementById('moveAssetModal');
var moveAssetModal = (moveAssetModalEl && window.bootstrap) ? new bootstrap.Modal(moveAssetModalEl) : null;
async function openMoveAssetModal(id) {
// 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;
moveAssetModalEl.dataset.assetId = id;
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 {
@@ -605,13 +697,18 @@
var confirmMoveAssetBtn = document.getElementById('confirmMoveAssetBtn');
if (confirmMoveAssetBtn) {
confirmMoveAssetBtn.addEventListener('click', async function () {
var id = moveAssetModalEl.dataset.assetId;
var ids = moveAssetModalEl.dataset.assetIds.split(',');
var val = document.getElementById('moveAssetDestination').value;
var folderId = val ? Number(val) : null;
try {
await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } });
await Promise.all(ids.map(function (id) {
return apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: folderId } });
}));
moveAssetModal.hide();
await refreshWholeTree();
await navigateAndSelect('asset', id);
if (ids.length === 1) await navigateAndSelect('asset', ids[0]);
else await reloadCurrentPanel();
clearMultiSelection();
} catch (err) {
alert('Move failed: ' + err.message);
}
@@ -625,17 +722,39 @@
var checkoutModalEl = document.getElementById('checkoutModal');
var checkoutModal = (checkoutModalEl && window.bootstrap) ? new bootstrap.Modal(checkoutModalEl) : null;
async function openCheckoutModal(id) {
// 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;
checkoutModalEl.dataset.assetId = id;
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/' + id);
var users = await apiFetch('/app/users/options');
(users || []).forEach(function (u) {
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;
@@ -655,18 +774,82 @@
var confirmCheckoutBtn = document.getElementById('confirmCheckoutBtn');
if (confirmCheckoutBtn) {
confirmCheckoutBtn.addEventListener('click', async function () {
var id = checkoutModalEl.dataset.assetId;
var ids = checkoutModalEl.dataset.assetIds.split(',');
var val = document.getElementById('checkoutUserSelect').value;
var userId = val ? Number(val) : null;
try {
await apiFetch('/app/assets/' + id + '/checkout', { method: 'POST', json: { assigned_user_id: val ? Number(val) : null } });
await Promise.all(ids.map(function (id) {
return apiFetch('/app/assets/' + id + '/checkout', { method: 'POST', json: { assigned_user_id: userId } });
}));
checkoutModal.hide();
await navigateAndSelect('asset', id);
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
// ---------------------------------------------------------------------
@@ -881,6 +1064,25 @@
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);
}
});