diff --git a/internal/assets/assets.go b/internal/assets/assets.go
index f702f2a..0d7c404 100644
--- a/internal/assets/assets.go
+++ b/internal/assets/assets.go
@@ -466,84 +466,3 @@ func (s *Store) descendantFolderIDs(ctx context.Context, rootID int64) ([]int64,
walk(rootID)
return out, nil
}
-
-// DashboardStats summarizes inventory counts for the small dashboard.
-type DashboardStats struct {
- Total int
- ByStatus []StatusCount
- ByType []TypeCount
- RecentlyAdded []models.Asset
-}
-
-type StatusCount struct {
- Name string
- Color string
- Count int
-}
-
-type TypeCount struct {
- Name string
- Icon string
- Count int
-}
-
-func (s *Store) Stats(ctx context.Context) (*DashboardStats, error) {
- stats := &DashboardStats{}
- if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets`).Scan(&stats.Total); err != nil {
- return nil, err
- }
-
- rows, err := s.db.QueryContext(ctx, `
- SELECT st.name, st.color, COUNT(a.id)
- FROM statuses st LEFT JOIN assets a ON a.status_id = st.id
- GROUP BY st.id ORDER BY st.sort_order ASC`)
- if err != nil {
- return nil, err
- }
- for rows.Next() {
- var sc StatusCount
- if err := rows.Scan(&sc.Name, &sc.Color, &sc.Count); err != nil {
- rows.Close()
- return nil, err
- }
- stats.ByStatus = append(stats.ByStatus, sc)
- }
- rows.Close()
- if err := rows.Err(); err != nil {
- return nil, err
- }
-
- rows, err = s.db.QueryContext(ctx, `
- SELECT it.name, it.icon, COUNT(a.id)
- FROM item_types it LEFT JOIN assets a ON a.item_type_id = it.id
- GROUP BY it.id HAVING COUNT(a.id) > 0 ORDER BY COUNT(a.id) DESC`)
- if err != nil {
- return nil, err
- }
- for rows.Next() {
- var tc TypeCount
- if err := rows.Scan(&tc.Name, &tc.Icon, &tc.Count); err != nil {
- rows.Close()
- return nil, err
- }
- stats.ByType = append(stats.ByType, tc)
- }
- rows.Close()
- if err := rows.Err(); err != nil {
- return nil, err
- }
-
- recentRows, err := s.db.QueryContext(ctx, assetSelect+` ORDER BY a.created_at DESC LIMIT 8`)
- if err != nil {
- return nil, err
- }
- defer recentRows.Close()
- for recentRows.Next() {
- a, err := scanAsset(recentRows)
- if err != nil {
- return nil, err
- }
- stats.RecentlyAdded = append(stats.RecentlyAdded, *a)
- }
- return stats, recentRows.Err()
-}
diff --git a/internal/web/handlers_inventory.go b/internal/web/handlers_inventory.go
index 29d2af8..6a03875 100644
--- a/internal/web/handlers_inventory.go
+++ b/internal/web/handlers_inventory.go
@@ -1,7 +1,6 @@
package web
import (
- "context"
"net/http"
"inventree/internal/assets"
@@ -12,7 +11,6 @@ import (
type InventoryPageData struct {
User *models.User
Tree []models.TreeNode
- Root *RootPanelData
ItemTypes []models.ItemType
Statuses []models.Status
}
@@ -31,11 +29,6 @@ func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
- root, err := a.rootPanelData(ctx)
- if err != nil {
- writeErr(w, http.StatusInternalServerError, err.Error())
- return
- }
itemTypes, err := a.ItemTypes.List(ctx, false)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
@@ -48,7 +41,7 @@ func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
}
a.Render(w, http.StatusOK, "inventory", InventoryPageData{
- User: u, Tree: tree, Root: root, ItemTypes: itemTypes, Statuses: statusList,
+ User: u, Tree: tree, ItemTypes: itemTypes, Statuses: statusList,
})
}
@@ -112,39 +105,13 @@ func (a *App) handleTreeCollapseAll(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
-// RootPanelData backs the main panel when nothing is selected: the virtual
-// "Inventory" root, showing top-level folders, unfiled assets, and the
-// small dashboard.
-type RootPanelData struct {
- Folders []models.Folder
- UnfiledAssets []models.Asset
- UnfiledTotal int
- Stats *assets.DashboardStats
-}
-
-func (a *App) rootPanelData(ctx context.Context) (*RootPanelData, error) {
- folderList, err := a.Folders.Children(ctx, nil)
- if err != nil {
- return nil, err
- }
- unfiled, total, err := a.Assets.List(ctx, assets.ListFilter{FolderSet: true, FolderID: nil, Page: 1, PageSize: 50})
- if err != nil {
- return nil, err
- }
- stats, err := a.Assets.Stats(ctx)
- if err != nil {
- return nil, err
- }
- return &RootPanelData{Folders: folderList, UnfiledAssets: unfiled, UnfiledTotal: total, Stats: stats}, nil
-}
-
+// handlePanelRoot backs the main panel when nothing is selected. It is
+// intentionally blank (no dashboard/summary) - the panel only shows
+// something once a folder or asset is actually clicked; browsing happens
+// in the tree. The fragment still carries a data-panel-type="root" hook so
+// right-click still offers "New Folder" / "Add Asset" / import / export.
func (a *App) handlePanelRoot(w http.ResponseWriter, r *http.Request) {
- data, err := a.rootPanelData(r.Context())
- if err != nil {
- writeErr(w, http.StatusInternalServerError, err.Error())
- return
- }
- a.RenderFragment(w, http.StatusOK, "panel_root", data)
+ a.RenderFragment(w, http.StatusOK, "panel_root", nil)
}
// FolderPanelData backs the main panel when a folder is selected.
diff --git a/internal/web/handlers_settings.go b/internal/web/handlers_settings.go
index 73c8828..560f6b0 100644
--- a/internal/web/handlers_settings.go
+++ b/internal/web/handlers_settings.go
@@ -9,43 +9,32 @@ import (
type settingsPageData struct {
User *models.User
- Statuses []models.Status
SiteName string
AssetPrefix string
AssetPadding string
Error string
}
-func (a *App) settingsData(r *http.Request) (settingsPageData, error) {
- ctx := r.Context()
- statusList, err := a.Statuses.List(ctx)
- if err != nil {
- return settingsPageData{}, err
- }
- siteName, err := a.Settings.Get(ctx, "site_name", "inventree")
- if err != nil {
- return settingsPageData{}, err
- }
- prefix, err := a.Settings.Get(ctx, "asset_id_prefix", "IT-")
- if err != nil {
- return settingsPageData{}, err
- }
- padding, err := a.Settings.Get(ctx, "asset_id_padding", "6")
- if err != nil {
- return settingsPageData{}, err
- }
- return settingsPageData{
- User: currentUser(r), Statuses: statusList, SiteName: siteName, AssetPrefix: prefix, AssetPadding: padding,
- }, nil
-}
-
func (a *App) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
- data, err := a.settingsData(r)
+ ctx := r.Context()
+ siteName, err := a.Settings.Get(ctx, "site_name", "inventree")
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
- a.Render(w, http.StatusOK, "settings", data)
+ prefix, err := a.Settings.Get(ctx, "asset_id_prefix", "IT-")
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ padding, err := a.Settings.Get(ctx, "asset_id_padding", "6")
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ a.Render(w, http.StatusOK, "settings", settingsPageData{
+ User: currentUser(r), SiteName: siteName, AssetPrefix: prefix, AssetPadding: padding,
+ })
}
func (a *App) handleSettingsGeneral(w http.ResponseWriter, r *http.Request) {
@@ -62,6 +51,20 @@ func (a *App) handleSettingsGeneral(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
+type statusesPageData struct {
+ User *models.User
+ Statuses []models.Status
+}
+
+func (a *App) handleSettingsStatusesPage(w http.ResponseWriter, r *http.Request) {
+ statusList, err := a.Statuses.List(r.Context())
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ a.Render(w, http.StatusOK, "settings_statuses", statusesPageData{User: currentUser(r), Statuses: statusList})
+}
+
func (a *App) handleStatusCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
@@ -71,7 +74,7 @@ func (a *App) handleStatusCreate(w http.ResponseWriter, r *http.Request) {
if err := a.Statuses.Create(r.Context(), st); err != nil {
a.Log.Warn("create status failed", "error", err)
}
- http.Redirect(w, r, "/settings", http.StatusSeeOther)
+ http.Redirect(w, r, "/settings/statuses", http.StatusSeeOther)
}
func (a *App) handleStatusUpdate(w http.ResponseWriter, r *http.Request) {
@@ -94,7 +97,7 @@ func (a *App) handleStatusUpdate(w http.ResponseWriter, r *http.Request) {
if err := a.Statuses.Update(r.Context(), st); err != nil {
a.Log.Warn("update status failed", "error", err)
}
- http.Redirect(w, r, "/settings", http.StatusSeeOther)
+ http.Redirect(w, r, "/settings/statuses", http.StatusSeeOther)
}
func (a *App) handleStatusDelete(w http.ResponseWriter, r *http.Request) {
@@ -106,7 +109,7 @@ func (a *App) handleStatusDelete(w http.ResponseWriter, r *http.Request) {
if err := a.Statuses.Delete(r.Context(), id); err != nil {
a.Log.Warn("delete status failed", "error", err)
}
- http.Redirect(w, r, "/settings", http.StatusSeeOther)
+ http.Redirect(w, r, "/settings/statuses", http.StatusSeeOther)
}
func (a *App) handleStatusReorder(w http.ResponseWriter, r *http.Request) {
@@ -152,5 +155,5 @@ func (a *App) handleStatusReorder(w http.ResponseWriter, r *http.Request) {
}
}
}
- http.Redirect(w, r, "/settings", http.StatusSeeOther)
+ http.Redirect(w, r, "/settings/statuses", http.StatusSeeOther)
}
diff --git a/internal/web/router.go b/internal/web/router.go
index 700ac6c..254a24b 100644
--- a/internal/web/router.go
+++ b/internal/web/router.go
@@ -64,6 +64,7 @@ func (a *App) Router() http.Handler {
// sub-navigation, rather than as separate top-level nav items.
mux.HandleFunc("GET /settings", a.requireAdmin(a.handleSettingsPage))
mux.HandleFunc("POST /settings/general", a.requireAdmin(a.handleSettingsGeneral))
+ mux.HandleFunc("GET /settings/statuses", a.requireAdmin(a.handleSettingsStatusesPage))
mux.HandleFunc("POST /settings/statuses", a.requireAdmin(a.handleStatusCreate))
mux.HandleFunc("POST /settings/statuses/{id}", a.requireAdmin(a.handleStatusUpdate))
mux.HandleFunc("POST /settings/statuses/{id}/delete", a.requireAdmin(a.handleStatusDelete))
diff --git a/web/static/js/app.js b/web/static/js/app.js
index a572c3e..6a2dcf9 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -14,7 +14,6 @@
var mainPanel = document.getElementById('main-panel');
var isAdmin = document.body.classList.contains('role-admin');
var draggedNode = null;
- var formReturnContext = { type: 'root', id: '' };
// ---------------------------------------------------------------------
// Generic helpers
@@ -514,33 +513,32 @@
}
// ---------------------------------------------------------------------
- // Asset form (create / edit)
+ // Asset form (create / edit) - shown in a modal
// ---------------------------------------------------------------------
+ var assetModalEl = document.getElementById('assetModal');
+ var assetModal = (assetModalEl && window.bootstrap) ? new bootstrap.Modal(assetModalEl) : null;
+
+ function showAssetModal(html) {
+ if (!assetModal) return;
+ document.getElementById('assetModalBody').innerHTML = html;
+ var form = document.getElementById('assetForm');
+ document.getElementById('assetModalTitle').textContent = form.dataset.title;
+ document.getElementById('assetModalSubmit').textContent = form.dataset.mode === 'edit' ? 'Save Changes' : 'Create Asset';
+ assetModal.show();
+ }
+
async function loadAssetForm(opts) {
- 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 = '';
+ showAssetModal(html);
}
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);
- }
+ showAssetModal(html);
}
async function submitAssetForm(form) {
@@ -569,6 +567,7 @@
var method = mode === 'edit' ? 'PATCH' : 'POST';
try {
var res = await apiFetch(url, { method: method, json: payload });
+ if (assetModal) assetModal.hide();
await refreshWholeTree();
await navigateAndSelect('asset', res.id);
} catch (err) {
@@ -779,18 +778,9 @@
var pageLink = e.target.closest('.panel-page-link');
if (pageLink) { e.preventDefault(); loadFolderPanelWithFilters(pageLink.dataset.folderId, { page: pageLink.dataset.page }); 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;
- }
+ mainPanel.addEventListener('change', function (e) {
var filterForm = e.target.closest('.panel-filter-form');
if (filterForm) {
loadFolderPanelWithFilters(filterForm.dataset.folderId, { page: 1 });
@@ -810,11 +800,6 @@
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);
}
});
@@ -822,6 +807,25 @@
// onto the tree only; the tree pane is the primary drag surface.
}
+ // The asset create/edit form lives inside #assetModal (injected fresh
+ // each time it's opened), so its dynamic bits are delegated from the
+ // modal element itself rather than from #main-panel.
+ if (assetModalEl) {
+ assetModalEl.addEventListener('change', async function (e) {
+ if (e.target.id === 'assetFormItemType') {
+ var html = await fetchText('/app/forms/asset-fields?item_type_id=' + e.target.value);
+ var container = document.getElementById('asset-fields-container');
+ if (container) container.innerHTML = html;
+ }
+ });
+ assetModalEl.addEventListener('submit', function (e) {
+ if (e.target.id === 'assetForm') {
+ e.preventDefault();
+ submitAssetForm(e.target);
+ }
+ });
+ }
+
// ---------------------------------------------------------------------
// Item type custom field admin form (item-types/{id}/edit page)
// ---------------------------------------------------------------------
diff --git a/web/templates/pages/inventory.html b/web/templates/pages/inventory.html
index 54eb491..8c56741 100644
--- a/web/templates/pages/inventory.html
+++ b/web/templates/pages/inventory.html
@@ -29,7 +29,7 @@
| Order | Preview | Name & Color | |
|---|---|---|---|
|
+ |
+ {{$st.Name}} | +
+ |
+
+ |
+
No folders yet. Right-click here (or the tree) to create one.
- {{end}} - - {{if .UnfiledAssets}} -| Asset ID | Name | Type | Status | Assigned |
|---|---|---|---|---|
| {{.AssetTag}} | -{{.Name}} | -{{.ItemTypeName}} | -{{.StatusName}} | -{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}—{{end}} | -
Select a folder or asset from the tree.