Bulk multi-select actions, barcode format, batch printing, site name on login

Multi-select (Ctrl/Cmd+click toggle, Shift+click range, right-click the
active selection for bulk actions - same conventions as a native file
manager):
- Print Labels (N) / Export CSV (N) for any mix of individually-picked
  assets and whole folders. Labels/export now take asset_ids/folder_ids
  instead of a single asset_id/folder_id.
- Edit Status..., Check Out..., Duplicate, Move to..., and Delete, bulk
  versions of the existing single-asset actions. Checkout/Move modals
  were generalized to accept an array of ids instead of just one; status
  editing got its own lightweight POST /app/assets/{id}/status endpoint
  (assets.SetStatus) so a bulk status change doesn't need to resend every
  other field the way a full PATCH would.

Labels:
- Choose QR (a /scan/<tag> URL, for a phone camera) or Code128 barcode
  (the bare Asset ID, for a USB scanner typing into the search box) via a
  format selector on the print page. New barcodeDataURI using
  github.com/boombuler/barcode alongside the existing QR generator.
- "Start at #" lets printing resume partway into an already-used sheet
  instead of wasting it on a fresh one.
- Added the Avery 5260 label template (same grid as 5160, rounded
  corners, so it isn't flagged approximate).

Also: the configured Site Name now shows on the login page too, not just
the authenticated navbar.
This commit is contained in:
Owen
2026-08-17 17:12:17 -05:00
parent 4e4313723e
commit 14e2f3bb77
15 changed files with 573 additions and 113 deletions
+25 -10
View File
@@ -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/<Asset ID>` 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/<Asset ID>` 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`
+1
View File
@@ -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
+2
View File
@@ -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=
+9
View File
@@ -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,
+52 -19
View File
@@ -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 {
+25
View File
@@ -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"`
}
+35 -4
View File
@@ -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)
}
+124 -52
View File
@@ -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/<tag> 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/<tag> 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),
})
}
+22
View File
@@ -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 {
+1
View File
@@ -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))
+9
View File
@@ -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;
+217 -15
View File
@@ -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 = '<option value="">Inventory (top level)</option>';
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 = '<option value="">Unassigned (check in)</option>';
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);
}
});
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · inventree</title>
<title>Sign in · {{siteName}}</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
<link rel="stylesheet" href="/static/css/app.css">
@@ -14,7 +14,7 @@
<div class="col-11 col-sm-8 col-md-5 col-lg-4">
<div class="text-center mb-4">
<div class="display-6"><i class="bi bi-diagram-3-fill"></i></div>
<h1 class="h4 mt-2 mb-0">inventree</h1>
<h1 class="h4 mt-2 mb-0">{{siteName}}</h1>
<div class="text-body-secondary small">IT asset inventory</div>
</div>
<div class="card shadow-sm">
+26 -10
View File
@@ -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; }
</style>
<div class="print-toolbar d-flex flex-wrap align-items-center gap-3 p-3 border-bottom">
<button class="btn btn-primary" onclick="window.print()"><i class="bi bi-printer"></i> Print</button>
<span class="text-body-secondary">{{len .Labels}} label(s)</span>
<form method="get" class="d-flex align-items-center gap-2 ms-auto">
{{if .AssetID}}<input type="hidden" name="asset_id" value="{{.AssetID}}">{{end}}
{{if .FolderID}}<input type="hidden" name="folder_id" value="{{.FolderID}}">{{end}}
{{if .Recursive}}<input type="hidden" name="recursive" value="1">{{end}}
<label class="form-label mb-0 small text-body-secondary" for="labelTemplateSelect">Label sheet</label>
<form method="get" class="d-flex flex-wrap align-items-center gap-2 ms-auto">
{{if .AssetIDs}}<input type="hidden" name="asset_ids" value="{{.AssetIDs}}">{{end}}
{{if .FolderIDs}}<input type="hidden" name="folder_ids" value="{{.FolderIDs}}">{{end}}
<label class="form-label mb-0 small text-body-secondary" for="labelFormatSelect">Code</label>
<select class="form-select form-select-sm" id="labelFormatSelect" name="format" onchange="this.form.submit()">
<option value="qr" {{if eq .Format "qr"}}selected{{end}}>QR code (opens on scan)</option>
<option value="barcode" {{if eq .Format "barcode"}}selected{{end}}>Barcode (Code128)</option>
</select>
<label class="form-label mb-0 small text-body-secondary" for="labelTemplateSelect">Sheet</label>
<select class="form-select form-select-sm" id="labelTemplateSelect" name="template" onchange="this.form.submit()">
{{range $key := .TemplateKeys}}
{{$t := index $.Templates $key}}
<option value="{{$key}}" {{if eq $key $.Selected.Key}}selected{{end}}>{{$t.DisplayName}}</option>
{{end}}
</select>
<label class="form-label mb-0 small text-body-secondary" for="labelStartInput">Start at #</label>
<input type="number" class="form-control form-control-sm" id="labelStartInput" name="start" value="{{.StartPos}}" min="1" style="width: 70px" onchange="this.form.submit()">
</form>
</div>
<p class="text-body-secondary small px-3 pt-2 mb-0">
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.
</p>
{{if .Selected.Approximate}}
<div class="alert alert-warning m-3">
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}}
<div class="label-sheet">
{{range .BlankCells}}<div class="label-blank"></div>{{end}}
{{range .Labels}}
<div class="label">
<img src="{{.QRData}}" alt="QR code for {{.Asset.AssetTag}}">
<div class="min-w-0">
<div class="label format-{{$.Format}}">
<img src="{{.ImageData}}" alt="{{if eq $.Format "barcode"}}Barcode{{else}}QR code{{end}} for {{.Asset.AssetTag}}">
<div class="min-w-0 label-text">
<div class="label-tag">{{.Asset.AssetTag}}</div>
<div class="label-name">{{.Asset.Name}}</div>
</div>
+23 -1
View File
@@ -111,7 +111,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Move Asset</h5>
<h5 class="modal-title" id="moveAssetModalTitle">Move Asset</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
@@ -151,6 +151,28 @@
</div>
</div>
<!-- Bulk status change (multi-select > Edit Status...) -->
<div class="modal fade" id="bulkStatusModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bulkStatusModalTitle">Edit Status</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label">New Status</label>
<select class="form-select" id="bulkStatusSelect">
{{range .Statuses}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmBulkStatusBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Import CSV into a specific folder (right-click a folder > Import CSV...) -->
<div class="modal fade" id="importModal" tabindex="-1">
<div class="modal-dialog">