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