Initial implementation of inventree
A small IT asset inventory system built around a nested folder tree, matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS, SQLite storage (WAL, FKs, migrations), embedded templates/static assets, single compiled binary. - AJAX-driven inventory tree: lazy-loaded folder expansion with per-user persisted expand state, drag-and-drop move for folders/assets, safe delete-with-relocate-or-cascade confirmation, search with breadcrumbs. - Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs, CSV import (with optional folder auto-create) and export. - Admin-defined item types with custom fields (text/long_text/number/date/ boolean/dropdown/url/ip_address). - Configurable statuses, session-based auth (bcrypt, secure cookies), admin/user roles, user management. - CLI: serve, version, database migrate, database backup; /healthz; graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
// Package content embeds inventree's HTML templates and static assets
|
||||
// (Bootstrap CSS/JS, icons, vanilla JS) into the compiled binary.
|
||||
package content
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates
|
||||
var TemplatesFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var StaticFS embed.FS
|
||||
@@ -0,0 +1,184 @@
|
||||
:root {
|
||||
--tree-indent: 1.15rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
|
||||
.app-navbar {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.login-body {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #eef1f6 0%, #f7f8fa 100%);
|
||||
}
|
||||
|
||||
/* Admin-only UI: shown only when <body> carries the role-admin class. */
|
||||
.admin-only { display: none !important; }
|
||||
body.role-admin .admin-only { display: inherit !important; }
|
||||
body.role-admin button.admin-only,
|
||||
body.role-admin a.admin-only { display: inline-block !important; }
|
||||
|
||||
.inventory-shell {
|
||||
height: calc(100vh - 56px);
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.tree-pane {
|
||||
width: 340px;
|
||||
min-width: 260px;
|
||||
max-width: 45vw;
|
||||
background-color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-scroll {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
ul.tree, ul.tree-children {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
ul.tree-children {
|
||||
padding-left: var(--tree-indent);
|
||||
}
|
||||
|
||||
.tree-node-root > .tree-row {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-row:hover {
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.tree-row.selected {
|
||||
background-color: #e7f1ff;
|
||||
color: #0d6efd;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-row.drop-target {
|
||||
outline: 2px dashed #0d6efd;
|
||||
outline-offset: -2px;
|
||||
background-color: #e7f1ff;
|
||||
}
|
||||
|
||||
.tree-row-more {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.tree-toggle i {
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.tree-toggle.expanded i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree-toggle-spacer {
|
||||
width: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.tree-status-badge {
|
||||
font-size: 0.65rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-tree-action {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6c757d;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.btn-tree-action:hover {
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.tree-node.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.stat-card .card-body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.asset-row, .folder-link, .asset-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.inventory-shell {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
.tree-pane {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 40vh;
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+5
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,722 @@
|
||||
// inventree - vanilla JS driving the inventory tree and AJAX panels.
|
||||
// No build step, no frameworks: plain DOM APIs + fetch.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var treePane = document.getElementById('tree-pane');
|
||||
var mainPanel = document.getElementById('main-panel');
|
||||
var isAdmin = document.body.classList.contains('role-admin');
|
||||
var draggedNode = null;
|
||||
var formReturnContext = { type: 'root', id: '' };
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
var res = await fetch(url);
|
||||
if (!res.ok) throw new Error('Request failed (' + res.status + ')');
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function apiFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
var fetchOpts = { method: opts.method || 'GET', headers: {} };
|
||||
if (opts.json !== undefined) {
|
||||
fetchOpts.headers['Content-Type'] = 'application/json';
|
||||
fetchOpts.body = JSON.stringify(opts.json);
|
||||
}
|
||||
var res = await fetch(url, fetchOpts);
|
||||
var body = null;
|
||||
var ct = res.headers.get('content-type') || '';
|
||||
if (ct.indexOf('application/json') !== -1) {
|
||||
body = await res.json();
|
||||
}
|
||||
if (!res.ok) {
|
||||
var msg = (body && body.error) ? body.error : ('Request failed (' + res.status + ')');
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body ? body.data : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Main panel navigation
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function loadPanel(type, id) {
|
||||
var url;
|
||||
if (type === 'root') url = '/app/panel/root';
|
||||
else if (type === 'folder') url = '/app/panel/folders/' + id;
|
||||
else if (type === 'asset') url = '/app/panel/assets/' + id;
|
||||
else return;
|
||||
var html = await fetchText(url);
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = type;
|
||||
mainPanel.dataset.contextId = id || '';
|
||||
}
|
||||
|
||||
async function reloadCurrentPanel() {
|
||||
var type = mainPanel.dataset.contextType || 'root';
|
||||
var id = mainPanel.dataset.contextId || '';
|
||||
if (type !== 'root' && type !== 'folder' && type !== 'asset') return;
|
||||
await loadPanel(type, id);
|
||||
}
|
||||
|
||||
function selectTreeRowVisually(type, id) {
|
||||
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
|
||||
var row;
|
||||
if (type === 'root') {
|
||||
row = document.querySelector('.tree-row[data-type="root"]');
|
||||
} else {
|
||||
var node = document.querySelector('.tree-node[data-type="' + type + '"][data-id="' + id + '"]');
|
||||
row = node ? node.querySelector(':scope > .tree-row') : null;
|
||||
}
|
||||
if (row) row.classList.add('selected');
|
||||
}
|
||||
|
||||
async function navigateAndSelect(type, id) {
|
||||
await loadPanel(type, id);
|
||||
selectTreeRowVisually(type, id);
|
||||
}
|
||||
|
||||
async function selectNode(row) {
|
||||
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
|
||||
row.classList.add('selected');
|
||||
var type = row.dataset.type;
|
||||
var id = row.dataset.id || '';
|
||||
await loadPanel(type, id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tree: refresh / expand / collapse
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function refreshWholeTree() {
|
||||
var ul = document.getElementById('top-level-children');
|
||||
if (!ul) return;
|
||||
var html = await fetchText('/app/tree/children');
|
||||
ul.innerHTML = html;
|
||||
}
|
||||
|
||||
async function toggleFolder(toggleBtn) {
|
||||
var li = toggleBtn.closest('.tree-node');
|
||||
var childUl = li.querySelector(':scope > ul.tree-children');
|
||||
var id = toggleBtn.dataset.id;
|
||||
var expanded = toggleBtn.classList.contains('expanded');
|
||||
if (expanded) {
|
||||
toggleBtn.classList.remove('expanded');
|
||||
if (childUl) childUl.classList.add('d-none');
|
||||
fetch('/app/tree/collapse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'folder_id=' + encodeURIComponent(id),
|
||||
});
|
||||
} else {
|
||||
toggleBtn.classList.add('expanded');
|
||||
if (childUl) {
|
||||
childUl.classList.remove('d-none');
|
||||
var html = await fetchText('/app/tree/children?parent_id=' + encodeURIComponent(id) + '&persist=1');
|
||||
childUl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expandAllUnder(ul) {
|
||||
var folderLis = Array.prototype.filter.call(ul.children, function (li) {
|
||||
return li.dataset && li.dataset.type === 'folder';
|
||||
});
|
||||
for (var i = 0; i < folderLis.length; i++) {
|
||||
var li = folderLis[i];
|
||||
var toggle = li.querySelector(':scope > .tree-row > .tree-toggle');
|
||||
if (toggle && !toggle.classList.contains('expanded')) {
|
||||
await toggleFolder(toggle);
|
||||
}
|
||||
var childUl = li.querySelector(':scope > ul.tree-children');
|
||||
if (childUl) await expandAllUnder(childUl);
|
||||
}
|
||||
}
|
||||
|
||||
async function expandAllHandler() {
|
||||
var top = document.getElementById('top-level-children');
|
||||
if (top) await expandAllUnder(top);
|
||||
}
|
||||
|
||||
async function collapseAllHandler() {
|
||||
document.querySelectorAll('.tree-toggle.expanded').forEach(function (t) {
|
||||
t.classList.remove('expanded');
|
||||
var li = t.closest('.tree-node');
|
||||
var childUl = li ? li.querySelector(':scope > ul.tree-children') : null;
|
||||
if (childUl) childUl.classList.add('d-none');
|
||||
});
|
||||
try { await fetch('/app/tree/collapse-all', { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tree context menu ("...")
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var ctxMenu = null;
|
||||
|
||||
function ensureCtxMenu() {
|
||||
if (ctxMenu) return ctxMenu;
|
||||
ctxMenu = document.createElement('div');
|
||||
ctxMenu.className = 'dropdown-menu tree-ctx-menu';
|
||||
ctxMenu.style.position = 'fixed';
|
||||
ctxMenu.style.display = 'none';
|
||||
ctxMenu.innerHTML =
|
||||
'<button type="button" class="dropdown-item" data-menu-action="new-subfolder"><i class="bi bi-folder-plus"></i> New Subfolder</button>' +
|
||||
'<button type="button" class="dropdown-item" data-menu-action="new-asset"><i class="bi bi-plus-lg"></i> Add Asset</button>' +
|
||||
'<button type="button" class="dropdown-item" data-menu-action="rename"><i class="bi bi-pencil"></i> Rename</button>' +
|
||||
'<button type="button" class="dropdown-item text-danger" data-menu-action="delete"><i class="bi bi-trash"></i> Delete</button>';
|
||||
document.body.appendChild(ctxMenu);
|
||||
return ctxMenu;
|
||||
}
|
||||
|
||||
function closeCtxMenu() {
|
||||
if (ctxMenu) ctxMenu.style.display = 'none';
|
||||
}
|
||||
|
||||
function openTreeContextMenu(btn) {
|
||||
var menu = ensureCtxMenu();
|
||||
var rect = btn.getBoundingClientRect();
|
||||
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
|
||||
menu.style.top = rect.bottom + 'px';
|
||||
menu.style.display = 'block';
|
||||
menu.dataset.folderId = btn.dataset.id;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target) && !e.target.closest('.btn-tree-action')) {
|
||||
closeCtxMenu();
|
||||
}
|
||||
var menuAction = e.target.closest('[data-menu-action]');
|
||||
if (menuAction && ctxMenu) {
|
||||
var id = ctxMenu.dataset.folderId;
|
||||
var action = menuAction.dataset.menuAction;
|
||||
closeCtxMenu();
|
||||
if (action === 'new-subfolder') openFolderModal({ parentId: id });
|
||||
else if (action === 'new-asset') loadAssetForm({ folderId: id });
|
||||
else if (action === 'rename') openRenameFolder(id);
|
||||
else if (action === 'delete') startDeleteFolder(id);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Folder create / rename modal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var folderModalEl = document.getElementById('folderModal');
|
||||
var folderModal = (folderModalEl && window.bootstrap) ? new bootstrap.Modal(folderModalEl) : null;
|
||||
|
||||
function openFolderModal(opts) {
|
||||
if (!folderModal) return;
|
||||
document.getElementById('folderFormId').value = '';
|
||||
document.getElementById('folderFormParentId').value = (opts && opts.parentId) || '';
|
||||
document.getElementById('folderFormName').value = '';
|
||||
document.getElementById('folderFormDescription').value = '';
|
||||
document.getElementById('folderFormIcon').value = '';
|
||||
document.getElementById('folderFormSortOrder').value = '0';
|
||||
document.getElementById('folderModalTitle').textContent = 'New Folder';
|
||||
document.getElementById('folderFormError').classList.add('d-none');
|
||||
folderModal.show();
|
||||
}
|
||||
|
||||
async function openRenameFolder(id) {
|
||||
if (!folderModal) return;
|
||||
try {
|
||||
var f = await apiFetch('/app/folders/' + id);
|
||||
document.getElementById('folderFormId').value = f.ID;
|
||||
document.getElementById('folderFormParentId').value = '';
|
||||
document.getElementById('folderFormName').value = f.Name;
|
||||
document.getElementById('folderFormDescription').value = f.Description;
|
||||
document.getElementById('folderFormIcon').value = f.Icon;
|
||||
document.getElementById('folderFormSortOrder').value = f.SortOrder;
|
||||
document.getElementById('folderModalTitle').textContent = 'Rename Folder';
|
||||
document.getElementById('folderFormError').classList.add('d-none');
|
||||
folderModal.show();
|
||||
} catch (err) {
|
||||
alert('Could not load folder: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
var folderFormEl = document.getElementById('folderForm');
|
||||
if (folderFormEl) {
|
||||
folderFormEl.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
var id = document.getElementById('folderFormId').value;
|
||||
var payload = {
|
||||
name: document.getElementById('folderFormName').value,
|
||||
description: document.getElementById('folderFormDescription').value,
|
||||
icon: document.getElementById('folderFormIcon').value,
|
||||
sort_order: Number(document.getElementById('folderFormSortOrder').value || 0),
|
||||
};
|
||||
var errorBox = document.getElementById('folderFormError');
|
||||
errorBox.classList.add('d-none');
|
||||
try {
|
||||
if (id) {
|
||||
await apiFetch('/app/folders/' + id, { method: 'PATCH', json: payload });
|
||||
} else {
|
||||
var parentId = document.getElementById('folderFormParentId').value;
|
||||
payload.parent_id = parentId ? Number(parentId) : null;
|
||||
await apiFetch('/app/folders', { method: 'POST', json: payload });
|
||||
}
|
||||
folderModal.hide();
|
||||
await refreshWholeTree();
|
||||
await reloadCurrentPanel();
|
||||
} catch (err) {
|
||||
errorBox.textContent = err.message;
|
||||
errorBox.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Folder delete modal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var deleteFolderModalEl = document.getElementById('deleteFolderModal');
|
||||
var deleteFolderModal = (deleteFolderModalEl && window.bootstrap) ? new bootstrap.Modal(deleteFolderModalEl) : null;
|
||||
|
||||
async function startDeleteFolder(id) {
|
||||
if (!deleteFolderModal) return;
|
||||
try {
|
||||
var info = await apiFetch('/app/folders/' + id + '/delete-info');
|
||||
deleteFolderModalEl.dataset.folderId = id;
|
||||
document.getElementById('deleteFolderName').textContent = info.folder_name;
|
||||
var empty = info.folder_count === 0 && info.asset_count === 0;
|
||||
document.getElementById('deleteFolderEmptyNotice').classList.toggle('d-none', !empty);
|
||||
document.getElementById('deleteFolderNotEmptyNotice').classList.toggle('d-none', empty);
|
||||
if (!empty) {
|
||||
document.getElementById('deleteFolderCounts').textContent =
|
||||
info.folder_count + ' folder(s) and ' + info.asset_count + ' asset(s)';
|
||||
var sel = document.getElementById('deleteFolderDestination');
|
||||
sel.innerHTML = info.destinations.map(function (d) {
|
||||
return '<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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Asset form (create / edit)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function loadAssetForm(opts) {
|
||||
formReturnContext = { type: mainPanel.dataset.contextType || 'root', id: mainPanel.dataset.contextId || '' };
|
||||
var params = new URLSearchParams();
|
||||
if (opts && opts.folderId) params.set('folder_id', opts.folderId);
|
||||
var qs = params.toString();
|
||||
var html = await fetchText('/app/panel/assets/new' + (qs ? '?' + qs : ''));
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = 'asset-form';
|
||||
mainPanel.dataset.contextId = '';
|
||||
}
|
||||
|
||||
async function loadAssetEditForm(id) {
|
||||
var html = await fetchText('/app/panel/assets/' + id + '/edit');
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = 'asset-form';
|
||||
mainPanel.dataset.contextId = id;
|
||||
}
|
||||
|
||||
function cancelAssetForm(btn) {
|
||||
if (btn.dataset.id) {
|
||||
navigateAndSelect('asset', btn.dataset.id);
|
||||
} else {
|
||||
navigateAndSelect(formReturnContext.type, formReturnContext.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAssetForm(form) {
|
||||
var errorBox = document.getElementById('assetFormError');
|
||||
errorBox.classList.add('d-none');
|
||||
var fd = new FormData(form);
|
||||
var payload = { fields: {} };
|
||||
for (var pair of fd.entries()) {
|
||||
var key = pair[0], value = pair[1];
|
||||
if (key.indexOf('field_') === 0) {
|
||||
payload.fields[key.slice(6)] = value;
|
||||
} else if (key === 'item_type_id') {
|
||||
payload.item_type_id = Number(value);
|
||||
} else if (key === 'folder_id') {
|
||||
payload.folder_id = value ? Number(value) : null;
|
||||
} else if (key === 'status_id') {
|
||||
payload.status_id = Number(value);
|
||||
} else if (key === 'assigned_user_id') {
|
||||
payload.assigned_user_id = value ? Number(value) : null;
|
||||
} else {
|
||||
payload[key] = value;
|
||||
}
|
||||
}
|
||||
var mode = form.dataset.mode;
|
||||
var url = mode === 'edit' ? '/app/assets/' + form.dataset.assetId : '/app/assets';
|
||||
var method = mode === 'edit' ? 'PATCH' : 'POST';
|
||||
try {
|
||||
var res = await apiFetch(url, { method: method, json: payload });
|
||||
await refreshWholeTree();
|
||||
await navigateAndSelect('asset', res.id);
|
||||
} catch (err) {
|
||||
errorBox.textContent = err.message;
|
||||
errorBox.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateAsset(id) {
|
||||
try {
|
||||
var res = await apiFetch('/app/assets/' + id + '/duplicate', { method: 'POST' });
|
||||
await refreshWholeTree();
|
||||
await navigateAndSelect('asset', res.id);
|
||||
} catch (err) {
|
||||
alert('Duplicate failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAsset(id, name) {
|
||||
if (!confirm('Delete asset "' + name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
var res = await apiFetch('/app/assets/' + id, { method: 'DELETE' });
|
||||
await refreshWholeTree();
|
||||
if (res.folder_id) await navigateAndSelect('folder', res.folder_id);
|
||||
else await navigateAndSelect('root', '');
|
||||
} catch (err) {
|
||||
alert('Delete failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMoveAsset(id, selectId) {
|
||||
var select = document.getElementById(selectId);
|
||||
var val = select.value;
|
||||
try {
|
||||
await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } });
|
||||
var modalEl = select.closest('.modal');
|
||||
if (modalEl && window.bootstrap) {
|
||||
var inst = bootstrap.Modal.getInstance(modalEl);
|
||||
if (inst) inst.hide();
|
||||
}
|
||||
await refreshWholeTree();
|
||||
await navigateAndSelect('asset', id);
|
||||
} catch (err) {
|
||||
alert('Move failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Folder listing filters / pagination
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function loadFolderPanelWithFilters(folderId, overrides) {
|
||||
var form = mainPanel.querySelector('.panel-filter-form');
|
||||
var params = new URLSearchParams();
|
||||
if (form) {
|
||||
var fd = new FormData(form);
|
||||
for (var pair of fd.entries()) {
|
||||
if (pair[1]) params.set(pair[0], pair[1]);
|
||||
}
|
||||
}
|
||||
if (overrides) {
|
||||
for (var k in overrides) params.set(k, overrides[k]);
|
||||
}
|
||||
var qs = params.toString();
|
||||
var html = await fetchText('/app/panel/folders/' + folderId + (qs ? '?' + qs : ''));
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = 'folder';
|
||||
mainPanel.dataset.contextId = folderId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function runSearch(q) {
|
||||
if (!q || !q.trim()) return;
|
||||
var html = await fetchText('/app/search?q=' + encodeURIComponent(q));
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = 'search';
|
||||
mainPanel.dataset.contextId = '';
|
||||
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
|
||||
}
|
||||
|
||||
var treeSearchEl = document.getElementById('tree-search');
|
||||
if (treeSearchEl) {
|
||||
var searchDebounce;
|
||||
treeSearchEl.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSearch(treeSearchEl.value);
|
||||
}
|
||||
});
|
||||
treeSearchEl.addEventListener('input', function () {
|
||||
clearTimeout(searchDebounce);
|
||||
var q = treeSearchEl.value;
|
||||
if (!q) return;
|
||||
searchDebounce = setTimeout(function () { runSearch(q); }, 400);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CSV import
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var importFormEl = document.getElementById('importForm');
|
||||
if (importFormEl) {
|
||||
importFormEl.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
var resultBox = document.getElementById('importResult');
|
||||
resultBox.innerHTML = '<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 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; }
|
||||
if (e.target.closest('#btn-new-root-folder')) { openFolderModal({ parentId: null }); return; }
|
||||
|
||||
var toggle = e.target.closest('.tree-toggle');
|
||||
if (toggle) { e.stopPropagation(); toggleFolder(toggle); return; }
|
||||
|
||||
var actionBtn = e.target.closest('.btn-tree-action');
|
||||
if (actionBtn) { e.stopPropagation(); openTreeContextMenu(actionBtn); return; }
|
||||
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (row && row.dataset.type && !row.classList.contains('tree-row-more')) {
|
||||
selectNode(row);
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop (admin only)
|
||||
treePane.addEventListener('dragstart', function (e) {
|
||||
if (!isAdmin) { e.preventDefault(); return; }
|
||||
var li = e.target.closest('.tree-node[draggable="true"]');
|
||||
if (!li) return;
|
||||
draggedNode = { type: li.dataset.type, id: li.dataset.id };
|
||||
li.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', draggedNode.type + ':' + draggedNode.id);
|
||||
});
|
||||
|
||||
treePane.addEventListener('dragend', function () {
|
||||
document.querySelectorAll('.tree-node.dragging').forEach(function (n) { n.classList.remove('dragging'); });
|
||||
document.querySelectorAll('.tree-row.drop-target').forEach(function (n) { n.classList.remove('drop-target'); });
|
||||
draggedNode = null;
|
||||
});
|
||||
|
||||
treePane.addEventListener('dragover', function (e) {
|
||||
if (!draggedNode) return;
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (!row) return;
|
||||
var type = row.dataset.type;
|
||||
if (type !== 'folder' && type !== 'root') return;
|
||||
if (type === 'folder' && draggedNode.type === 'folder' && draggedNode.id === row.dataset.id) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
row.classList.add('drop-target');
|
||||
});
|
||||
|
||||
treePane.addEventListener('dragleave', function (e) {
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (row) row.classList.remove('drop-target');
|
||||
});
|
||||
|
||||
treePane.addEventListener('drop', async function (e) {
|
||||
if (!draggedNode) return;
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (!row) return;
|
||||
var type = row.dataset.type;
|
||||
if (type !== 'folder' && type !== 'root') return;
|
||||
e.preventDefault();
|
||||
row.classList.remove('drop-target');
|
||||
var destId = type === 'root' ? null : Number(row.dataset.id);
|
||||
var dragged = draggedNode;
|
||||
draggedNode = null;
|
||||
try {
|
||||
if (dragged.type === 'folder') {
|
||||
await apiFetch('/app/folders/' + dragged.id + '/move', { method: 'POST', json: { parent_id: destId } });
|
||||
} else if (dragged.type === 'asset') {
|
||||
await apiFetch('/app/assets/' + dragged.id + '/move', { method: 'POST', json: { folder_id: destId } });
|
||||
}
|
||||
await refreshWholeTree();
|
||||
await reloadCurrentPanel();
|
||||
} catch (err) {
|
||||
alert('Move failed: ' + err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Main panel: click / change / submit delegation
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
if (mainPanel) {
|
||||
mainPanel.addEventListener('click', function (e) {
|
||||
var 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; }
|
||||
|
||||
var newSub = e.target.closest('[data-action="new-subfolder"]');
|
||||
if (newSub) { openFolderModal({ parentId: newSub.dataset.id }); return; }
|
||||
|
||||
var renameBtn = e.target.closest('[data-action="rename-folder"]');
|
||||
if (renameBtn) { openRenameFolder(renameBtn.dataset.id); return; }
|
||||
|
||||
var deleteBtn = e.target.closest('[data-action="delete-folder"]');
|
||||
if (deleteBtn) { startDeleteFolder(deleteBtn.dataset.id); return; }
|
||||
|
||||
var newAssetBtn = e.target.closest('[data-action="new-asset"]');
|
||||
if (newAssetBtn) { loadAssetForm({ folderId: newAssetBtn.dataset.folderId }); return; }
|
||||
|
||||
var editAssetBtn = e.target.closest('[data-action="edit-asset"]');
|
||||
if (editAssetBtn) { loadAssetEditForm(editAssetBtn.dataset.id); return; }
|
||||
|
||||
var dupBtn = e.target.closest('[data-action="duplicate-asset"]');
|
||||
if (dupBtn) { duplicateAsset(dupBtn.dataset.id); return; }
|
||||
|
||||
var delAssetBtn = e.target.closest('[data-action="delete-asset"]');
|
||||
if (delAssetBtn) { deleteAsset(delAssetBtn.dataset.id, delAssetBtn.dataset.name); return; }
|
||||
|
||||
var confirmMoveBtn = e.target.closest('[data-action="confirm-move-asset"]');
|
||||
if (confirmMoveBtn) { confirmMoveAsset(confirmMoveBtn.dataset.id, confirmMoveBtn.dataset.select); return; }
|
||||
|
||||
var cancelFormBtn = e.target.closest('[data-action="cancel-asset-form"]');
|
||||
if (cancelFormBtn) { cancelAssetForm(cancelFormBtn); return; }
|
||||
});
|
||||
|
||||
mainPanel.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;
|
||||
return;
|
||||
}
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
if (e.target.id === 'assetForm') {
|
||||
e.preventDefault();
|
||||
submitAssetForm(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
// Assets/folders are also draggable as drop-into-root targets from the
|
||||
// panel view when browsing (drag onto the tree's root row still works
|
||||
// via the tree pane handler above); dragstart on panel rows is not
|
||||
// implemented since the tree is the primary drag surface.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
+7
File diff suppressed because one or more lines are too long
@@ -0,0 +1,54 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>inventree</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="role-{{.User.Role}}">
|
||||
<nav class="navbar navbar-expand-lg app-navbar border-bottom px-3">
|
||||
<a class="navbar-brand fw-semibold d-flex align-items-center gap-2" href="/inventory">
|
||||
<i class="bi bi-diagram-3-fill"></i> inventree
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navMain">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item"><a class="nav-link" href="/inventory">Inventory</a></li>
|
||||
{{if .User.IsAdmin}}
|
||||
<li class="nav-item"><a class="nav-link" href="/item-types">Item Types</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/users">Users</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/settings">Settings</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center gap-1" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i> {{.User.Username}}
|
||||
{{if .User.IsAdmin}}<span class="badge text-bg-secondary ms-1">admin</span>{{end}}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<form action="/logout" method="post" class="px-3 py-1">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary w-100">Sign out</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "login"}}<!doctype html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · inventree</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="login-body d-flex align-items-center">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-11 col-sm-8 col-md-5 col-lg-4">
|
||||
<div class="text-center mb-4">
|
||||
<div class="display-6"><i class="bi bi-diagram-3-fill"></i></div>
|
||||
<h1 class="h4 mt-2 mb-0">inventree</h1>
|
||||
<div class="text-body-secondary small">IT asset inventory</div>
|
||||
</div>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
{{if .Error}}<div class="alert alert-danger py-2">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input class="form-control" type="text" id="username" name="username" autofocus required autocomplete="username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input class="form-control" type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-body-secondary small mt-3">Default login: admin / admin</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "content"}}
|
||||
<div class="inventory-shell d-flex">
|
||||
<aside id="tree-pane" class="tree-pane border-end d-flex flex-column">
|
||||
<div class="p-2 border-bottom">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="search" id="tree-search" class="form-control" placeholder="Search inventory…">
|
||||
</div>
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btn-expand-all" title="Expand all folders"><i class="bi bi-arrows-expand"></i> Expand</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btn-collapse-all" title="Collapse all folders"><i class="bi bi-arrows-collapse"></i> Collapse</button>
|
||||
{{if .User.IsAdmin}}
|
||||
<button class="btn btn-sm btn-primary ms-auto" id="btn-new-root-folder" data-parent-id="" title="New top-level folder">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tree-scroll flex-fill overflow-auto">
|
||||
<ul class="tree list-unstyled mb-0" id="tree-root">
|
||||
<li class="tree-node tree-node-root" data-type="root">
|
||||
<div class="tree-row selected" data-type="root" tabindex="0">
|
||||
<span class="tree-toggle-spacer"></span>
|
||||
<i class="bi bi-house-door-fill tree-icon text-primary"></i>
|
||||
<span class="tree-label">Inventory</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<ul class="tree-children list-unstyled" id="top-level-children" data-expanded="true">
|
||||
{{template "tree_children" .Tree}}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section id="main-panel" class="main-panel flex-fill overflow-auto" data-context-type="root" data-context-id="">
|
||||
{{template "panel_root" .Root}}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{template "modals" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,120 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3" style="max-width: 900px">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">{{if .ItemType}}Edit Item Type{{else}}New Item Type{{end}}</h4>
|
||||
<a href="/item-types" class="btn btn-sm btn-outline-secondary ms-auto">Back to Item Types</a>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{if .ItemType}}/item-types/{{.ItemType.ID}}{{else}}/item-types{{end}}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name" value="{{if .ItemType}}{{.ItemType.Name}}{{end}}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class)</span></label>
|
||||
<input type="text" class="form-control" name="icon" value="{{if .ItemType}}{{.ItemType.Icon}}{{end}}" placeholder="bi-box-seam">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" name="description" rows="2">{{if .ItemType}}{{.ItemType.Description}}{{end}}</textarea>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Sort Order</label>
|
||||
<input type="number" class="form-control" name="sort_order" value="{{if .ItemType}}{{.ItemType.SortOrder}}{{else}}0{{end}}">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if .ItemType}}{{if .ItemType.Enabled}}checked{{end}}{{else}}checked{{end}}>
|
||||
<label class="form-check-label" for="enabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mt-3">Save</button>
|
||||
{{if .ItemType}}
|
||||
<button type="submit" formaction="/item-types/{{.ItemType.ID}}/delete" class="btn btn-outline-danger mt-3" onclick="return confirm('Delete this item type? This is only possible if no assets use it.')">Delete Item Type</button>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ItemType}}
|
||||
<h5 class="mb-2">Custom Fields</h5>
|
||||
<div class="card mb-4">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 align-middle">
|
||||
<thead><tr><th>Name</th><th>Key</th><th>Type</th><th>Required</th><th>Default</th><th>Options</th><th>Order</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .ItemType.Fields}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><code>{{.FieldKey}}</code></td>
|
||||
<td>{{.FieldType.Label}}</td>
|
||||
<td>{{if .Required}}<i class="bi bi-check-lg text-success"></i>{{end}}</td>
|
||||
<td>{{.DefaultValue}}</td>
|
||||
<td class="text-body-secondary small">{{range .Options}}{{.}}<br>{{end}}</td>
|
||||
<td>{{.SortOrder}}</td>
|
||||
<td class="text-nowrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary field-edit-btn"
|
||||
data-id="{{.ID}}" data-name="{{.Name}}" data-type="{{.FieldType}}"
|
||||
data-required="{{.Required}}" data-default="{{.DefaultValue}}"
|
||||
data-options="{{range $i, $o := .Options}}{{if $i}} {{end}}{{$o}}{{end}}" data-sort-order="{{.SortOrder}}">
|
||||
Edit
|
||||
</button>
|
||||
<form method="post" action="/item-types/{{$.ItemType.ID}}/fields/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this field? Existing values will be lost.')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if not .ItemType.Fields}}
|
||||
<tr><td colspan="8" class="text-body-secondary">No custom fields yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body border-top">
|
||||
<h6 id="fieldFormTitle">Add Field</h6>
|
||||
<form method="post" action="/item-types/{{.ItemType.ID}}/fields" id="fieldForm" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" name="name" id="fieldFormName" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Type</label>
|
||||
<select class="form-select form-select-sm" name="field_type" id="fieldFormType">
|
||||
{{range .FieldTypes}}<option value="{{.}}">{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Default Value</label>
|
||||
<input type="text" class="form-control form-control-sm" name="default_value" id="fieldFormDefault">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Dropdown Options <span class="text-body-secondary">(one per line)</span></label>
|
||||
<textarea class="form-control form-control-sm" name="options" id="fieldFormOptions" rows="1"></textarea>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="required" id="fieldFormRequired">
|
||||
<label class="form-check-label small" for="fieldFormRequired">Required</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100" id="fieldFormSubmit">Add</button>
|
||||
</div>
|
||||
<input type="hidden" name="sort_order" id="fieldFormSortOrder" value="0">
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-link d-none" id="fieldFormCancel">Cancel edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.itemTypeFieldsBaseAction = "/item-types/{{.ItemType.ID}}/fields";</script>
|
||||
{{else}}
|
||||
<p class="text-body-secondary">Save the item type first to add custom fields.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">Item Types</h4>
|
||||
<a href="/item-types/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-plus-lg"></i> New Item Type</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead><tr><th>Icon</th><th>Name</th><th>Description</th><th>Custom Fields</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .ItemTypes}}
|
||||
<tr>
|
||||
<td><i class="bi {{iconOr .Icon "bi-box-seam"}} fs-5"></i></td>
|
||||
<td><a href="/item-types/{{.ID}}/edit">{{.Name}}</a></td>
|
||||
<td class="text-body-secondary">{{.Description}}</td>
|
||||
<td>{{len .Fields}}</td>
|
||||
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
|
||||
<td class="text-end"><a href="/item-types/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="text-body-secondary">No item types defined yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,86 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3" style="max-width: 800px">
|
||||
<h4 class="mb-3">Settings</h4>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-2 fw-semibold">General</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/settings/general" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Site Name</label>
|
||||
<input type="text" class="form-control" name="site_name" value="{{.SiteName}}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Asset ID Prefix</label>
|
||||
<input type="text" class="form-control" name="asset_id_prefix" value="{{.AssetPrefix}}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Asset ID Padding</label>
|
||||
<input type="number" class="form-control" name="asset_id_padding" value="{{.AssetPadding}}" min="1" max="12">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header py-2 fw-semibold">Statuses</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 align-middle">
|
||||
<thead><tr><th>Order</th><th>Preview</th><th>Name & Color</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range $i, $st := .Statuses}}
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
|
||||
<input type="hidden" name="dir" value="up">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq $i 0}}disabled{{end}}><i class="bi bi-arrow-up"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
|
||||
<input type="hidden" name="dir" value="down">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq (add $i 1) (len $.Statuses)}}disabled{{end}}><i class="bi bi-arrow-down"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
<td><span class="badge text-bg-{{$st.Color}}">{{$st.Name}}</span></td>
|
||||
<td>
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}" class="d-flex gap-2 align-items-center">
|
||||
<input type="text" class="form-control form-control-sm" name="name" value="{{$st.Name}}" style="max-width:160px" required>
|
||||
<select class="form-select form-select-sm" name="color" style="max-width:130px">
|
||||
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}
|
||||
<option value="{{.}}" {{if eq . $st.Color}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}/delete" onsubmit="return confirm('Delete this status?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body border-top">
|
||||
<h6>Add Status</h6>
|
||||
<form method="post" action="/settings/statuses" class="d-flex gap-2 align-items-end">
|
||||
<div>
|
||||
<label class="form-label small mb-1">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" name="name" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small mb-1">Color</label>
|
||||
<select class="form-select form-select-sm" name="color">
|
||||
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3" style="max-width: 600px">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">{{if .EditUser}}Edit User{{else}}New User{{end}}</h4>
|
||||
<a href="/users" class="btn btn-sm btn-outline-secondary ms-auto">Back to Users</a>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{if .EditUser}}/users/{{.EditUser.ID}}{{else}}/users{{end}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" name="username" value="{{if .EditUser}}{{.EditUser.Username}}{{end}}" required autofocus>
|
||||
</div>
|
||||
{{if not .EditUser}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" name="password" required autocomplete="new-password">
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-select" name="role">
|
||||
<option value="user" {{if .EditUser}}{{if eq .EditUser.Role "user"}}selected{{end}}{{end}}>User</option>
|
||||
<option value="admin" {{if .EditUser}}{{if eq .EditUser.Role "admin"}}selected{{end}}{{end}}>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if .EditUser}}{{if .EditUser.Enabled}}checked{{end}}{{else}}checked{{end}}>
|
||||
<label class="form-check-label" for="enabled">Enabled</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
{{if .EditUser}}
|
||||
<form method="post" action="/users/{{.EditUser.ID}}/delete" class="d-inline mt-2" onsubmit="return confirm('Delete this user?')">
|
||||
<button type="submit" class="btn btn-outline-danger">Delete User</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .EditUser}}
|
||||
<div class="card">
|
||||
<div class="card-header py-2 fw-semibold">Change Password</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/users/{{.EditUser.ID}}/password" class="row g-2 align-items-end">
|
||||
<div class="col">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" class="form-control" name="password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-outline-primary">Set Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">Users</h4>
|
||||
<a href="/users/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-person-plus"></i> New User</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td><a href="/users/{{.ID}}/edit">{{.Username}}</a></td>
|
||||
<td>{{if .IsAdmin}}<span class="badge text-bg-primary">Admin</span>{{else}}<span class="badge text-bg-secondary">User</span>{{end}}</td>
|
||||
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
|
||||
<td>{{fmtDateShort .CreatedAt}}</td>
|
||||
<td class="text-end"><a href="/users/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{define "asset_fields_only"}}
|
||||
{{if .}}
|
||||
<h6 class="mt-3 mb-2">Type-Specific Fields</h6>
|
||||
{{range .}}
|
||||
{{$fv := .}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="field-{{.Def.ID}}">{{.Def.Name}}{{if .Def.Required}} <span class="text-danger">*</span>{{end}}</label>
|
||||
{{if eq .Def.FieldType "long_text"}}
|
||||
<textarea class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" rows="3" {{if .Def.Required}}required{{end}}>{{.Value}}</textarea>
|
||||
{{else if eq .Def.FieldType "boolean"}}
|
||||
<select class="form-select" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}">
|
||||
<option value="false" {{if ne .Value "true"}}selected{{end}}>No</option>
|
||||
<option value="true" {{if eq .Value "true"}}selected{{end}}>Yes</option>
|
||||
</select>
|
||||
{{else if eq .Def.FieldType "dropdown"}}
|
||||
<select class="form-select" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" {{if .Def.Required}}required{{end}}>
|
||||
<option value="">—</option>
|
||||
{{range .Def.Options}}<option value="{{.}}" {{if eq . $fv.Value}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
{{else if eq .Def.FieldType "date"}}
|
||||
<input type="date" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" {{if .Def.Required}}required{{end}}>
|
||||
{{else if eq .Def.FieldType "number"}}
|
||||
<input type="number" step="any" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" {{if .Def.Required}}required{{end}}>
|
||||
{{else if eq .Def.FieldType "url"}}
|
||||
<input type="url" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" {{if .Def.Required}}required{{end}}>
|
||||
{{else if eq .Def.FieldType "ip_address"}}
|
||||
<input type="text" pattern="^[0-9a-fA-F:\.]+$" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" placeholder="192.168.1.1" {{if .Def.Required}}required{{end}}>
|
||||
{{else}}
|
||||
<input type="text" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" {{if .Def.Required}}required{{end}}>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,81 @@
|
||||
{{define "asset_form"}}
|
||||
<div class="panel-content p-3">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
<li class="breadcrumb-item active">{{if .Asset}}Edit {{.Asset.Name}}{{else}}New Asset{{end}}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<form id="assetForm" data-mode="{{if .Asset}}edit{{else}}create{{end}}" {{if .Asset}}data-asset-id="{{.Asset.ID}}"{{end}}>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Asset ID</label>
|
||||
<input type="text" class="form-control" name="asset_id" value="{{if .Asset}}{{.Asset.AssetTag}}{{end}}" placeholder="Auto-generated if left blank">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="name" value="{{if .Asset}}{{.Asset.Name}}{{end}}" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Item Type <span class="text-danger">*</span></label>
|
||||
{{if .Asset}}
|
||||
<input type="text" class="form-control" value="{{.Asset.ItemTypeName}}" disabled>
|
||||
<div class="form-text">Item type cannot be changed after creation.</div>
|
||||
{{else}}
|
||||
<select class="form-select" name="item_type_id" id="assetFormItemType" required>
|
||||
{{range .ItemTypes}}<option value="{{.ID}}" {{if eq .ID $.SelectedIT}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Folder</label>
|
||||
{{$selectedFolder := deref .FolderID}}
|
||||
{{if .Asset}}{{$selectedFolder = deref .Asset.FolderID}}{{end}}
|
||||
<select class="form-select" name="folder_id">
|
||||
<option value="">Inventory (top level)</option>
|
||||
{{range .FolderOptions}}
|
||||
<option value="{{.ID}}" {{if eq $selectedFolder .ID}}selected{{end}}>
|
||||
{{range seq .Depth}} {{end}}{{.Path}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Status <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="status_id" required>
|
||||
{{range .Statuses}}<option value="{{.ID}}" {{if $.Asset}}{{if eq .ID $.Asset.StatusID}}selected{{end}}{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Assigned User</label>
|
||||
{{$selectedUser := 0}}
|
||||
{{if .Asset}}{{$selectedUser = deref .Asset.AssignedUserID}}{{end}}
|
||||
<select class="form-select" name="assigned_user_id">
|
||||
<option value="">Unassigned</option>
|
||||
{{range .Users}}
|
||||
<option value="{{.ID}}" {{if eq $selectedUser .ID}}selected{{end}}>{{.Username}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-control" name="notes" rows="3">{{if .Asset}}{{.Asset.Notes}}{{end}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12" id="asset-fields-container">
|
||||
{{template "asset_fields_only" .Fields}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">{{if .Asset}}Save Changes{{else}}Create Asset{{end}}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-action="cancel-asset-form" {{if .Asset}}data-id="{{.Asset.ID}}"{{end}}>Cancel</button>
|
||||
</div>
|
||||
<div class="alert alert-danger mt-3 d-none" id="assetFormError"></div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{define "breadcrumb"}}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
{{$last := sub (len .) 1}}
|
||||
{{range $i, $b := .}}
|
||||
{{if eq $i $last}}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{$b.Name}}</li>
|
||||
{{else}}
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{$b.ID}}">{{$b.Name}}</a></li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ol>
|
||||
</nav>
|
||||
{{end}}
|
||||
@@ -0,0 +1,112 @@
|
||||
{{define "modals"}}
|
||||
{{if .User.IsAdmin}}
|
||||
<!-- Create / rename folder -->
|
||||
<div class="modal fade" id="folderModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="folderForm">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="folderModalTitle">New Folder</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="folder_id" id="folderFormId">
|
||||
<input type="hidden" name="parent_id" id="folderFormParentId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="folderFormName" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" name="description" id="folderFormDescription" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col">
|
||||
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class, optional)</span></label>
|
||||
<input type="text" class="form-control" name="icon" id="folderFormIcon" placeholder="bi-folder-fill">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Sort Order</label>
|
||||
<input type="number" class="form-control" name="sort_order" id="folderFormSortOrder" value="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-feedback d-block d-none" id="folderFormError"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Folder</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete folder confirmation -->
|
||||
<div class="modal fade" id="deleteFolderModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-exclamation-triangle-fill text-danger"></i> Delete Folder</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Delete <strong id="deleteFolderName"></strong>?</p>
|
||||
<div id="deleteFolderEmptyNotice" class="d-none">
|
||||
<p class="text-body-secondary">This folder is empty and can be deleted safely.</p>
|
||||
</div>
|
||||
<div id="deleteFolderNotEmptyNotice" class="d-none">
|
||||
<div class="alert alert-warning py-2">
|
||||
This folder contains <span id="deleteFolderCounts"></span>. Choose what to do with its contents.
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="delete_mode" id="modeRelocate" value="relocate" checked>
|
||||
<label class="form-check-label" for="modeRelocate">Move contents to…</label>
|
||||
</div>
|
||||
<select class="form-select mt-1 mb-2" id="deleteFolderDestination"></select>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="delete_mode" id="modeCascade" value="cascade">
|
||||
<label class="form-check-label text-danger" for="modeCascade">Permanently delete everything inside</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteFolderBtn">Delete Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import CSV -->
|
||||
<div class="modal fade" id="importModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="importForm">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import Assets from CSV</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">CSV File</label>
|
||||
<input type="file" class="form-control" name="file" id="importFile" accept=".csv" required>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="create_missing_folders" id="importCreateFolders" value="1" checked>
|
||||
<label class="form-check-label" for="importCreateFolders">Create missing folders referenced in the Folder column</label>
|
||||
</div>
|
||||
<p class="text-body-secondary small mb-0">
|
||||
Columns: Asset ID, Name, Item Type, Folder, Status, Assigned User, Notes, plus <code>cf_<field_key></code> for custom fields.
|
||||
</p>
|
||||
<div id="importResult" class="mt-3"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary" id="importSubmitBtn">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,104 @@
|
||||
{{define "panel_asset"}}
|
||||
<div class="panel-content p-3" data-asset-id="{{.Asset.ID}}">
|
||||
<div class="d-flex align-items-start mb-2">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
{{range .Asset.FolderPath}}
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{.ID}}">{{.Name}}</a></li>
|
||||
{{end}}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{.Asset.Name}}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{{if .CanEdit}}
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="edit-asset" data-id="{{.Asset.ID}}"><i class="bi bi-pencil"></i> Edit</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="move-asset" data-bs-toggle="modal" data-bs-target="#moveAssetModal-{{.Asset.ID}}"><i class="bi bi-arrows-move"></i> Move</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="duplicate-asset" data-id="{{.Asset.ID}}"><i class="bi bi-files"></i> Duplicate</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-action="delete-asset" data-id="{{.Asset.ID}}" data-name="{{.Asset.Name}}"><i class="bi bi-trash"></i> Delete</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi {{iconOr .Asset.ItemTypeIcon "bi-box-seam"}} fs-3 text-body-secondary"></i>
|
||||
<div>
|
||||
<h4 class="mb-0">{{.Asset.Name}}</h4>
|
||||
<div class="text-body-secondary">{{.Asset.AssetTag}} · {{.Asset.ItemTypeName}}</div>
|
||||
</div>
|
||||
<span class="badge text-bg-{{.Asset.StatusColor}} ms-2 fs-6">{{.Asset.StatusName}}</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2 fw-semibold">Details</div>
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
<tr><th class="w-25">Assigned User</th><td>{{if .Asset.AssignedUsername}}{{.Asset.AssignedUsername}}{{else}}<span class="text-body-secondary">Unassigned</span>{{end}}</td></tr>
|
||||
<tr><th>Created</th><td>{{fmtDate .Asset.CreatedAt}}</td></tr>
|
||||
<tr><th>Updated</th><td>{{fmtDate .Asset.UpdatedAt}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if .Asset.Notes}}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2 fw-semibold">Notes</div>
|
||||
<div class="card-body py-2" style="white-space:pre-wrap">{{.Asset.Notes}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{if .Asset.CustomFields}}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2 fw-semibold">{{.Asset.ItemTypeName}} Fields</div>
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
{{range .Asset.CustomFields}}
|
||||
<tr>
|
||||
<th class="w-40">{{.Def.Name}}</th>
|
||||
<td>
|
||||
{{if eq .Def.FieldType "boolean"}}
|
||||
{{if eq .Value "true"}}<i class="bi bi-check-circle-fill text-success"></i>{{else}}<i class="bi bi-x-circle text-body-secondary"></i>{{end}}
|
||||
{{else if eq .Def.FieldType "url"}}
|
||||
{{if .Value}}<a href="{{.Value}}" target="_blank" rel="noopener">{{.Value}}</a>{{end}}
|
||||
{{else}}
|
||||
{{if .Value}}{{.Value}}{{else}}<span class="text-body-secondary">—</span>{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .CanEdit}}
|
||||
<div class="modal fade" id="moveAssetModal-{{.Asset.ID}}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Move "{{.Asset.Name}}"</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label">Destination Folder</label>
|
||||
<select class="form-select" id="moveAssetDestination-{{.Asset.ID}}">
|
||||
<option value="">Inventory (top level)</option>
|
||||
{{range .FolderOptions}}
|
||||
<option value="{{.ID}}">{{range seq .Depth}} {{end}}{{.Path}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-action="confirm-move-asset" data-id="{{.Asset.ID}}" data-select="moveAssetDestination-{{.Asset.ID}}">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,80 @@
|
||||
{{define "panel_folder"}}
|
||||
<div class="panel-content p-3" data-folder-id="{{.Folder.ID}}">
|
||||
<div class="d-flex align-items-start mb-2">
|
||||
{{template "breadcrumb" .Breadcrumb}}
|
||||
<div class="ms-auto d-flex gap-2 admin-only">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="new-subfolder" data-id="{{.Folder.ID}}"><i class="bi bi-folder-plus"></i> Subfolder</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="rename-folder" data-id="{{.Folder.ID}}" data-name="{{.Folder.Name}}" data-description="{{.Folder.Description}}" data-icon="{{.Folder.Icon}}" data-sort-order="{{.Folder.SortOrder}}"><i class="bi bi-pencil"></i> Rename</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-action="delete-folder" data-id="{{.Folder.ID}}"><i class="bi bi-trash"></i> Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-1"><i class="bi {{iconOr .Folder.Icon "bi-folder-fill"}} text-warning-emphasis me-1"></i>{{.Folder.Name}}</h4>
|
||||
{{if .Folder.Description}}<p class="text-body-secondary">{{.Folder.Description}}</p>{{end}}
|
||||
|
||||
{{if .Subfolders}}
|
||||
<h6 class="mt-3 mb-2">Subfolders</h6>
|
||||
<div class="list-group mb-3">
|
||||
{{range .Subfolders}}
|
||||
<a href="#" class="list-group-item list-group-item-action folder-link d-flex align-items-center" data-id="{{.ID}}">
|
||||
<i class="bi {{iconOr .Icon "bi-folder-fill"}} text-warning-emphasis me-2"></i>
|
||||
{{.Name}}
|
||||
<span class="ms-auto text-body-secondary small">{{.FolderCount}} folder(s), {{.AssetCount}} asset(s)</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mt-3 mb-2">
|
||||
<h6 class="mb-0 me-2">Assets</h6>
|
||||
<form class="d-flex gap-2 flex-wrap align-items-center panel-filter-form" data-folder-id="{{.Folder.ID}}">
|
||||
<select class="form-select form-select-sm" name="item_type_id" style="width:auto">
|
||||
<option value="">All Types</option>
|
||||
{{range .ItemTypes}}<option value="{{.ID}}" {{if eq .ID $.FilterType}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
<select class="form-select form-select-sm" name="status_id" style="width:auto">
|
||||
<option value="">All Statuses</option>
|
||||
{{range .Statuses}}<option value="{{.ID}}" {{if eq .ID $.FilterStat}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
<input type="search" class="form-control form-control-sm" name="q" value="{{.Query}}" placeholder="Filter assets…" style="width:160px">
|
||||
</form>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/export/assets.csv?folder_id={{.Folder.ID}}&recursive=1"><i class="bi bi-download"></i> Export</a>
|
||||
<button class="btn btn-sm btn-primary admin-only" data-action="new-asset" data-folder-id="{{.Folder.ID}}"><i class="bi bi-plus-lg"></i> Add Asset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Assets}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover asset-table">
|
||||
<thead><tr><th>Asset ID</th><th>Name</th><th>Type</th><th>Status</th><th>Assigned</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Assets}}
|
||||
<tr class="asset-row" data-id="{{.ID}}">
|
||||
<td>{{.AssetTag}}</td>
|
||||
<td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td>
|
||||
<td>{{.ItemTypeName}}</td>
|
||||
<td><span class="badge text-bg-{{.StatusColor}}">{{.StatusName}}</span></td>
|
||||
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">—</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{$pages := ceilDiv .Total .PageSize}}
|
||||
{{if gt $pages 1}}
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm">
|
||||
{{range $i := seqFrom1 $pages}}
|
||||
<li class="page-item {{if eq $i $.Page}}active{{end}}">
|
||||
<a class="page-link panel-page-link" href="#" data-folder-id="{{$.Folder.ID}}" data-page="{{$i}}">{{$i}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-body-secondary">No assets in this folder yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,109 @@
|
||||
{{define "panel_root"}}
|
||||
<div class="panel-content p-3">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item active" aria-current="page">Inventory</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-2">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body py-2">
|
||||
<div class="stat-value">{{.Stats.Total}}</div>
|
||||
<div class="stat-label">Total Assets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{range .Stats.ByStatus}}
|
||||
<div class="col-6 col-md-2">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body py-2">
|
||||
<div class="stat-value">{{.Count}}</div>
|
||||
<div class="stat-label"><span class="badge text-bg-{{.Color}}">{{.Name}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 fw-semibold">Assets by Type</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .Stats.ByType}}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center py-1">
|
||||
<span><i class="bi {{iconOr .Icon "bi-box-seam"}} me-2"></i>{{.Name}}</span>
|
||||
<span class="badge text-bg-secondary rounded-pill">{{.Count}}</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="list-group-item text-body-secondary py-2">No assets yet.</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 fw-semibold">Recently Added</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .Stats.RecentlyAdded}}
|
||||
<li class="list-group-item py-1">
|
||||
<a href="#" class="asset-link text-decoration-none" data-id="{{.ID}}">
|
||||
<i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-2"></i>{{.Name}}
|
||||
<span class="text-body-secondary small">{{.AssetTag}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="list-group-item text-body-secondary py-2">No assets yet.</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h6 class="mb-0">Top-Level Folders</h6>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/export/assets.csv"><i class="bi bi-download"></i> Export CSV</a>
|
||||
<button class="btn btn-sm btn-outline-secondary admin-only" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
|
||||
<button class="btn btn-sm btn-primary admin-only" id="btn-new-folder-root"><i class="bi bi-folder-plus"></i> New Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Folders}}
|
||||
<div class="list-group mb-3">
|
||||
{{range .Folders}}
|
||||
<a href="#" class="list-group-item list-group-item-action folder-link d-flex align-items-center" data-id="{{.ID}}">
|
||||
<i class="bi {{iconOr .Icon "bi-folder-fill"}} text-warning-emphasis me-2"></i>
|
||||
{{.Name}}
|
||||
<span class="text-body-secondary small ms-2">{{if .Description}}{{.Description}}{{end}}</span>
|
||||
<span class="ms-auto text-body-secondary small">{{.FolderCount}} folder(s), {{.AssetCount}} asset(s)</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-body-secondary">No folders yet. {{if true}}<span class="admin-only">Create one to start organizing your inventory.</span>{{end}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .UnfiledAssets}}
|
||||
<h6 class="mb-2">Unfiled Assets</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover asset-table">
|
||||
<thead><tr><th>Asset ID</th><th>Name</th><th>Type</th><th>Status</th><th>Assigned</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .UnfiledAssets}}
|
||||
<tr class="asset-row" data-id="{{.ID}}">
|
||||
<td>{{.AssetTag}}</td>
|
||||
<td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td>
|
||||
<td>{{.ItemTypeName}}</td>
|
||||
<td><span class="badge text-bg-{{.StatusColor}}">{{.StatusName}}</span></td>
|
||||
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">—</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "panel_search"}}
|
||||
<div class="panel-content p-3">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
<li class="breadcrumb-item active">Search: "{{.Query}}"</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h5 class="mb-3">{{len .Results}} result(s) for "{{.Query}}"</h5>
|
||||
|
||||
{{if .Results}}
|
||||
<div class="list-group">
|
||||
{{range .Results}}
|
||||
<a href="#" class="list-group-item list-group-item-action asset-link" data-id="{{.ID}}">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-2 fs-5 text-body-secondary"></i>
|
||||
<div>
|
||||
<div class="fw-semibold">{{.AssetTag}} — {{.Name}}</div>
|
||||
<div class="text-body-secondary small">
|
||||
Inventory{{range .FolderPath}} / {{.Name}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge text-bg-{{.StatusColor}} ms-auto">{{.StatusName}}</span>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-body-secondary">No matches found.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "tree_children"}}
|
||||
{{range .}}
|
||||
{{if eq .Type "folder"}}
|
||||
<li class="tree-node" data-type="folder" data-id="{{.ID}}" draggable="true">
|
||||
<div class="tree-row" data-type="folder" data-id="{{.ID}}" tabindex="0">
|
||||
{{if .HasChildren}}
|
||||
<button type="button" class="tree-toggle{{if .Expanded}} expanded{{end}}" data-id="{{.ID}}" aria-label="Toggle">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
{{else}}
|
||||
<span class="tree-toggle-spacer"></span>
|
||||
{{end}}
|
||||
<i class="bi {{iconOr .Icon "bi-folder-fill"}} tree-icon text-warning-emphasis"></i>
|
||||
<span class="tree-label">{{.Name}}</span>
|
||||
<span class="tree-actions admin-only">
|
||||
<button type="button" class="btn-tree-action" data-action="menu" data-id="{{.ID}}" data-type="folder" title="Actions">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<ul class="tree-children list-unstyled{{if not .Expanded}} d-none{{end}}" data-expanded="{{.Expanded}}" data-loaded="{{.Expanded}}">
|
||||
{{if .Expanded}}{{template "tree_children" .Children}}{{end}}
|
||||
</ul>
|
||||
</li>
|
||||
{{else if eq .Type "asset"}}
|
||||
<li class="tree-node" data-type="asset" data-id="{{.ID}}" draggable="true">
|
||||
<div class="tree-row" data-type="asset" data-id="{{.ID}}" tabindex="0">
|
||||
<span class="tree-toggle-spacer"></span>
|
||||
<i class="bi {{iconOr .Icon "bi-box-seam"}} tree-icon text-body-secondary"></i>
|
||||
<span class="tree-label">{{.Name}}</span>
|
||||
<span class="badge rounded-pill text-bg-{{.StatusColor}} tree-status-badge">{{.StatusName}}</span>
|
||||
</div>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="tree-node tree-node-more">
|
||||
<div class="tree-row tree-row-more text-body-secondary fst-italic">
|
||||
<span class="tree-toggle-spacer"></span>
|
||||
<i class="bi bi-three-dots"></i>
|
||||
<span class="tree-label">{{.Name}}</span>
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user