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 without creating them a login account. A blank password is fine when
creating one. 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 for **Print Label**, or right-click a folder for **Print Labels…** (every
asset in that folder, recursively). The QR code encodes an absolute asset in that folder, recursively). Choose the code format on the print
`/scan/<Asset ID>` URL, so scanning it with a phone camera — or a USB page itself:
barcode/QR scanner focused on the browser's address bar — opens straight to
that asset.
The print page supports two label sheet layouts, switchable from a dropdown - **QR code** — encodes an absolute `/scan/<Asset ID>` URL, so scanning it
on the page itself: 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 The print page also supports three label sheet layouts, switchable from a
published dimensions. 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 - **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 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 page on plain paper and check it against a real label before committing a
full (often non-refundable) sheet. 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 ## CSV import / export
- **Export**: from the tree's right-click menu, or `/export/assets.csv` - **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 go 1.25.0
require ( require (
github.com/boombuler/barcode v1.1.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= 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 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") var ErrQuantityNotTracked = errors.New("quantity is not tracked for unique item types")
// AdjustQuantity increments or decrements an asset's quantity by delta, // 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 { if err != nil {
return err return err
} }
cw := csv.NewWriter(w) cw := csv.NewWriter(w)
header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Quantity", "Created At", "Updated At"} if err := writeAssetHeader(cw, fieldDefs); err != nil {
for _, k := range fieldDefs {
header = append(header, cfColPrefix+k)
}
if err := cw.Write(header); err != nil {
return err return err
} }
@@ -312,19 +307,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
if err != nil { if err != nil {
return err return err
} }
row := []string{ if err := writeAssetRow(cw, a, fieldDefs); err != nil {
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 {
return err return err
} }
} }
@@ -337,6 +320,56 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
return cw.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 { func folderPathString(path []models.BreadcrumbItem) string {
names := make([]string, len(path)) names := make([]string, len(path))
for i, p := range 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}) 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 { type assetQuantityRequest struct {
Delta int `json:"delta"` Delta int `json:"delta"`
} }
+35 -4
View File
@@ -10,6 +10,41 @@ import (
func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) { func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() 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{ filter := assets.ListFilter{
ItemTypeID: queryInt64(r, "item_type_id"), ItemTypeID: queryInt64(r, "item_type_id"),
StatusID: queryInt64(r, "status_id"), StatusID: queryInt64(r, "status_id"),
@@ -21,10 +56,6 @@ func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
filter.FolderSet = true filter.FolderSet = true
filter.IncludeSub = r.URL.Query().Get("recursive") == "1" 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 { if err := a.Assets.Export(ctx, w, filter); err != nil {
a.Log.Error("csv export failed", "error", err) a.Log.Error("csv export failed", "error", err)
} }
+124 -52
View File
@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"strconv"
"strings"
"inventree/internal/assets" "inventree/internal/assets"
"inventree/internal/models" "inventree/internal/models"
@@ -12,17 +14,19 @@ import (
// LabelTemplate describes a sheet of adhesive labels: cell size, grid, and // LabelTemplate describes a sheet of adhesive labels: cell size, grid, and
// page margins, all in inches, used to lay out printable asset tags. // page margins, all in inches, used to lay out printable asset tags.
type LabelTemplate struct { type LabelTemplate struct {
Key string Key string
DisplayName string DisplayName string
LabelWidthIn float64 LabelWidthIn float64
LabelHeightIn float64 LabelHeightIn float64
Cols int Cols int
Rows int Rows int
MarginTopIn float64 MarginTopIn float64
MarginLeftIn float64 MarginLeftIn float64
ColGapIn float64 ColGapIn float64
RowGapIn float64 RowGapIn float64
QRSizeIn float64 QRSizeIn float64
BarcodeWidthIn float64
BarcodeHeightIn float64
// Approximate is true for templates whose margins/grid aren't from a // Approximate is true for templates whose margins/grid aren't from a
// verified manufacturer spec sheet - print one test page on plain // verified manufacturer spec sheet - print one test page on plain
// paper and hold it up to a real label sheet before committing a // 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)`, Key: "5160", DisplayName: `Avery 5160 - 1" x 2-5/8" (30/sheet)`,
LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10, LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10,
MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0, MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0,
QRSizeIn: 0.85, QRSizeIn: 0.85, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4,
}, },
"5260": { "5260": {
// Same 30-per-sheet grid as 5160 (1" x 2-5/8"); 5260 is just the // 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)`, Key: "5260", DisplayName: `Avery 5260 - 1" x 2-5/8" (30/sheet, rounded corners)`,
LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10, LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10,
MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0, MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0,
QRSizeIn: 0.85, QRSizeIn: 0.85, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4,
}, },
"61526": { "61526": {
Key: "61526", DisplayName: `Asset Tag Labels 61526 - 3/4" x 2" (~48/sheet)`, Key: "61526", DisplayName: `Asset Tag Labels 61526 - 3/4" x 2" (~48/sheet)`,
LabelWidthIn: 2, LabelHeightIn: 0.75, Cols: 4, Rows: 12, LabelWidthIn: 2, LabelHeightIn: 0.75, Cols: 4, Rows: 12,
MarginTopIn: 1, MarginLeftIn: 0.25, ColGapIn: 0, RowGapIn: 0, 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; row-gap: %.4fin;
justify-content: center; justify-content: center;
} }
.label { .label, .label-blank {
width: %.4fin; width: %.4fin;
height: %.4fin; height: %.4fin;
box-sizing: border-box; box-sizing: border-box;
}
.label {
padding: 0.06in 0.08in; padding: 0.06in 0.08in;
display: flex;
align-items: center;
gap: 0.08in;
overflow: hidden; overflow: hidden;
page-break-inside: avoid; 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.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 { type labelItem struct {
@@ -91,7 +109,7 @@ type labelItem struct {
// never user input), so html/template's URL filter doesn't replace it // never user input), so html/template's URL filter doesn't replace it
// with the "#ZgotmplZ" safety placeholder the way it would a plain // with the "#ZgotmplZ" safety placeholder the way it would a plain
// string containing a data: URI. // string containing a data: URI.
QRData template.URL ImageData template.URL
} }
type labelsPageData struct { type labelsPageData struct {
@@ -101,17 +119,34 @@ type labelsPageData struct {
Templates map[string]LabelTemplate Templates map[string]LabelTemplate
TemplateKeys []string TemplateKeys []string
Selected LabelTemplate Selected LabelTemplate
AssetID string Format string // "qr" or "barcode"
FolderID string AssetIDs string
Recursive bool 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: func parseIDList(s string) []int64 {
// either a single asset (?asset_id=) or every asset in a folder var out []int64
// (?folder_id=&recursive=1), or the whole inventory with neither param. for _, part := range strings.Split(s, ",") {
// Each QR code encodes an absolute /scan/<tag> URL so a phone camera (or a part = strings.TrimSpace(part)
// USB barcode scanner focused on a browser address bar) opens straight to if part == "" {
// that asset. 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) { func (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
origin := requestOrigin(r) origin := requestOrigin(r)
@@ -121,46 +156,83 @@ func (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) {
tmpl = labelTemplates[defaultLabelTemplate] tmpl = labelTemplates[defaultLabelTemplate]
} }
var list []models.Asset startPos := queryInt(r, "start", 1)
assetIDParam := r.URL.Query().Get("asset_id") if startPos < 1 {
folderIDParam := r.URL.Query().Get("folder_id") startPos = 1
recursive := r.URL.Query().Get("recursive") == "1" }
if assetID, err := queryOptionalID(r, "asset_id"); err == nil && assetID != nil { assetIDsParam := r.URL.Query().Get("asset_ids")
asset, err := a.Assets.Get(ctx, *assetID) folderIDsParam := r.URL.Query().Get("folder_ids")
if err != nil {
writeErr(w, http.StatusNotFound, err.Error()) seen := map[int64]bool{}
return 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 assetIDsParam == "" && folderIDsParam == "" {
if folderID, err := queryOptionalID(r, "folder_id"); err == nil && folderID != nil { batch, _, err := a.Assets.List(ctx, assets.ListFilter{Page: 1, PageSize: 2000})
filter.FolderID = folderID
filter.FolderSet = true
filter.IncludeSub = recursive
}
batch, _, err := a.Assets.List(ctx, filter)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error()) writeErr(w, http.StatusInternalServerError, err.Error())
return 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)) labels := make([]labelItem, 0, len(list))
for _, as := range 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 { if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error()) writeErr(w, http.StatusInternalServerError, err.Error())
return 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{ a.Render(w, http.StatusOK, "labels", labelsPageData{
User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(), User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(),
Templates: labelTemplates, TemplateKeys: []string{"5160", "5260", "61526"}, 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 package web
import ( import (
"bytes"
"encoding/base64" "encoding/base64"
"image/png"
"net/http" "net/http"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/code128"
qrcode "github.com/skip2/go-qrcode" 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 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 // requestOrigin reconstructs the scheme+host the request arrived on, for
// building absolute URLs (e.g. QR codes need a full URL, not a path). // building absolute URLs (e.g. QR codes need a full URL, not a path).
func requestOrigin(r *http.Request) string { 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}/duplicate", a.requireAdmin(a.handleAssetDuplicate))
mux.HandleFunc("POST /app/assets/{id}/move", a.requireAdmin(a.handleAssetMove)) 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}/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("POST /app/assets/{id}/quantity", a.requireAdmin(a.handleAssetQuantity))
mux.HandleFunc("GET /app/users/options", a.requireAdmin(a.handleUserOptions)) mux.HandleFunc("GET /app/users/options", a.requireAdmin(a.handleUserOptions))
+9
View File
@@ -77,6 +77,15 @@ ul.tree-children {
font-weight: 500; 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 { .tree-row.drop-target {
outline: 2px dashed #0d6efd; outline: 2px dashed #0d6efd;
outline-offset: -2px; 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: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(id); } });
} }
items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?folder_id=' + id + '&recursive=1'; } }); items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?folder_id=' + id + '&recursive=1'; } });
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) { if (isAdmin) {
items.push({ divider: true }); items.push({ divider: true });
items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { startDeleteFolder(id); } }); items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { startDeleteFolder(id); } });
@@ -253,7 +253,7 @@
function assetMenuItems(id) { function assetMenuItems(id) {
var items = [{ label: 'Open', icon: 'bi-box-arrow-up-right', action: function () { navigateAndSelect('asset', 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) { if (isAdmin) {
items.push({ label: 'Edit', icon: 'bi-pencil', action: function () { loadAssetEditForm(id); } }); items.push({ label: 'Edit', icon: 'bi-pencil', action: function () { loadAssetEditForm(id); } });
items.push({ label: 'Check Out…', icon: 'bi-person-check', action: function () { openCheckoutModal(id); } }); items.push({ label: 'Check Out…', icon: 'bi-person-check', action: function () { openCheckoutModal(id); } });
@@ -298,6 +298,85 @@
return items; 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 ------------------------------------------------- // ---- Wiring: tree pane -------------------------------------------------
if (treePane) { if (treePane) {
@@ -312,8 +391,16 @@
var type = row.dataset.type; var type = row.dataset.type;
if (type !== 'folder' && type !== 'asset') return; // e.g. the "N more assets" row: no menu if (type !== 'folder' && type !== 'asset') return; // e.g. the "N more assets" row: no menu
e.preventDefault(); 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'); }); document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
row.classList.add('selected'); row.classList.add('selected');
selectionAnchorRow = row;
showContextMenu(e.clientX, e.clientY, type === 'folder' ? folderMenuItems(row.dataset.id) : assetMenuItems(row.dataset.id)); 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 moveAssetModalEl = document.getElementById('moveAssetModal');
var moveAssetModal = (moveAssetModalEl && window.bootstrap) ? new bootstrap.Modal(moveAssetModalEl) : null; 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; 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'); var sel = document.getElementById('moveAssetDestination');
sel.innerHTML = '<option value="">Inventory (top level)</option>'; sel.innerHTML = '<option value="">Inventory (top level)</option>';
try { try {
@@ -605,13 +697,18 @@
var confirmMoveAssetBtn = document.getElementById('confirmMoveAssetBtn'); var confirmMoveAssetBtn = document.getElementById('confirmMoveAssetBtn');
if (confirmMoveAssetBtn) { if (confirmMoveAssetBtn) {
confirmMoveAssetBtn.addEventListener('click', async function () { confirmMoveAssetBtn.addEventListener('click', async function () {
var id = moveAssetModalEl.dataset.assetId; var ids = moveAssetModalEl.dataset.assetIds.split(',');
var val = document.getElementById('moveAssetDestination').value; var val = document.getElementById('moveAssetDestination').value;
var folderId = val ? Number(val) : null;
try { 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(); moveAssetModal.hide();
await refreshWholeTree(); await refreshWholeTree();
await navigateAndSelect('asset', id); if (ids.length === 1) await navigateAndSelect('asset', ids[0]);
else await reloadCurrentPanel();
clearMultiSelection();
} catch (err) { } catch (err) {
alert('Move failed: ' + err.message); alert('Move failed: ' + err.message);
} }
@@ -625,17 +722,39 @@
var checkoutModalEl = document.getElementById('checkoutModal'); var checkoutModalEl = document.getElementById('checkoutModal');
var checkoutModal = (checkoutModalEl && window.bootstrap) ? new bootstrap.Modal(checkoutModalEl) : null; 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; 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 sel = document.getElementById('checkoutUserSelect');
var currentText = document.getElementById('checkoutCurrent'); var currentText = document.getElementById('checkoutCurrent');
sel.innerHTML = '<option value="">Unassigned (check in)</option>'; 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…'; currentText.textContent = 'Loading…';
try { try {
var asset = await apiFetch('/app/assets/' + id); var asset = await apiFetch('/app/assets/' + ids[0]);
var users = await apiFetch('/app/users/options'); var usersList = await apiFetch('/app/users/options');
(users || []).forEach(function (u) { (usersList || []).forEach(function (u) {
var opt = document.createElement('option'); var opt = document.createElement('option');
opt.value = u.id; opt.value = u.id;
opt.textContent = u.username; opt.textContent = u.username;
@@ -655,18 +774,82 @@
var confirmCheckoutBtn = document.getElementById('confirmCheckoutBtn'); var confirmCheckoutBtn = document.getElementById('confirmCheckoutBtn');
if (confirmCheckoutBtn) { if (confirmCheckoutBtn) {
confirmCheckoutBtn.addEventListener('click', async function () { confirmCheckoutBtn.addEventListener('click', async function () {
var id = checkoutModalEl.dataset.assetId; var ids = checkoutModalEl.dataset.assetIds.split(',');
var val = document.getElementById('checkoutUserSelect').value; var val = document.getElementById('checkoutUserSelect').value;
var userId = val ? Number(val) : null;
try { 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(); checkoutModal.hide();
await navigateAndSelect('asset', id); if (ids.length === 1) await navigateAndSelect('asset', ids[0]);
else await reloadCurrentPanel();
clearMultiSelection();
} catch (err) { } catch (err) {
alert('Checkout failed: ' + err.message); 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 // Asset form (create / edit) - shown in a modal
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -881,6 +1064,25 @@
var row = e.target.closest('.tree-row'); var row = e.target.closest('.tree-row');
if (row && row.dataset.type && !row.classList.contains('tree-row-more')) { 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); selectNode(row);
} }
}); });
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · inventree</title> <title>Sign in · {{siteName}}</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css"> <link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css"> <link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
@@ -14,7 +14,7 @@
<div class="col-11 col-sm-8 col-md-5 col-lg-4"> <div class="col-11 col-sm-8 col-md-5 col-lg-4">
<div class="text-center mb-4"> <div class="text-center mb-4">
<div class="display-6"><i class="bi bi-diagram-3-fill"></i></div> <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 class="text-body-secondary small">IT asset inventory</div>
</div> </div>
<div class="card shadow-sm"> <div class="card shadow-sm">
+26 -10
View File
@@ -5,27 +5,42 @@
body { margin: 0; background: #fff; } body { margin: 0; background: #fff; }
.label-sheet { margin: 0; } .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-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; } .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> </style>
<div class="print-toolbar d-flex flex-wrap align-items-center gap-3 p-3 border-bottom"> <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> <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> <span class="text-body-secondary">{{len .Labels}} label(s)</span>
<form method="get" class="d-flex align-items-center gap-2 ms-auto"> <form method="get" class="d-flex flex-wrap align-items-center gap-2 ms-auto">
{{if .AssetID}}<input type="hidden" name="asset_id" value="{{.AssetID}}">{{end}} {{if .AssetIDs}}<input type="hidden" name="asset_ids" value="{{.AssetIDs}}">{{end}}
{{if .FolderID}}<input type="hidden" name="folder_id" value="{{.FolderID}}">{{end}} {{if .FolderIDs}}<input type="hidden" name="folder_ids" value="{{.FolderIDs}}">{{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> <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()"> <select class="form-select form-select-sm" id="labelTemplateSelect" name="template" onchange="this.form.submit()">
{{range $key := .TemplateKeys}} {{range $key := .TemplateKeys}}
{{$t := index $.Templates $key}} {{$t := index $.Templates $key}}
<option value="{{$key}}" {{if eq $key $.Selected.Key}}selected{{end}}>{{$t.DisplayName}}</option> <option value="{{$key}}" {{if eq $key $.Selected.Key}}selected{{end}}>{{$t.DisplayName}}</option>
{{end}} {{end}}
</select> </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> </form>
</div> </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}} {{if .Selected.Approximate}}
<div class="alert alert-warning m-3"> <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 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}} {{end}}
<div class="label-sheet"> <div class="label-sheet">
{{range .BlankCells}}<div class="label-blank"></div>{{end}}
{{range .Labels}} {{range .Labels}}
<div class="label"> <div class="label format-{{$.Format}}">
<img src="{{.QRData}}" alt="QR code for {{.Asset.AssetTag}}"> <img src="{{.ImageData}}" alt="{{if eq $.Format "barcode"}}Barcode{{else}}QR code{{end}} for {{.Asset.AssetTag}}">
<div class="min-w-0"> <div class="min-w-0 label-text">
<div class="label-tag">{{.Asset.AssetTag}}</div> <div class="label-tag">{{.Asset.AssetTag}}</div>
<div class="label-name">{{.Asset.Name}}</div> <div class="label-name">{{.Asset.Name}}</div>
</div> </div>
+23 -1
View File
@@ -111,7 +111,7 @@
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -151,6 +151,28 @@
</div> </div>
</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...) --> <!-- Import CSV into a specific folder (right-click a folder > Import CSV...) -->
<div class="modal fade" id="importModal" tabindex="-1"> <div class="modal fade" id="importModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">