diff --git a/README.md b/README.md index bafa2cf..c526401 100644 --- a/README.md +++ b/README.md @@ -106,25 +106,40 @@ types and fields is seeded on first run — feel free to edit or delete it. without creating them a login account. A blank password is fine when creating one. -## QR asset tags and printable labels +## QR/barcode asset tags and printable labels -Every asset can have a QR-coded label printed for it: right-click an asset +Every asset can have a scannable label printed for it: right-click an asset for **Print Label**, or right-click a folder for **Print Labels…** (every -asset in that folder, recursively). The QR code encodes an absolute -`/scan/` URL, so scanning it with a phone camera — or a USB -barcode/QR scanner focused on the browser's address bar — opens straight to -that asset. +asset in that folder, recursively). Choose the code format on the print +page itself: -The print page supports two label sheet layouts, switchable from a dropdown -on the page itself: +- **QR code** — encodes an absolute `/scan/` URL, so scanning it + with a phone camera opens straight to that asset. +- **Barcode (Code128)** — encodes the bare Asset ID, meant for a dedicated + USB barcode scanner typing it into whatever's focused (e.g. the app's + search box). -- **Avery 5160** (1" x 2-5/8", 30/sheet) — standard address labels, exact - published dimensions. +The print page also supports three label sheet layouts, switchable from a +dropdown: + +- **Avery 5160 / 5260** (1" x 2-5/8", 30/sheet) — standard address labels + (5260 is the same grid, just rounded corners), exact published + dimensions. - **61526** asset tag labels (3/4" x 2", ~48/sheet) — the grid/margins here are a best-effort layout, not a verified spec sheet, so print one test page on plain paper and check it against a real label before committing a full (often non-refundable) sheet. +**Printing a batch without wasting sheet space**: Ctrl/Cmd+click (toggle) +or Shift+click (range) multiple folders/assets in the tree, the same way +you would in a file manager, then right-click the selection for **Print +Labels (N)** — along with **Export CSV (N)**, **Edit Status…**, +**Check Out…**, **Duplicate**, **Move to…**, and **Delete**, all bulk +versions of the single-item actions. On the print page itself, **Start at +#** lets you resume printing partway into an already-used sheet (position 1 += top-left, counting left-to-right, top-to-bottom) instead of starting a +fresh one over a few peeled-off labels. + ## CSV import / export - **Export**: from the tree's right-click menu, or `/export/assets.csv` diff --git a/go.mod b/go.mod index 84e8e22..a62c6b6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module inventree go 1.25.0 require ( + github.com/boombuler/barcode v1.1.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/crypto v0.55.0 modernc.org/sqlite v1.56.0 diff --git a/go.sum b/go.sum index 3961fd7..8dd17a3 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= diff --git a/internal/assets/assets.go b/internal/assets/assets.go index 568a8eb..6d4f763 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -246,6 +246,15 @@ func (s *Store) AssignUser(ctx context.Context, id int64, userID *int64) error { return err } +// SetStatus changes only an asset's status, independent of the full edit +// form - used by the bulk "Edit Status" multi-select action. +func (s *Store) SetStatus(ctx context.Context, id, statusID int64) error { + _, err := s.db.ExecContext(ctx, + `UPDATE assets SET status_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, + statusID, id) + return err +} + var ErrQuantityNotTracked = errors.New("quantity is not tracked for unique item types") // AdjustQuantity increments or decrements an asset's quantity by delta, diff --git a/internal/assets/csv.go b/internal/assets/csv.go index d17c577..4dbaa1d 100644 --- a/internal/assets/csv.go +++ b/internal/assets/csv.go @@ -290,13 +290,8 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error { if err != nil { return err } - cw := csv.NewWriter(w) - header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Quantity", "Created At", "Updated At"} - for _, k := range fieldDefs { - header = append(header, cfColPrefix+k) - } - if err := cw.Write(header); err != nil { + if err := writeAssetHeader(cw, fieldDefs); err != nil { return err } @@ -312,19 +307,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error { if err != nil { return err } - row := []string{ - a.AssetTag, a.Name, a.ItemTypeName, folderPathString(a.FolderPath), a.StatusName, a.AssignedUsername, a.Notes, - strconv.Itoa(a.Quantity), - a.CreatedAt.Format("2006-01-02 15:04:05"), a.UpdatedAt.Format("2006-01-02 15:04:05"), - } - values := map[string]string{} - for _, fv := range a.CustomFields { - values[fv.Def.FieldKey] = fv.Value - } - for _, k := range fieldDefs { - row = append(row, values[k]) - } - if err := cw.Write(row); err != nil { + if err := writeAssetRow(cw, a, fieldDefs); err != nil { return err } } @@ -337,6 +320,56 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error { return cw.Error() } +// ExportByIDs writes exactly the given assets (in the given order) as CSV - +// used for a multi-select "Export CSV" batch that doesn't map onto a single +// ListFilter (an arbitrary mix of individually-picked assets and whole +// folders). +func (s *Store) ExportByIDs(ctx context.Context, w io.Writer, ids []int64) error { + fieldDefs, err := s.allFieldKeys(ctx) + if err != nil { + return err + } + cw := csv.NewWriter(w) + if err := writeAssetHeader(cw, fieldDefs); err != nil { + return err + } + for _, id := range ids { + a, err := s.Get(ctx, id) + if err != nil { + continue + } + if err := writeAssetRow(cw, a, fieldDefs); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +} + +func writeAssetHeader(cw *csv.Writer, fieldDefs []string) error { + header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Quantity", "Created At", "Updated At"} + for _, k := range fieldDefs { + header = append(header, cfColPrefix+k) + } + return cw.Write(header) +} + +func writeAssetRow(cw *csv.Writer, a *models.Asset, fieldDefs []string) error { + row := []string{ + a.AssetTag, a.Name, a.ItemTypeName, folderPathString(a.FolderPath), a.StatusName, a.AssignedUsername, a.Notes, + strconv.Itoa(a.Quantity), + a.CreatedAt.Format("2006-01-02 15:04:05"), a.UpdatedAt.Format("2006-01-02 15:04:05"), + } + values := map[string]string{} + for _, fv := range a.CustomFields { + values[fv.Def.FieldKey] = fv.Value + } + for _, k := range fieldDefs { + row = append(row, values[k]) + } + return cw.Write(row) +} + func folderPathString(path []models.BreadcrumbItem) string { names := make([]string, len(path)) for i, p := range path { diff --git a/internal/web/handlers_assets.go b/internal/web/handlers_assets.go index 8c6fff8..0feb6c4 100644 --- a/internal/web/handlers_assets.go +++ b/internal/web/handlers_assets.go @@ -169,6 +169,31 @@ func (a *App) handleAssetCheckout(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]any{"id": id, "assigned_user_id": req.AssignedUserID}) } +type assetStatusRequest struct { + StatusID int64 `json:"status_id"` +} + +// handleAssetSetStatus changes only an asset's status - used by the bulk +// "Edit Status" multi-select action so it doesn't have to resend every +// other field the way a full PATCH does. +func (a *App) handleAssetSetStatus(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r, "id") + if err != nil { + jsonErr(w, http.StatusBadRequest, err) + return + } + var req assetStatusRequest + if err := readJSON(r, &req); err != nil { + jsonErr(w, http.StatusBadRequest, err) + return + } + if err := a.Assets.SetStatus(r.Context(), id, req.StatusID); err != nil { + jsonErr(w, http.StatusInternalServerError, err) + return + } + jsonOK(w, map[string]any{"id": id, "status_id": req.StatusID}) +} + type assetQuantityRequest struct { Delta int `json:"delta"` } diff --git a/internal/web/handlers_csv.go b/internal/web/handlers_csv.go index de7b5d4..0a3bf27 100644 --- a/internal/web/handlers_csv.go +++ b/internal/web/handlers_csv.go @@ -10,6 +10,41 @@ import ( func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + filename := fmt.Sprintf("inventree-assets-%s.csv", time.Now().Format("2006-01-02")) + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") + + assetIDsParam := r.URL.Query().Get("asset_ids") + folderIDsParam := r.URL.Query().Get("folder_ids") + if assetIDsParam != "" || folderIDsParam != "" { + // A multi-select batch (an arbitrary mix of individually-picked + // assets and whole folders) doesn't map onto a single ListFilter. + seen := map[int64]bool{} + var ids []int64 + for _, id := range parseIDList(assetIDsParam) { + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + for _, fid := range parseIDList(folderIDsParam) { + batch, _, err := a.Assets.List(ctx, assets.ListFilter{FolderID: &fid, FolderSet: true, IncludeSub: true, Page: 1, PageSize: 2000}) + if err != nil { + continue + } + for _, as := range batch { + if !seen[as.ID] { + seen[as.ID] = true + ids = append(ids, as.ID) + } + } + } + if err := a.Assets.ExportByIDs(ctx, w, ids); err != nil { + a.Log.Error("csv export failed", "error", err) + } + return + } + filter := assets.ListFilter{ ItemTypeID: queryInt64(r, "item_type_id"), StatusID: queryInt64(r, "status_id"), @@ -21,10 +56,6 @@ func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) { filter.FolderSet = true filter.IncludeSub = r.URL.Query().Get("recursive") == "1" } - - filename := fmt.Sprintf("inventree-assets-%s.csv", time.Now().Format("2006-01-02")) - w.Header().Set("Content-Type", "text/csv; charset=utf-8") - w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") if err := a.Assets.Export(ctx, w, filter); err != nil { a.Log.Error("csv export failed", "error", err) } diff --git a/internal/web/handlers_labels.go b/internal/web/handlers_labels.go index 98c0da1..1d07219 100644 --- a/internal/web/handlers_labels.go +++ b/internal/web/handlers_labels.go @@ -4,6 +4,8 @@ import ( "fmt" "html/template" "net/http" + "strconv" + "strings" "inventree/internal/assets" "inventree/internal/models" @@ -12,17 +14,19 @@ import ( // LabelTemplate describes a sheet of adhesive labels: cell size, grid, and // page margins, all in inches, used to lay out printable asset tags. type LabelTemplate struct { - Key string - DisplayName string - LabelWidthIn float64 - LabelHeightIn float64 - Cols int - Rows int - MarginTopIn float64 - MarginLeftIn float64 - ColGapIn float64 - RowGapIn float64 - QRSizeIn float64 + Key string + DisplayName string + LabelWidthIn float64 + LabelHeightIn float64 + Cols int + Rows int + MarginTopIn float64 + MarginLeftIn float64 + ColGapIn float64 + RowGapIn float64 + QRSizeIn float64 + BarcodeWidthIn float64 + BarcodeHeightIn float64 // Approximate is true for templates whose margins/grid aren't from a // verified manufacturer spec sheet - print one test page on plain // paper and hold it up to a real label sheet before committing a @@ -35,7 +39,7 @@ var labelTemplates = map[string]LabelTemplate{ Key: "5160", DisplayName: `Avery 5160 - 1" x 2-5/8" (30/sheet)`, LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10, MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0, - QRSizeIn: 0.85, + QRSizeIn: 0.85, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4, }, "5260": { // Same 30-per-sheet grid as 5160 (1" x 2-5/8"); 5260 is just the @@ -43,13 +47,13 @@ var labelTemplates = map[string]LabelTemplate{ Key: "5260", DisplayName: `Avery 5260 - 1" x 2-5/8" (30/sheet, rounded corners)`, LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10, MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0, - QRSizeIn: 0.85, + QRSizeIn: 0.85, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4, }, "61526": { Key: "61526", DisplayName: `Asset Tag Labels 61526 - 3/4" x 2" (~48/sheet)`, LabelWidthIn: 2, LabelHeightIn: 0.75, Cols: 4, Rows: 12, MarginTopIn: 1, MarginLeftIn: 0.25, ColGapIn: 0, RowGapIn: 0, - QRSizeIn: 0.62, Approximate: true, + QRSizeIn: 0.62, BarcodeWidthIn: 1.7, BarcodeHeightIn: 0.3, Approximate: true, }, } @@ -68,21 +72,35 @@ func (t LabelTemplate) css() template.CSS { row-gap: %.4fin; justify-content: center; } -.label { +.label, .label-blank { width: %.4fin; height: %.4fin; box-sizing: border-box; +} +.label { padding: 0.06in 0.08in; - display: flex; - align-items: center; - gap: 0.08in; overflow: hidden; page-break-inside: avoid; } -.label img { width: %.4fin; height: %.4fin; flex-shrink: 0; } +.label.format-qr { + display: flex; + align-items: center; + gap: 0.08in; +} +.label.format-qr img { width: %.4fin; height: %.4fin; flex-shrink: 0; } +.label.format-barcode { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.03in; +} +.label.format-barcode img { width: %.4fin; height: %.4fin; } `, t.MarginTopIn, t.MarginLeftIn, t.Cols, t.LabelWidthIn, t.ColGapIn, t.RowGapIn, - t.LabelWidthIn, t.LabelHeightIn, t.QRSizeIn, t.QRSizeIn)) + t.LabelWidthIn, t.LabelHeightIn, + t.QRSizeIn, t.QRSizeIn, + t.BarcodeWidthIn, t.BarcodeHeightIn)) } type labelItem struct { @@ -91,7 +109,7 @@ type labelItem struct { // never user input), so html/template's URL filter doesn't replace it // with the "#ZgotmplZ" safety placeholder the way it would a plain // string containing a data: URI. - QRData template.URL + ImageData template.URL } type labelsPageData struct { @@ -101,17 +119,34 @@ type labelsPageData struct { Templates map[string]LabelTemplate TemplateKeys []string Selected LabelTemplate - AssetID string - FolderID string - Recursive bool + Format string // "qr" or "barcode" + AssetIDs string + FolderIDs string + StartPos int + BlankCells []int // len == StartPos-1, just for {{range}} to emit that many placeholder cells } -// handleLabelsPage renders a print-ready sheet of QR-coded asset labels: -// either a single asset (?asset_id=) or every asset in a folder -// (?folder_id=&recursive=1), or the whole inventory with neither param. -// Each QR code encodes an absolute /scan/ URL so a phone camera (or a -// USB barcode scanner focused on a browser address bar) opens straight to -// that asset. +func parseIDList(s string) []int64 { + var out []int64 + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if id, err := strconv.ParseInt(part, 10, 64); err == nil { + out = append(out, id) + } + } + return out +} + +// handleLabelsPage renders a print-ready sheet of QR-coded asset labels for +// any combination of individual assets (?asset_ids=1,2,3) and whole folders +// (?folder_ids=5,9, expanded recursively), or the entire inventory when +// neither is given. ?start=N (1-indexed) leaves the first N-1 cells blank, +// so a partially-used sheet can be resumed instead of wasted. Each QR code +// encodes an absolute /scan/ URL so a phone camera (or a USB barcode +// scanner focused on a browser address bar) opens straight to that asset. func (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() origin := requestOrigin(r) @@ -121,46 +156,83 @@ func (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) { tmpl = labelTemplates[defaultLabelTemplate] } - var list []models.Asset - assetIDParam := r.URL.Query().Get("asset_id") - folderIDParam := r.URL.Query().Get("folder_id") - recursive := r.URL.Query().Get("recursive") == "1" + startPos := queryInt(r, "start", 1) + if startPos < 1 { + startPos = 1 + } - if assetID, err := queryOptionalID(r, "asset_id"); err == nil && assetID != nil { - asset, err := a.Assets.Get(ctx, *assetID) - if err != nil { - writeErr(w, http.StatusNotFound, err.Error()) - return + assetIDsParam := r.URL.Query().Get("asset_ids") + folderIDsParam := r.URL.Query().Get("folder_ids") + + seen := map[int64]bool{} + var list []models.Asset + addAsset := func(as *models.Asset) { + if !seen[as.ID] { + seen[as.ID] = true + list = append(list, *as) } - list = []models.Asset{*asset} - } else { - filter := assets.ListFilter{Page: 1, PageSize: 2000} - if folderID, err := queryOptionalID(r, "folder_id"); err == nil && folderID != nil { - filter.FolderID = folderID - filter.FolderSet = true - filter.IncludeSub = recursive - } - batch, _, err := a.Assets.List(ctx, filter) + } + + if assetIDsParam == "" && folderIDsParam == "" { + batch, _, err := a.Assets.List(ctx, assets.ListFilter{Page: 1, PageSize: 2000}) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } - list = batch + for i := range batch { + addAsset(&batch[i]) + } + } else { + for _, id := range parseIDList(assetIDsParam) { + if as, err := a.Assets.Get(ctx, id); err == nil { + addAsset(as) + } + } + for _, fid := range parseIDList(folderIDsParam) { + batch, _, err := a.Assets.List(ctx, assets.ListFilter{ + FolderID: &fid, FolderSet: true, IncludeSub: true, Page: 1, PageSize: 2000, + }) + if err != nil { + continue + } + for i := range batch { + addAsset(&batch[i]) + } + } + } + + format := r.URL.Query().Get("format") + if format != "barcode" { + format = "qr" } labels := make([]labelItem, 0, len(list)) for _, as := range list { - qr, err := qrDataURI(origin+"/scan/"+as.AssetTag, 220) + var ( + data string + err error + ) + if format == "barcode" { + // A linear barcode is meant for a dedicated scanner typing the + // raw tag into whatever's focused (e.g. the app's search box), + // so it encodes the bare Asset ID rather than a URL. + data, err = barcodeDataURI(as.AssetTag, int(tmpl.BarcodeWidthIn*300), int(tmpl.BarcodeHeightIn*300)) + } else { + // A QR code is meant for a phone camera, so it encodes a full + // URL that opens straight to the asset. + data, err = qrDataURI(origin+"/scan/"+as.AssetTag, 220) + } if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } - labels = append(labels, labelItem{Asset: as, QRData: template.URL(qr)}) + labels = append(labels, labelItem{Asset: as, ImageData: template.URL(data)}) } a.Render(w, http.StatusOK, "labels", labelsPageData{ User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(), Templates: labelTemplates, TemplateKeys: []string{"5160", "5260", "61526"}, - Selected: tmpl, AssetID: assetIDParam, FolderID: folderIDParam, Recursive: recursive, + Selected: tmpl, Format: format, AssetIDs: assetIDsParam, FolderIDs: folderIDsParam, + StartPos: startPos, BlankCells: make([]int, startPos-1), }) } diff --git a/internal/web/qr.go b/internal/web/qr.go index b90365a..b5ca6c0 100644 --- a/internal/web/qr.go +++ b/internal/web/qr.go @@ -1,9 +1,13 @@ package web import ( + "bytes" "encoding/base64" + "image/png" "net/http" + "github.com/boombuler/barcode" + "github.com/boombuler/barcode/code128" qrcode "github.com/skip2/go-qrcode" ) @@ -17,6 +21,24 @@ func qrDataURI(content string, size int) (string, error) { return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil } +// barcodeDataURI renders content as a Code128 linear barcode PNG, scaled to +// widthPx x heightPx, and returns it as a data: URI. +func barcodeDataURI(content string, widthPx, heightPx int) (string, error) { + bc, err := code128.Encode(content) + if err != nil { + return "", err + } + scaled, err := barcode.Scale(bc, widthPx, heightPx) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := png.Encode(&buf, scaled); err != nil { + return "", err + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + // requestOrigin reconstructs the scheme+host the request arrived on, for // building absolute URLs (e.g. QR codes need a full URL, not a path). func requestOrigin(r *http.Request) string { diff --git a/internal/web/router.go b/internal/web/router.go index d3e19a8..51159c0 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -56,6 +56,7 @@ func (a *App) Router() http.Handler { mux.HandleFunc("POST /app/assets/{id}/duplicate", a.requireAdmin(a.handleAssetDuplicate)) mux.HandleFunc("POST /app/assets/{id}/move", a.requireAdmin(a.handleAssetMove)) mux.HandleFunc("POST /app/assets/{id}/checkout", a.requireAdmin(a.handleAssetCheckout)) + mux.HandleFunc("POST /app/assets/{id}/status", a.requireAdmin(a.handleAssetSetStatus)) mux.HandleFunc("POST /app/assets/{id}/quantity", a.requireAdmin(a.handleAssetQuantity)) mux.HandleFunc("GET /app/users/options", a.requireAdmin(a.handleUserOptions)) diff --git a/web/static/css/app.css b/web/static/css/app.css index 656c9b5..0a5d835 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -77,6 +77,15 @@ ul.tree-children { font-weight: 500; } +/* Ctrl/Shift-click multi-selection (right-click one of these for bulk + actions). Distinct from .selected, which marks the single item shown in + the main panel - a row can carry both at once. */ +.tree-row.multi-selected { + background-color: #fff3cd; + outline: 1px solid #ffe69c; + outline-offset: -1px; +} + .tree-row.drop-target { outline: 2px dashed #0d6efd; outline-offset: -2px; diff --git a/web/static/js/app.js b/web/static/js/app.js index 3935e82..f4982a9 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -243,7 +243,7 @@ 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: 'Print Labels…', icon: 'bi-qr-code', action: function () { window.open('/labels?folder_id=' + id + '&recursive=1', '_blank'); } }); + items.push({ label: 'Print Labels…', icon: 'bi-qr-code', action: function () { window.open('/labels?folder_ids=' + id, '_blank'); } }); if (isAdmin) { items.push({ divider: true }); items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { startDeleteFolder(id); } }); @@ -253,7 +253,7 @@ function assetMenuItems(id) { var items = [{ label: 'Open', icon: 'bi-box-arrow-up-right', action: function () { navigateAndSelect('asset', id); } }]; - items.push({ label: 'Print Label', icon: 'bi-qr-code', action: function () { window.open('/labels?asset_id=' + id, '_blank'); } }); + items.push({ label: 'Print Label', icon: 'bi-qr-code', action: function () { window.open('/labels?asset_ids=' + id, '_blank'); } }); if (isAdmin) { items.push({ label: 'Edit', icon: 'bi-pencil', action: function () { loadAssetEditForm(id); } }); items.push({ label: 'Check Out…', icon: 'bi-person-check', action: function () { openCheckoutModal(id); } }); @@ -298,6 +298,85 @@ return items; } + // ---- Multi-selection: Ctrl/Cmd+click toggles, Shift+click selects a + // range, right-clicking a member of an active (2+) selection shows bulk + // actions instead of that one row's normal menu. ----------------------- + + var multiSelected = new Map(); // "type:id" -> {type, id, row} + var selectionAnchorRow = null; + + function selKey(type, id) { return type + ':' + id; } + + function clearMultiSelection() { + multiSelected.forEach(function (item) { item.row.classList.remove('multi-selected'); }); + multiSelected.clear(); + } + + function addToMultiSelection(row) { + var key = selKey(row.dataset.type, row.dataset.id); + if (!multiSelected.has(key)) { + multiSelected.set(key, { type: row.dataset.type, id: row.dataset.id, row: row }); + row.classList.add('multi-selected'); + } + } + + function toggleMultiSelection(row) { + var key = selKey(row.dataset.type, row.dataset.id); + if (multiSelected.has(key)) { + multiSelected.delete(key); + row.classList.remove('multi-selected'); + } else { + addToMultiSelection(row); + } + } + + function visibleSelectableRows() { + return Array.prototype.slice.call( + document.querySelectorAll('#tree-root .tree-row[data-type="folder"], #tree-root .tree-row[data-type="asset"]') + ); + } + + function selectRange(fromRow, toRow) { + var rows = visibleSelectableRows(); + var i1 = rows.indexOf(fromRow), i2 = rows.indexOf(toRow); + if (i1 === -1 || i2 === -1) { addToMultiSelection(toRow); return; } + var start = Math.min(i1, i2), end = Math.max(i1, i2); + for (var i = start; i <= end; i++) addToMultiSelection(rows[i]); + } + + function multiSelectionMenuItems() { + var assetIds = [], folderIds = []; + multiSelected.forEach(function (item) { + if (item.type === 'asset') assetIds.push(item.id); + else if (item.type === 'folder') folderIds.push(item.id); + }); + var count = multiSelected.size; + var idParams = function () { + var params = []; + if (assetIds.length) params.push('asset_ids=' + assetIds.join(',')); + if (folderIds.length) params.push('folder_ids=' + folderIds.join(',')); + return params.join('&'); + }; + + var items = []; + items.push({ label: 'Print Labels (' + count + ')', icon: 'bi-qr-code', action: function () { window.open('/labels?' + idParams(), '_blank'); } }); + items.push({ label: 'Export CSV (' + count + ')', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?' + idParams(); } }); + if (isAdmin && assetIds.length) { + items.push({ divider: true }); + items.push({ label: 'Edit Status… (' + assetIds.length + ')', icon: 'bi-pencil-square', action: function () { openBulkStatusModal(assetIds); } }); + items.push({ label: 'Check Out… (' + assetIds.length + ')', icon: 'bi-person-check', action: function () { openCheckoutModal(assetIds); } }); + items.push({ label: 'Duplicate (' + assetIds.length + ')', icon: 'bi-files', action: function () { duplicateAssets(assetIds); } }); + items.push({ label: 'Move to… (' + assetIds.length + ')', icon: 'bi-arrows-move', action: function () { openMoveAssetModal(assetIds); } }); + items.push({ divider: true }); + items.push({ label: 'Delete (' + assetIds.length + ')', icon: 'bi-trash', danger: true, action: function () { deleteAssets(assetIds); } }); + } + return items; + } + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') clearMultiSelection(); + }); + // ---- Wiring: tree pane ------------------------------------------------- if (treePane) { @@ -312,8 +391,16 @@ var type = row.dataset.type; if (type !== 'folder' && type !== 'asset') return; // e.g. the "N more assets" row: no menu e.preventDefault(); + + var key = selKey(type, row.dataset.id); + if (multiSelected.size > 1 && multiSelected.has(key)) { + showContextMenu(e.clientX, e.clientY, multiSelectionMenuItems()); + return; + } + clearMultiSelection(); document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); }); row.classList.add('selected'); + selectionAnchorRow = row; showContextMenu(e.clientX, e.clientY, type === 'folder' ? folderMenuItems(row.dataset.id) : assetMenuItems(row.dataset.id)); }); } @@ -583,9 +670,14 @@ var moveAssetModalEl = document.getElementById('moveAssetModal'); var moveAssetModal = (moveAssetModalEl && window.bootstrap) ? new bootstrap.Modal(moveAssetModalEl) : null; - async function openMoveAssetModal(id) { + // openMoveAssetModal accepts either a single id or an array of ids, so + // the same modal serves both the single-asset context menu and the + // multi-selection bulk menu. + async function openMoveAssetModal(idOrIds) { if (!moveAssetModal) return; - moveAssetModalEl.dataset.assetId = id; + var ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + moveAssetModalEl.dataset.assetIds = ids.join(','); + document.getElementById('moveAssetModalTitle').textContent = ids.length > 1 ? 'Move ' + ids.length + ' Assets' : 'Move Asset'; var sel = document.getElementById('moveAssetDestination'); sel.innerHTML = ''; try { @@ -605,13 +697,18 @@ var confirmMoveAssetBtn = document.getElementById('confirmMoveAssetBtn'); if (confirmMoveAssetBtn) { confirmMoveAssetBtn.addEventListener('click', async function () { - var id = moveAssetModalEl.dataset.assetId; + var ids = moveAssetModalEl.dataset.assetIds.split(','); var val = document.getElementById('moveAssetDestination').value; + var folderId = val ? Number(val) : null; try { - await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } }); + await Promise.all(ids.map(function (id) { + return apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: folderId } }); + })); moveAssetModal.hide(); await refreshWholeTree(); - await navigateAndSelect('asset', id); + if (ids.length === 1) await navigateAndSelect('asset', ids[0]); + else await reloadCurrentPanel(); + clearMultiSelection(); } catch (err) { alert('Move failed: ' + err.message); } @@ -625,17 +722,39 @@ var checkoutModalEl = document.getElementById('checkoutModal'); var checkoutModal = (checkoutModalEl && window.bootstrap) ? new bootstrap.Modal(checkoutModalEl) : null; - async function openCheckoutModal(id) { + // openCheckoutModal accepts either a single id or an array of ids. For a + // single asset it shows who currently has it; for a batch it skips that + // (no single "current" to show) and just checks all of them out/in + // together. + async function openCheckoutModal(idOrIds) { if (!checkoutModal) return; - checkoutModalEl.dataset.assetId = id; + var ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + checkoutModalEl.dataset.assetIds = ids.join(','); var sel = document.getElementById('checkoutUserSelect'); var currentText = document.getElementById('checkoutCurrent'); sel.innerHTML = ''; + + if (ids.length > 1) { + currentText.textContent = 'Checking out ' + ids.length + ' assets.'; + document.getElementById('checkoutModalTitle').textContent = 'Check Out ' + ids.length + ' Assets'; + try { + var users = await apiFetch('/app/users/options'); + (users || []).forEach(function (u) { + var opt = document.createElement('option'); + opt.value = u.id; + opt.textContent = u.username; + sel.appendChild(opt); + }); + } catch (err) { /* leave just "Unassigned" */ } + checkoutModal.show(); + return; + } + currentText.textContent = 'Loading…'; try { - var asset = await apiFetch('/app/assets/' + id); - var users = await apiFetch('/app/users/options'); - (users || []).forEach(function (u) { + var asset = await apiFetch('/app/assets/' + ids[0]); + var usersList = await apiFetch('/app/users/options'); + (usersList || []).forEach(function (u) { var opt = document.createElement('option'); opt.value = u.id; opt.textContent = u.username; @@ -655,18 +774,82 @@ var confirmCheckoutBtn = document.getElementById('confirmCheckoutBtn'); if (confirmCheckoutBtn) { confirmCheckoutBtn.addEventListener('click', async function () { - var id = checkoutModalEl.dataset.assetId; + var ids = checkoutModalEl.dataset.assetIds.split(','); var val = document.getElementById('checkoutUserSelect').value; + var userId = val ? Number(val) : null; try { - await apiFetch('/app/assets/' + id + '/checkout', { method: 'POST', json: { assigned_user_id: val ? Number(val) : null } }); + await Promise.all(ids.map(function (id) { + return apiFetch('/app/assets/' + id + '/checkout', { method: 'POST', json: { assigned_user_id: userId } }); + })); checkoutModal.hide(); - await navigateAndSelect('asset', id); + if (ids.length === 1) await navigateAndSelect('asset', ids[0]); + else await reloadCurrentPanel(); + clearMultiSelection(); } catch (err) { alert('Checkout failed: ' + err.message); } }); } + // --------------------------------------------------------------------- + // Bulk status change modal (multi-selection > Edit Status...) + // --------------------------------------------------------------------- + + var bulkStatusModalEl = document.getElementById('bulkStatusModal'); + var bulkStatusModal = (bulkStatusModalEl && window.bootstrap) ? new bootstrap.Modal(bulkStatusModalEl) : null; + + function openBulkStatusModal(ids) { + if (!bulkStatusModal) return; + bulkStatusModalEl.dataset.assetIds = ids.join(','); + document.getElementById('bulkStatusModalTitle').textContent = 'Edit Status (' + ids.length + ')'; + bulkStatusModal.show(); + } + + var confirmBulkStatusBtn = document.getElementById('confirmBulkStatusBtn'); + if (confirmBulkStatusBtn) { + confirmBulkStatusBtn.addEventListener('click', async function () { + var ids = bulkStatusModalEl.dataset.assetIds.split(','); + var statusId = Number(document.getElementById('bulkStatusSelect').value); + try { + await Promise.all(ids.map(function (id) { + return apiFetch('/app/assets/' + id + '/status', { method: 'POST', json: { status_id: statusId } }); + })); + bulkStatusModal.hide(); + await reloadCurrentPanel(); + clearMultiSelection(); + } catch (err) { + alert('Status update failed: ' + err.message); + } + }); + } + + async function duplicateAssets(ids) { + try { + await Promise.all(ids.map(function (id) { + return apiFetch('/app/assets/' + id + '/duplicate', { method: 'POST' }); + })); + await refreshWholeTree(); + await reloadCurrentPanel(); + clearMultiSelection(); + } catch (err) { + alert('Duplicate failed: ' + err.message); + } + } + + async function deleteAssets(ids) { + if (!confirm('Delete ' + ids.length + ' assets? This cannot be undone.')) return; + try { + await Promise.all(ids.map(function (id) { + return apiFetch('/app/assets/' + id, { method: 'DELETE' }); + })); + await refreshWholeTree(); + clearMultiSelection(); + await navigateAndSelect('root', ''); + } catch (err) { + alert('Delete failed: ' + err.message); + } + } + // --------------------------------------------------------------------- // Asset form (create / edit) - shown in a modal // --------------------------------------------------------------------- @@ -881,6 +1064,25 @@ var row = e.target.closest('.tree-row'); if (row && row.dataset.type && !row.classList.contains('tree-row-more')) { + if (row.dataset.type === 'root') { + clearMultiSelection(); + selectionAnchorRow = null; + selectNode(row); + return; + } + if (e.shiftKey && selectionAnchorRow) { + e.preventDefault(); + selectRange(selectionAnchorRow, row); + return; + } + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + toggleMultiSelection(row); + selectionAnchorRow = row; + return; + } + clearMultiSelection(); + selectionAnchorRow = row; selectNode(row); } }); diff --git a/web/templates/login.html b/web/templates/login.html index 48f49c9..cd7f189 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -3,7 +3,7 @@ - Sign in · inventree + Sign in · {{siteName}} @@ -14,7 +14,7 @@
-

