diff --git a/internal/assets/csv.go b/internal/assets/csv.go index c666144..93cef71 100644 --- a/internal/assets/csv.go +++ b/internal/assets/csv.go @@ -25,6 +25,13 @@ const ( // ImportOptions controls CSV import behavior. type ImportOptions struct { 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 { @@ -157,8 +164,9 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions, } var folderID *int64 - folderPath := get(row, colFolder) - if folderPath != "" { + if opts.TargetFolderID != nil { + folderID = opts.TargetFolderID + } else if folderPath := get(row, colFolder); folderPath != "" { id, err := s.resolveFolderPath(ctx, lk, folderPath, opts.CreateMissingFolders) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("row %d: folder %q: %v", rowNum, folderPath, err)) diff --git a/internal/statuses/csv.go b/internal/statuses/csv.go new file mode 100644 index 0000000..41beb62 --- /dev/null +++ b/internal/statuses/csv.go @@ -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 +} diff --git a/internal/statuses/csv_test.go b/internal/statuses/csv_test.go new file mode 100644 index 0000000..88224c6 --- /dev/null +++ b/internal/statuses/csv_test.go @@ -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) + } +} diff --git a/internal/web/app.go b/internal/web/app.go index 3e6ccf2..696f10a 100644 --- a/internal/web/app.go +++ b/internal/web/app.go @@ -58,7 +58,7 @@ func NewApp(db *sql.DB, log *slog.Logger, secureCookies bool) (*App, error) { SecureCookies: secureCookies, } - tmpl, err := loadTemplates() + tmpl, err := loadTemplates(app) if err != nil { return nil, err } diff --git a/internal/web/handlers_csv.go b/internal/web/handlers_csv.go index 96d51a9..de7b5d4 100644 --- a/internal/web/handlers_csv.go +++ b/internal/web/handlers_csv.go @@ -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) { ctx := r.Context() 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() - 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) if err != nil { @@ -61,7 +69,7 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) { 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 { jsonErr(w, http.StatusBadRequest, err) return diff --git a/internal/web/handlers_settings_import.go b/internal/web/handlers_settings_import.go new file mode 100644 index 0000000..be7470b --- /dev/null +++ b/internal/web/handlers_settings_import.go @@ -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) +} diff --git a/internal/web/helpers.go b/internal/web/helpers.go index 67dba2a..47a522d 100644 --- a/internal/web/helpers.go +++ b/internal/web/helpers.go @@ -25,6 +25,20 @@ func queryOptionalID(r *http.Request, name string) (*int64, error) { 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 { v := r.URL.Query().Get(name) if v == "" { diff --git a/internal/web/render.go b/internal/web/render.go index a6729c1..3df684a 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -1,6 +1,7 @@ package web import ( + "context" "fmt" "html/template" "io/fs" @@ -94,8 +95,21 @@ type templateSet struct { // each page can define a block named "content" without colliding with the // others (html/template templates sharing one *template.Template must have // unique names, but isolated clones may reuse the same name freely). -func loadTemplates() (*templateSet, error) { - shared := template.New("root").Funcs(templateFuncs) +func loadTemplates(a *App) (*templateSet, error) { + // 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 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") if err != nil { @@ -124,7 +138,7 @@ func loadTemplates() (*templateSet, error) { 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") if err != nil { return nil, fmt.Errorf("parse login: %w", err) diff --git a/internal/web/router.go b/internal/web/router.go index 254a24b..8b9ec05 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -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}/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/new", a.requireAdmin(a.handleUserNewForm)) mux.HandleFunc("POST /settings/users", a.requireAdmin(a.handleUserCreate)) diff --git a/web/static/css/app.css b/web/static/css/app.css index 11eac9b..656c9b5 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -154,23 +154,91 @@ ul.tree-children { cursor: default; } -.stat-card .card-body { - text-align: center; +.panel-content { + max-width: 1100px; } -.stat-value { - font-size: 1.4rem; - font-weight: 600; - line-height: 1.2; +.panel-blank { + max-width: none; } -.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; color: #6c757d; } -.panel-content { - max-width: 1100px; +.icon-picker-menu { + 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 { diff --git a/web/static/js/app.js b/web/static/js/app.js index 6a2dcf9..2f1b736 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -240,6 +240,7 @@ items.push({ divider: true }); items.push({ label: 'Rename', icon: 'bi-pencil', action: function () { openRenameFolder(id); } }); 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'; } }); if (isAdmin) { @@ -261,6 +262,9 @@ 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() { var items = []; if (isAdmin) { @@ -271,9 +275,6 @@ items.push({ label: 'Expand All', icon: 'bi-arrows-expand', action: expandAllHandler }); items.push({ label: 'Collapse All', icon: 'bi-arrows-collapse', action: collapseAllHandler }); 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'; } }); return items; } @@ -283,10 +284,13 @@ if (isAdmin) { 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({ divider: true }); - items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { if (importModal) importModal.show(); } }); + if (folderId) { + items.push({ divider: true }); + 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'; + items.push({ divider: true }); items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = exportUrl; } }); 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 // --------------------------------------------------------------------- @@ -357,7 +460,7 @@ document.getElementById('folderFormParentId').value = (opts && opts.parentId) || ''; document.getElementById('folderFormName').value = ''; document.getElementById('folderFormDescription').value = ''; - document.getElementById('folderFormIcon').value = ''; + setIconPickerValue(document.getElementById('folderFormIcon'), ''); document.getElementById('folderFormSortOrder').value = '0'; document.getElementById('folderModalTitle').textContent = 'New Folder'; document.getElementById('folderFormError').classList.add('d-none'); @@ -372,7 +475,7 @@ document.getElementById('folderFormParentId').value = ''; document.getElementById('folderFormName').value = f.Name; 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('folderModalTitle').textContent = 'Rename Folder'; document.getElementById('folderFormError').classList.add('d-none'); @@ -658,6 +761,15 @@ var importModalEl = document.getElementById('importModal'); 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'); if (importFormEl) { importFormEl.addEventListener('submit', async function (e) { diff --git a/web/templates/layout.html b/web/templates/layout.html index 95cf7fe..93351eb 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -3,16 +3,16 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>inventree + {{siteName}}