Blank root panel, asset form as a modal, and a dedicated Statuses page

- Main panel is now blank at the top level ("Select a folder or asset
  from the tree") instead of showing a dashboard - browsing happens in
  the tree, the panel only shows something once you click into it.
  Removed the now-dead DashboardStats/Stats() machinery it depended on.
- Add/Edit Asset no longer takes over the main panel; it opens in
  #assetModal instead; asset_form.html was trimmed down to just the form
  (no more breadcrumb/panel wrapper) since the modal chrome provides the
  title and footer buttons now. The dynamic item-type-fields and submit
  listeners moved from #main-panel's delegation to the modal element's.
- Statuses split out of the General settings page into its own
  /settings/statuses page and sub-nav entry, alongside General, Item
  Types, and Users.
This commit is contained in:
Owen
2026-08-17 15:45:38 -05:00
parent 4d7ebed68e
commit 8d9106542c
12 changed files with 254 additions and 436 deletions
-81
View File
@@ -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()
}
+7 -40
View File
@@ -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.
+33 -30
View File
@@ -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)
}
+1
View File
@@ -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))