Statuses import, an icon picker, folder-scoped CSV import, and site name

- Statuses can now be bulk-imported/updated via CSV (Name, Color columns;
  matches and updates existing statuses by name). New
  internal/statuses/csv.go, tested.
- Icon fields (folder create/rename, item type form) are now a searchable
  dropdown of the 50 most common Bootstrap Icons instead of a free-text
  box, via a small reusable .icon-picker widget in app.js/app.css.
- CSV asset import is now folder-scoped when triggered from the tree:
  right-click a folder (or its background) > Import CSV... imports every
  row directly into that folder, so the CSV needs no Folder column at
  all, and the option no longer appears at the top level. The original
  global, Folder-path-aware import (with auto-create-missing-folders)
  moved to a new admin-only Settings > Import / Export page, alongside
  the new Statuses import and format examples for both. assets.Import
  gained ImportOptions.TargetFolderID to drive the folder-scoped mode.
- Site Name (Settings > General) now actually renders in the navbar
  brand and <title>, via a per-App "siteName" template func bound in
  loadTemplates. The brand is plain text now, not a link to /inventory.
This commit is contained in:
Owen
2026-08-17 15:57:59 -05:00
parent 8d9106542c
commit 42e7b2a21c
16 changed files with 657 additions and 39 deletions
+10 -2
View File
@@ -25,6 +25,13 @@ const (
// ImportOptions controls CSV import behavior. // ImportOptions controls CSV import behavior.
type ImportOptions struct { type ImportOptions struct {
CreateMissingFolders bool CreateMissingFolders bool
// TargetFolderID, when set, imports every row directly into this
// folder and ignores any Folder column in the CSV entirely (used for
// the folder-scoped "import here" context menu action). When nil,
// each row's folder is resolved from its Folder column instead
// (global import), optionally creating missing folders.
TargetFolderID *int64
} }
type ImportResult struct { type ImportResult struct {
@@ -157,8 +164,9 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
} }
var folderID *int64 var folderID *int64
folderPath := get(row, colFolder) if opts.TargetFolderID != nil {
if folderPath != "" { folderID = opts.TargetFolderID
} else if folderPath := get(row, colFolder); folderPath != "" {
id, err := s.resolveFolderPath(ctx, lk, folderPath, opts.CreateMissingFolders) id, err := s.resolveFolderPath(ctx, lk, folderPath, opts.CreateMissingFolders)
if err != nil { if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: folder %q: %v", rowNum, folderPath, err)) result.Errors = append(result.Errors, fmt.Sprintf("row %d: folder %q: %v", rowNum, folderPath, err))
+99
View File
@@ -0,0 +1,99 @@
package statuses
import (
"context"
"encoding/csv"
"fmt"
"io"
"strings"
"inventree/internal/models"
)
type ImportResult struct {
Created int
Updated int
Errors []string
}
// Import creates or updates statuses from CSV data with "Name" and "Color"
// columns (case-insensitive header names, Color optional). Existing
// statuses are matched and updated by name (case-insensitive); everything
// else is created.
func (s *Store) Import(ctx context.Context, r io.Reader) (*ImportResult, error) {
existing, err := s.List(ctx)
if err != nil {
return nil, err
}
byName := map[string]int64{}
for _, st := range existing {
byName[strings.ToLower(st.Name)] = st.ID
}
cr := csv.NewReader(r)
cr.FieldsPerRecord = -1
header, err := cr.Read()
if err != nil {
if err == io.EOF {
return &ImportResult{}, nil
}
return nil, fmt.Errorf("read CSV header: %w", err)
}
colIdx := map[string]int{}
for i, h := range header {
colIdx[strings.ToLower(strings.TrimSpace(h))] = i
}
get := func(row []string, key string) string {
idx, ok := colIdx[key]
if !ok || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}
result := &ImportResult{}
rowNum := 1
for {
row, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read CSV row %d: %w", rowNum, err)
}
rowNum++
name := get(row, "name")
if name == "" {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: name is required, skipped", rowNum))
continue
}
color := get(row, "color")
if id, ok := byName[strings.ToLower(name)]; ok {
st, err := s.Get(ctx, id)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %v", rowNum, err))
continue
}
if color != "" {
st.Color = color
}
if err := s.Update(ctx, st); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: update failed: %v", rowNum, err))
continue
}
result.Updated++
continue
}
st := &models.Status{Name: name, Color: color}
if err := s.Create(ctx, st); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: create failed: %v", rowNum, err))
continue
}
byName[strings.ToLower(name)] = st.ID
result.Created++
}
return result, nil
}
+76
View File
@@ -0,0 +1,76 @@
package statuses_test
import (
"context"
"database/sql"
"path/filepath"
"strings"
"testing"
"inventree/internal/database"
"inventree/internal/statuses"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
dir := t.TempDir()
db, err := database.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestImportCreatesAndUpdatesByName(t *testing.T) {
ctx := context.Background()
s := statuses.NewStore(testDB(t))
// "In Use" already exists from seed data; this should update its
// color rather than create a duplicate, and create "Awaiting Parts".
csvData := "Name,Color\nIn Use,primary\nAwaiting Parts,warning\n"
result, err := s.Import(ctx, strings.NewReader(csvData))
if err != nil {
t.Fatalf("import: %v", err)
}
if result.Created != 1 || result.Updated != 1 {
t.Fatalf("expected 1 created, 1 updated, got %+v", result)
}
list, err := s.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
var foundInUse, foundAwaiting bool
for _, st := range list {
if st.Name == "In Use" {
foundInUse = true
if st.Color != "primary" {
t.Errorf("expected In Use color updated to primary, got %s", st.Color)
}
}
if st.Name == "Awaiting Parts" {
foundAwaiting = true
if st.Color != "warning" {
t.Errorf("expected Awaiting Parts color warning, got %s", st.Color)
}
}
}
if !foundInUse || !foundAwaiting {
t.Fatalf("expected both statuses present, got %+v", list)
}
}
func TestImportSkipsRowsMissingName(t *testing.T) {
ctx := context.Background()
s := statuses.NewStore(testDB(t))
csvData := "Name,Color\n,primary\n"
result, err := s.Import(ctx, strings.NewReader(csvData))
if err != nil {
t.Fatalf("import: %v", err)
}
if result.Created != 0 || len(result.Errors) != 1 {
t.Fatalf("expected 0 created and 1 error, got %+v", result)
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ func NewApp(db *sql.DB, log *slog.Logger, secureCookies bool) (*App, error) {
SecureCookies: secureCookies, SecureCookies: secureCookies,
} }
tmpl, err := loadTemplates() tmpl, err := loadTemplates(app)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+10 -2
View File
@@ -30,6 +30,11 @@ func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
} }
} }
// handleImportCSV backs the folder-scoped "Import CSV..." context-menu
// action: every row imports directly into the given folder_id, so the CSV
// needs no Folder column at all. It's only ever invoked from within a
// specific folder (never at the top level) and responds with JSON since
// it's called via fetch from the tree/panel context menu.
func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) { func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
if err := r.ParseMultipartForm(32 << 20); err != nil { if err := r.ParseMultipartForm(32 << 20); err != nil {
@@ -43,7 +48,10 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
} }
defer file.Close() defer file.Close()
createMissing := r.FormValue("create_missing_folders") == "1" opts := assets.ImportOptions{}
if folderID, err := formOptionalID(r, "folder_id"); err == nil && folderID != nil {
opts.TargetFolderID = folderID
}
itemTypes, err := a.ItemTypes.List(ctx, true) itemTypes, err := a.ItemTypes.List(ctx, true)
if err != nil { if err != nil {
@@ -61,7 +69,7 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
return return
} }
result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList) result, err := a.Assets.Import(ctx, file, opts, itemTypes, statusList, userList)
if err != nil { if err != nil {
jsonErr(w, http.StatusBadRequest, err) jsonErr(w, http.StatusBadRequest, err)
return return
+95
View File
@@ -0,0 +1,95 @@
package web
import (
"fmt"
"net/http"
"net/url"
"inventree/internal/assets"
"inventree/internal/models"
)
type settingsImportPageData struct {
User *models.User
Kind string
Imported int
Updated int
Errs int
Error string
}
// handleSettingsImportPage backs the admin "Import / Export" settings page:
// global CSV import for assets (with a Folder column, optionally
// auto-creating missing folders) and for statuses, plus format examples.
// This is distinct from the folder-scoped "Import CSV..." context-menu
// action in the tree, which never needs a Folder column.
func (a *App) handleSettingsImportPage(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
a.Render(w, http.StatusOK, "settings_import", settingsImportPageData{
User: currentUser(r),
Kind: q.Get("kind"),
Imported: queryInt(r, "imported", 0),
Updated: queryInt(r, "updated", 0),
Errs: queryInt(r, "errs", 0),
Error: q.Get("error"),
})
}
func (a *App) handleSettingsImportAssets(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
defer file.Close()
createMissing := r.FormValue("create_missing_folders") == "1"
itemTypes, err := a.ItemTypes.List(ctx, true)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
statusList, err := a.Statuses.List(ctx)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
userList, err := a.Users.List(ctx)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, fmt.Sprintf("/settings/import?kind=assets&imported=%d&updated=%d&errs=%d", result.Created, result.Updated, len(result.Errors)), http.StatusSeeOther)
}
func (a *App) handleSettingsImportStatuses(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
defer file.Close()
result, err := a.Statuses.Import(r.Context(), file)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, fmt.Sprintf("/settings/import?kind=statuses&imported=%d&updated=%d&errs=%d", result.Created, result.Updated, len(result.Errors)), http.StatusSeeOther)
}
+14
View File
@@ -25,6 +25,20 @@ func queryOptionalID(r *http.Request, name string) (*int64, error) {
return &id, nil return &id, nil
} }
// formOptionalID parses a POST form/multipart-form field (already parsed via
// ParseForm/ParseMultipartForm) as *int64; empty or missing returns nil.
func formOptionalID(r *http.Request, name string) (*int64, error) {
v := r.FormValue(name)
if v == "" {
return nil, nil
}
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, err
}
return &id, nil
}
func queryInt64(r *http.Request, name string) int64 { func queryInt64(r *http.Request, name string) int64 {
v := r.URL.Query().Get(name) v := r.URL.Query().Get(name)
if v == "" { if v == "" {
+17 -3
View File
@@ -1,6 +1,7 @@
package web package web
import ( import (
"context"
"fmt" "fmt"
"html/template" "html/template"
"io/fs" "io/fs"
@@ -94,8 +95,21 @@ type templateSet struct {
// each page can define a block named "content" without colliding with the // each page can define a block named "content" without colliding with the
// others (html/template templates sharing one *template.Template must have // others (html/template templates sharing one *template.Template must have
// unique names, but isolated clones may reuse the same name freely). // unique names, but isolated clones may reuse the same name freely).
func loadTemplates() (*templateSet, error) { func loadTemplates(a *App) (*templateSet, error) {
shared := template.New("root").Funcs(templateFuncs) // siteName is bound per-App (rather than living in the package-level
// templateFuncs) since it needs a live DB read through a.Settings; it's
// used by layout.html for the <title> and navbar brand text.
appFuncs := template.FuncMap{
"siteName": func() string {
name, err := a.Settings.Get(context.Background(), "site_name", "inventree")
if err != nil || name == "" {
return "inventree"
}
return name
},
}
shared := template.New("root").Funcs(templateFuncs).Funcs(appFuncs)
shared, err := shared.ParseFS(content.TemplatesFS, "templates/layout.html") shared, err := shared.ParseFS(content.TemplatesFS, "templates/layout.html")
if err != nil { if err != nil {
@@ -124,7 +138,7 @@ func loadTemplates() (*templateSet, error) {
pages[name] = clone pages[name] = clone
} }
login := template.New("login-root").Funcs(templateFuncs) login := template.New("login-root").Funcs(templateFuncs).Funcs(appFuncs)
login, err = login.ParseFS(content.TemplatesFS, "templates/login.html") login, err = login.ParseFS(content.TemplatesFS, "templates/login.html")
if err != nil { if err != nil {
return nil, fmt.Errorf("parse login: %w", err) return nil, fmt.Errorf("parse login: %w", err)
+4
View File
@@ -80,6 +80,10 @@ func (a *App) Router() http.Handler {
mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}", a.requireAdmin(a.handleFieldUpdate)) mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}", a.requireAdmin(a.handleFieldUpdate))
mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}/delete", a.requireAdmin(a.handleFieldDelete)) mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}/delete", a.requireAdmin(a.handleFieldDelete))
mux.HandleFunc("GET /settings/import", a.requireAdmin(a.handleSettingsImportPage))
mux.HandleFunc("POST /settings/import/assets", a.requireAdmin(a.handleSettingsImportAssets))
mux.HandleFunc("POST /settings/import/statuses", a.requireAdmin(a.handleSettingsImportStatuses))
mux.HandleFunc("GET /settings/users", a.requireAdmin(a.handleUsersList)) mux.HandleFunc("GET /settings/users", a.requireAdmin(a.handleUsersList))
mux.HandleFunc("GET /settings/users/new", a.requireAdmin(a.handleUserNewForm)) mux.HandleFunc("GET /settings/users/new", a.requireAdmin(a.handleUserNewForm))
mux.HandleFunc("POST /settings/users", a.requireAdmin(a.handleUserCreate)) mux.HandleFunc("POST /settings/users", a.requireAdmin(a.handleUserCreate))
+77 -9
View File
@@ -154,23 +154,91 @@ ul.tree-children {
cursor: default; cursor: default;
} }
.stat-card .card-body { .panel-content {
text-align: center; max-width: 1100px;
} }
.stat-value { .panel-blank {
font-size: 1.4rem; max-width: none;
font-weight: 600;
line-height: 1.2;
} }
.stat-label { /* Icon picker: a searchable dropdown of common Bootstrap Icons, standing
in for a free-text icon class field. */
.icon-picker {
position: relative;
}
.icon-picker-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
text-align: left;
}
.icon-picker-preview {
font-size: 1.1rem;
width: 1.1rem;
flex-shrink: 0;
}
.icon-picker-label {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-picker-caret {
flex-shrink: 0;
font-size: 0.75rem; font-size: 0.75rem;
color: #6c757d; color: #6c757d;
} }
.panel-content { .icon-picker-menu {
max-width: 1100px; display: none;
position: absolute;
z-index: 1085;
top: 100%;
left: 0;
margin-top: 0.25rem;
width: 280px;
max-width: 90vw;
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
padding: 0.5rem;
}
.icon-picker-menu.show {
display: block;
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.25rem;
max-height: 190px;
overflow-y: auto;
margin-top: 0.5rem;
}
.icon-picker-item {
border: 1px solid transparent;
background: none;
border-radius: 0.25rem;
padding: 0.4rem;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
color: #212529;
}
.icon-picker-item:hover,
.icon-picker-item:focus {
background-color: #f1f3f5;
border-color: rgba(0, 0, 0, 0.1);
} }
.asset-row, .folder-link, .asset-link { .asset-row, .folder-link, .asset-link {
+118 -6
View File
@@ -240,6 +240,7 @@
items.push({ divider: true }); items.push({ divider: true });
items.push({ label: 'Rename', icon: 'bi-pencil', action: function () { openRenameFolder(id); } }); items.push({ label: 'Rename', icon: 'bi-pencil', action: function () { openRenameFolder(id); } });
items.push({ divider: true }); items.push({ divider: true });
items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(id); } });
} }
items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?folder_id=' + id + '&recursive=1'; } }); items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?folder_id=' + id + '&recursive=1'; } });
if (isAdmin) { if (isAdmin) {
@@ -261,6 +262,9 @@
return items; return items;
} }
// Import is intentionally folder-scoped only (see openImportModal) - it
// never appears at the top level, since a global admin import with
// folder-path resolution lives under Settings > Import / Export instead.
function treeBackgroundMenuItems() { function treeBackgroundMenuItems() {
var items = []; var items = [];
if (isAdmin) { if (isAdmin) {
@@ -271,9 +275,6 @@
items.push({ label: 'Expand All', icon: 'bi-arrows-expand', action: expandAllHandler }); items.push({ label: 'Expand All', icon: 'bi-arrows-expand', action: expandAllHandler });
items.push({ label: 'Collapse All', icon: 'bi-arrows-collapse', action: collapseAllHandler }); items.push({ label: 'Collapse All', icon: 'bi-arrows-collapse', action: collapseAllHandler });
items.push({ divider: true }); items.push({ divider: true });
if (isAdmin) {
items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { if (importModal) importModal.show(); } });
}
items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv'; } }); items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv'; } });
return items; return items;
} }
@@ -283,10 +284,13 @@
if (isAdmin) { if (isAdmin) {
items.push({ label: folderId ? 'New Subfolder' : 'New Folder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: folderId }); } }); items.push({ label: folderId ? 'New Subfolder' : 'New Folder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: folderId }); } });
items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: folderId }); } }); items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: folderId }); } });
if (folderId) {
items.push({ divider: true }); items.push({ divider: true });
items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { if (importModal) importModal.show(); } }); items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(folderId); } });
}
} }
var exportUrl = folderId ? ('/export/assets.csv?folder_id=' + folderId + '&recursive=1') : '/export/assets.csv'; var exportUrl = folderId ? ('/export/assets.csv?folder_id=' + folderId + '&recursive=1') : '/export/assets.csv';
items.push({ divider: true });
items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = exportUrl; } }); items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = exportUrl; } });
return items; return items;
} }
@@ -344,6 +348,105 @@
}); });
} }
// ---------------------------------------------------------------------
// Icon picker: a searchable dropdown of common Bootstrap Icons standing
// in for a free-text icon-class input (used by the folder modal and the
// item type form).
// ---------------------------------------------------------------------
var COMMON_ICONS = [
'bi-folder-fill', 'bi-folder', 'bi-archive', 'bi-box-seam', 'bi-boxes',
'bi-laptop', 'bi-pc-display', 'bi-pc', 'bi-display', 'bi-tv', 'bi-projector',
'bi-phone', 'bi-telephone', 'bi-tablet', 'bi-smartwatch',
'bi-camera-video', 'bi-camera', 'bi-printer', 'bi-keyboard', 'bi-mouse',
'bi-hdd', 'bi-hdd-rack', 'bi-hdd-stack', 'bi-server', 'bi-cpu', 'bi-memory',
'bi-disc', 'bi-usb-drive', 'bi-usb-plug', 'bi-ethernet', 'bi-router', 'bi-wifi',
'bi-shield-lock', 'bi-shield-check', 'bi-lock', 'bi-unlock', 'bi-plug',
'bi-battery', 'bi-battery-charging', 'bi-gear', 'bi-gear-fill', 'bi-tools',
'bi-wrench', 'bi-tag', 'bi-tags', 'bi-bookmark', 'bi-house-door-fill',
'bi-building', 'bi-person', 'bi-people',
];
function setIconPickerValue(input, val) {
input.value = val || '';
var container = input.closest('.icon-picker');
if (!container) return;
var preview = container.querySelector('.icon-picker-preview');
var label = container.querySelector('.icon-picker-label');
if (val) {
preview.className = 'bi ' + val + ' icon-picker-preview';
label.textContent = val;
label.classList.remove('text-body-secondary');
} else {
preview.className = 'bi icon-picker-preview';
label.textContent = 'Choose icon…';
label.classList.add('text-body-secondary');
}
}
function closeAllIconPickers() {
document.querySelectorAll('.icon-picker-menu.show').forEach(function (m) { m.classList.remove('show'); });
}
function initIconPickers() {
document.querySelectorAll('[data-icon-picker]').forEach(function (container) {
if (container.dataset.iconPickerInit) return;
container.dataset.iconPickerInit = '1';
var input = container.querySelector('input[type="hidden"]');
var toggle = container.querySelector('.icon-picker-toggle');
var menu = container.querySelector('.icon-picker-menu');
var search = container.querySelector('.icon-picker-search');
var grid = container.querySelector('.icon-picker-grid');
var clearBtn = container.querySelector('.icon-picker-clear');
function renderGrid(filter) {
filter = (filter || '').toLowerCase();
var list = COMMON_ICONS.filter(function (ic) { return !filter || ic.indexOf(filter) !== -1; });
grid.innerHTML = list.map(function (ic) {
return '<button type="button" class="icon-picker-item" data-icon="' + ic + '" title="' + ic + '"><i class="bi ' + ic + '"></i></button>';
}).join('') || '<div class="text-body-secondary small p-2">No matches</div>';
}
toggle.addEventListener('click', function (e) {
e.stopPropagation();
var isOpen = menu.classList.contains('show');
closeAllIconPickers();
if (!isOpen) {
menu.classList.add('show');
search.value = '';
renderGrid('');
search.focus();
}
});
search.addEventListener('input', function () { renderGrid(search.value.trim()); });
grid.addEventListener('click', function (e) {
var btn = e.target.closest('.icon-picker-item');
if (!btn) return;
setIconPickerValue(input, btn.dataset.icon);
menu.classList.remove('show');
});
if (clearBtn) {
clearBtn.addEventListener('click', function () {
setIconPickerValue(input, '');
menu.classList.remove('show');
});
}
renderGrid('');
setIconPickerValue(input, input.value);
});
}
document.addEventListener('click', function (e) {
if (!e.target.closest('.icon-picker')) closeAllIconPickers();
});
initIconPickers();
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Folder create / rename modal // Folder create / rename modal
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -357,7 +460,7 @@
document.getElementById('folderFormParentId').value = (opts && opts.parentId) || ''; document.getElementById('folderFormParentId').value = (opts && opts.parentId) || '';
document.getElementById('folderFormName').value = ''; document.getElementById('folderFormName').value = '';
document.getElementById('folderFormDescription').value = ''; document.getElementById('folderFormDescription').value = '';
document.getElementById('folderFormIcon').value = ''; setIconPickerValue(document.getElementById('folderFormIcon'), '');
document.getElementById('folderFormSortOrder').value = '0'; document.getElementById('folderFormSortOrder').value = '0';
document.getElementById('folderModalTitle').textContent = 'New Folder'; document.getElementById('folderModalTitle').textContent = 'New Folder';
document.getElementById('folderFormError').classList.add('d-none'); document.getElementById('folderFormError').classList.add('d-none');
@@ -372,7 +475,7 @@
document.getElementById('folderFormParentId').value = ''; document.getElementById('folderFormParentId').value = '';
document.getElementById('folderFormName').value = f.Name; document.getElementById('folderFormName').value = f.Name;
document.getElementById('folderFormDescription').value = f.Description; document.getElementById('folderFormDescription').value = f.Description;
document.getElementById('folderFormIcon').value = f.Icon; setIconPickerValue(document.getElementById('folderFormIcon'), f.Icon);
document.getElementById('folderFormSortOrder').value = f.SortOrder; document.getElementById('folderFormSortOrder').value = f.SortOrder;
document.getElementById('folderModalTitle').textContent = 'Rename Folder'; document.getElementById('folderModalTitle').textContent = 'Rename Folder';
document.getElementById('folderFormError').classList.add('d-none'); document.getElementById('folderFormError').classList.add('d-none');
@@ -658,6 +761,15 @@
var importModalEl = document.getElementById('importModal'); var importModalEl = document.getElementById('importModal');
var importModal = (importModalEl && window.bootstrap) ? new bootstrap.Modal(importModalEl) : null; var importModal = (importModalEl && window.bootstrap) ? new bootstrap.Modal(importModalEl) : null;
function openImportModal(folderId) {
if (!importModal) return;
document.getElementById('importFolderId').value = folderId || '';
document.getElementById('importResult').innerHTML = '';
var fileInput = document.getElementById('importFile');
if (fileInput) fileInput.value = '';
importModal.show();
}
var importFormEl = document.getElementById('importForm'); var importFormEl = document.getElementById('importForm');
if (importFormEl) { if (importFormEl) {
importFormEl.addEventListener('submit', async function (e) { importFormEl.addEventListener('submit', async function (e) {
+4 -4
View File
@@ -3,16 +3,16 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>inventree</title> <title>{{siteName}}</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css"> <link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css"> <link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
</head> </head>
<body class="role-{{.User.Role}}"> <body class="role-{{.User.Role}}">
<nav class="navbar navbar-expand-lg app-navbar border-bottom px-3"> <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"> <span class="navbar-brand fw-semibold d-flex align-items-center gap-2 mb-0">
<i class="bi bi-diagram-3-fill"></i> inventree <i class="bi bi-diagram-3-fill"></i> {{siteName}}
</a> </span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
+14 -2
View File
@@ -22,8 +22,20 @@
<input type="text" class="form-control" name="name" value="{{if .ItemType}}{{.ItemType.Name}}{{end}}" required> <input type="text" class="form-control" name="name" value="{{if .ItemType}}{{.ItemType.Name}}{{end}}" required>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class)</span></label> <label class="form-label">Icon</label>
<input type="text" class="form-control" name="icon" value="{{if .ItemType}}{{.ItemType.Icon}}{{end}}" placeholder="bi-box-seam"> <div class="icon-picker" data-icon-picker>
<input type="hidden" name="icon" value="{{if .ItemType}}{{.ItemType.Icon}}{{end}}">
<button type="button" class="btn btn-outline-secondary icon-picker-toggle w-100">
<i class="bi icon-picker-preview"></i>
<span class="icon-picker-label text-body-secondary">Choose icon&hellip;</span>
<i class="bi bi-chevron-down icon-picker-caret"></i>
</button>
<div class="icon-picker-menu">
<input type="search" class="form-control form-control-sm icon-picker-search" placeholder="Search icons&hellip;">
<div class="icon-picker-grid"></div>
<button type="button" class="btn btn-sm btn-link p-0 mt-2 icon-picker-clear">Clear</button>
</div>
</div>
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Description</label> <label class="form-label">Description</label>
+94
View File
@@ -0,0 +1,94 @@
{{define "content"}}
<div class="container-fluid p-3">
<h4 class="mb-3">Settings</h4>
<div class="row g-3">
<div class="col-md-3 col-lg-2">
{{template "settings_nav" "import"}}
</div>
<div class="col-md-9 col-lg-10" style="max-width: 900px">
{{if .Error}}
<div class="alert alert-danger">{{.Error}}</div>
{{else if eq .Kind "assets"}}
<div class="alert alert-success">Imported {{.Imported}} asset(s), updated {{.Updated}}.{{if .Errs}} {{.Errs}} row(s) had warnings.{{end}}</div>
{{else if eq .Kind "statuses"}}
<div class="alert alert-success">Imported {{.Imported}} status(es), updated {{.Updated}}.{{if .Errs}} {{.Errs}} row(s) had warnings.{{end}}</div>
{{end}}
<p class="text-body-secondary">
This page is for bulk, admin-level imports that can reference any folder path (and create missing
folders along the way). To import assets straight into one specific folder without a Folder column,
right-click that folder in the Inventory tree and choose <strong>Import CSV&hellip;</strong> instead.
</p>
<div class="card mb-4">
<div class="card-header py-2 fw-semibold">Import Assets</div>
<div class="card-body">
<form method="post" action="/settings/import/assets" enctype="multipart/form-data" class="mb-3">
<div class="mb-3">
<label class="form-label">CSV File</label>
<input type="file" class="form-control" name="file" accept=".csv" required>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="create_missing_folders" id="createMissingFolders" value="1" checked>
<label class="form-check-label" for="createMissingFolders">Create missing folders referenced in the Folder column</label>
</div>
<button type="submit" class="btn btn-primary">Import Assets</button>
</form>
<p class="mb-1 fw-semibold small">Format</p>
<p class="text-body-secondary small mb-2">
Required: <code>Name</code>, <code>Item Type</code>, <code>Status</code>. Optional:
<code>Asset ID</code> (auto-generated if blank; matches an existing asset to update it),
<code>Folder</code> (a <code>/</code>-separated path such as <code>Main Office/Network</code>),
<code>Assigned User</code>, <code>Notes</code>, and one <code>cf_&lt;field_key&gt;</code> column
per custom field (e.g. <code>cf_serial_number</code>).
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0 small">
<thead>
<tr><th>Asset ID</th><th>Name</th><th>Item Type</th><th>Folder</th><th>Status</th><th>Assigned User</th><th>Notes</th><th>cf_serial_number</th></tr>
</thead>
<tbody>
<tr><td></td><td>Core Switch</td><td>Switch</td><td>Main Office/Network</td><td>In Use</td><td></td><td></td><td>SN-12345</td></tr>
<tr><td>IT-000042</td><td>Front Desk PC</td><td>Desktop</td><td>Main Office/Workstations</td><td>In Use</td><td>jdoe</td><td>Loaner unit</td><td></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header py-2 fw-semibold">Import Statuses</div>
<div class="card-body">
<form method="post" action="/settings/import/statuses" enctype="multipart/form-data" class="mb-3">
<div class="mb-3">
<label class="form-label">CSV File</label>
<input type="file" class="form-control" name="file" accept=".csv" required>
</div>
<button type="submit" class="btn btn-primary">Import Statuses</button>
</form>
<p class="mb-1 fw-semibold small">Format</p>
<p class="text-body-secondary small mb-2">
Required: <code>Name</code>. Optional: <code>Color</code> (any Bootstrap color name -
<code>primary</code>, <code>secondary</code>, <code>success</code>, <code>danger</code>,
<code>warning</code>, <code>info</code>, or <code>dark</code>; defaults to <code>secondary</code>).
A row whose name matches an existing status updates its color instead of creating a duplicate.
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0 small">
<thead><tr><th>Name</th><th>Color</th></tr></thead>
<tbody>
<tr><td>In Use</td><td>success</td></tr>
<tr><td>Awaiting Parts</td><td>warning</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
+20 -9
View File
@@ -22,8 +22,20 @@
</div> </div>
<div class="row g-2"> <div class="row g-2">
<div class="col"> <div class="col">
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class, optional)</span></label> <label class="form-label">Icon <span class="text-body-secondary">(optional)</span></label>
<input type="text" class="form-control" name="icon" id="folderFormIcon" placeholder="bi-folder-fill"> <div class="icon-picker" data-icon-picker>
<input type="hidden" name="icon" id="folderFormIcon" value="">
<button type="button" class="btn btn-outline-secondary icon-picker-toggle w-100">
<i class="bi icon-picker-preview"></i>
<span class="icon-picker-label text-body-secondary">Choose icon&hellip;</span>
<i class="bi bi-chevron-down icon-picker-caret"></i>
</button>
<div class="icon-picker-menu">
<input type="search" class="form-control form-control-sm icon-picker-search" placeholder="Search icons&hellip;">
<div class="icon-picker-grid"></div>
<button type="button" class="btn btn-sm btn-link p-0 mt-2 icon-picker-clear">Clear</button>
</div>
</div>
</div> </div>
<div class="col-4"> <div class="col-4">
<label class="form-label">Sort Order</label> <label class="form-label">Sort Order</label>
@@ -116,26 +128,25 @@
</div> </div>
</div> </div>
<!-- Import CSV --> <!-- Import CSV into a specific folder (right-click a folder > Import CSV...) -->
<div class="modal fade" id="importModal" tabindex="-1"> <div class="modal fade" id="importModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<form id="importForm"> <form id="importForm">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Import Assets from CSV</h5> <h5 class="modal-title" id="importModalTitle">Import CSV</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="folder_id" id="importFolderId">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">CSV File</label> <label class="form-label">CSV File</label>
<input type="file" class="form-control" name="file" id="importFile" accept=".csv" required> <input type="file" class="form-control" name="file" id="importFile" accept=".csv" required>
</div> </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"> <p class="text-body-secondary small mb-0">
Columns: Asset ID, Name, Item Type, Folder, Status, Assigned User, Notes, plus <code>cf_&lt;field_key&gt;</code> for custom fields. Every row imports into this folder. Columns: <code>Name</code>, <code>Item Type</code>,
<code>Status</code>. Optional: <code>Asset ID</code>, <code>Assigned User</code>, <code>Notes</code>,
plus <code>cf_&lt;field_key&gt;</code> for custom fields. No Folder column needed.
</p> </p>
<div id="importResult" class="mt-3"></div> <div id="importResult" class="mt-3"></div>
</div> </div>
+3
View File
@@ -12,5 +12,8 @@
<a href="/settings/users" class="list-group-item list-group-item-action{{if eq . "users"}} active{{end}}"> <a href="/settings/users" class="list-group-item list-group-item-action{{if eq . "users"}} active{{end}}">
<i class="bi bi-people me-2"></i>Users <i class="bi bi-people me-2"></i>Users
</a> </a>
<a href="/settings/import" class="list-group-item list-group-item-action{{if eq . "import"}} active{{end}}">
<i class="bi bi-upload me-2"></i>Import / Export
</a>
</div> </div>
{{end}} {{end}}