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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user