inventree

+

{{siteName}}

IT asset inventory
diff --git a/web/templates/pages/labels.html b/web/templates/pages/labels.html index 5d6f732..3188c58 100644 --- a/web/templates/pages/labels.html +++ b/web/templates/pages/labels.html @@ -5,27 +5,42 @@ body { margin: 0; background: #fff; } .label-sheet { margin: 0; } } -.label-tag { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; font-size: 11pt; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.label-name { font-size: 8pt; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.label-tag { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; font-size: 11pt; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; } +.label-name { font-size: 8pt; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } +.label.format-barcode .label-text { text-align: center; } +

+ Already used some labels off this sheet? Set "Start at #" to the next free position (counting left-to-right, + top-to-bottom) instead of wasting them and starting a new sheet. +

+ {{if .Selected.Approximate}}
This layout's grid/margins aren't from a verified spec sheet - print one test page on plain paper and @@ -34,10 +49,11 @@ {{end}}
+ {{range .BlankCells}}
{{end}} {{range .Labels}} -
- QR code for {{.Asset.AssetTag}} -
+
+ {{if eq $.Format +
{{.Asset.AssetTag}}
{{.Asset.Name}}
diff --git a/web/templates/partials/modals.html b/web/templates/partials/modals.html index 3b50c54..bf9837e 100644 --- a/web/templates/partials/modals.html +++ b/web/templates/partials/modals.html @@ -111,7 +111,7 @@