Add asset checkout, quantity/uniqueness, contacts, and QR labels
Checkout:
- assets.AssignUser + POST /app/assets/{id}/checkout assign/unassign an
asset's user independent of the full edit form. New shared
#checkoutModal, wired from a "Check Out..." context-menu action, backed
by GET /app/assets/{id} and GET /app/users/options JSON endpoints.
Quantity / uniqueness:
- Item types gain an IsUnique flag (checked by default, matching prior
behavior). Assets gain a Quantity column, fixed at 1 for unique types
and adjustable via +/- steppers (POST /app/assets/{id}/quantity) for
non-unique ones. Flipping a type back to unique clamps existing assets'
quantity back to 1. CSV import/export gained a Quantity column.
Migration 0003_quantity.sql.
Contacts:
- New models.RoleContact: an assignable person who cannot sign in
(User.CanLogin). Migration 0004_contacts.sql rebuilds the users table
(SQLite can't alter a CHECK constraint in place) to allow the role.
Users form/list updated; contact creation auto-generates an unusable
password since the login path is blocked by role regardless.
QR asset tags and printable labels:
- Each asset's QR code (via github.com/skip2/go-qrcode, pure Go) encodes
an absolute /scan/<tag> URL; GET /scan/{tag} resolves it and redirects
into /inventory?asset=<id>, which app.js picks up on load to
auto-select that asset - works from a phone camera or a USB
scanner-as-keyboard-wedge pointed at the address bar.
- GET /labels renders a print-ready sheet (right-click an asset for one
label, a folder for every asset in it) with a template switcher between
Avery 5160 (verified dimensions) and 61526 asset tag labels (best-effort
layout, flagged as such on the page).
Also refreshed README to reflect the right-click-first tree UX and all of
the above.
This commit is contained in:
@@ -66,8 +66,12 @@ asset).
|
|||||||
never fully reloads when you browse the tree.
|
never fully reloads when you browse the tree.
|
||||||
- **Drag and drop** folders and assets onto another folder (or onto
|
- **Drag and drop** folders and assets onto another folder (or onto
|
||||||
"Inventory" itself) to move them. Admin only.
|
"Inventory" itself) to move them. Admin only.
|
||||||
- **Right-click-style actions**: each folder has a "…" action menu (New
|
- **Right-click for everything**: it's meant to feel like a native file
|
||||||
Subfolder, Add Asset, Rename, Delete).
|
manager. Right-click a folder, an asset, a breadcrumb crumb, or empty
|
||||||
|
tree/panel background for the actions available there (New Subfolder, Add
|
||||||
|
Asset, Rename, Check Out, Move, Print Label, Delete, …) instead of hunting
|
||||||
|
for a toolbar button. The main panel itself stays blank until you actually
|
||||||
|
click into a folder or asset — browsing happens in the tree.
|
||||||
- **Deleting a folder** that still contains subfolders or assets requires
|
- **Deleting a folder** that still contains subfolders or assets requires
|
||||||
confirmation, and offers a safe "move contents elsewhere" option instead
|
confirmation, and offers a safe "move contents elsewhere" option instead
|
||||||
of an accidental cascade. A "permanently delete everything inside" option
|
of an accidental cascade. A "permanently delete everything inside" option
|
||||||
@@ -84,21 +88,63 @@ number, date, boolean, dropdown, URL, IP address), marked required or not,
|
|||||||
with a default value and display order. A representative starter set of
|
with a default value and display order. A representative starter set of
|
||||||
types and fields is seeded on first run — feel free to edit or delete it.
|
types and fields is seeded on first run — feel free to edit or delete it.
|
||||||
|
|
||||||
|
## Checkout, quantity, and contacts
|
||||||
|
|
||||||
|
- **Check out / check in**: right-click an asset → **Check Out…** to assign
|
||||||
|
it to a user (or clear the assignment to check it back in). Works from any
|
||||||
|
asset row, not just its detail view.
|
||||||
|
- **Quantity vs. unique**: item types have a **Unique** flag (checked by
|
||||||
|
default). Unique types — a laptop, a server — get one asset row per
|
||||||
|
physical unit, same as before. Uncheck it for bulk/consumable types (a USB
|
||||||
|
cable, a box of screws) and assets of that type are tracked as a single
|
||||||
|
row with a **Quantity** and +/- steppers in the asset panel and folder
|
||||||
|
tables, instead of one row per unit. Flipping a type back to Unique clamps
|
||||||
|
any existing quantities back down to 1.
|
||||||
|
- **Contacts**: under Settings → Users, a third role alongside Admin and
|
||||||
|
User. Contacts can't sign in at all — they exist purely so you can assign
|
||||||
|
assets to end users (e.g. "this laptop belongs to Jane in Accounting")
|
||||||
|
without creating them a login account. A blank password is fine when
|
||||||
|
creating one.
|
||||||
|
|
||||||
|
## QR asset tags and printable labels
|
||||||
|
|
||||||
|
Every asset can have a QR-coded label printed for it: right-click an asset
|
||||||
|
for **Print Label**, or right-click a folder for **Print Labels…** (every
|
||||||
|
asset in that folder, recursively). The QR code encodes an absolute
|
||||||
|
`/scan/<Asset ID>` URL, so scanning it with a phone camera — or a USB
|
||||||
|
barcode/QR scanner focused on the browser's address bar — opens straight to
|
||||||
|
that asset.
|
||||||
|
|
||||||
|
The print page supports two label sheet layouts, switchable from a dropdown
|
||||||
|
on the page itself:
|
||||||
|
|
||||||
|
- **Avery 5160** (1" x 2-5/8", 30/sheet) — standard address labels, exact
|
||||||
|
published dimensions.
|
||||||
|
- **61526** asset tag labels (3/4" x 2", ~48/sheet) — the grid/margins here
|
||||||
|
are a best-effort layout, not a verified spec sheet, so print one test
|
||||||
|
page on plain paper and check it against a real label before committing a
|
||||||
|
full (often non-refundable) sheet.
|
||||||
|
|
||||||
## CSV import / export
|
## CSV import / export
|
||||||
|
|
||||||
- **Export**: from the Inventory toolbar, or `/export/assets.csv` (optionally
|
- **Export**: from the tree's right-click menu, or `/export/assets.csv`
|
||||||
scoped to a folder, with `?recursive=1` to include subfolders). Includes
|
(optionally scoped to a folder, with `?recursive=1` to include
|
||||||
one `cf_<field_key>` column per distinct custom field in use.
|
subfolders). Includes a `Quantity` column and one `cf_<field_key>` column
|
||||||
- **Import**: CSV with columns `Asset ID` (optional — auto-generated if
|
per distinct custom field in use.
|
||||||
blank), `Name`, `Item Type`, `Folder` (a `/`-separated path, e.g.
|
- **Folder-scoped import**: right-click a folder → **Import CSV…** imports
|
||||||
`Main Office/Network`), `Status`, `Assigned User`, `Notes`, plus
|
every row directly into that folder. No `Folder` column needed — that's
|
||||||
`cf_<field_key>` columns for custom fields. Existing assets are matched
|
the whole point of doing it from a specific folder. Never available at the
|
||||||
and updated by Asset ID; everything else is created. Missing folders in
|
top level.
|
||||||
the path can optionally be created automatically.
|
- **Global import**: Settings → Import / Export has the full-featured
|
||||||
|
version — a CSV with a `Folder` column (a `/`-separated path, e.g.
|
||||||
|
`Main Office/Network`) that can optionally auto-create missing folders —
|
||||||
|
plus a separate CSV import for bulk-adding/updating Statuses (`Name`,
|
||||||
|
`Color`). Both matching-by-name/Asset-ID semantics and full column
|
||||||
|
references with examples are documented right on that page.
|
||||||
|
|
||||||
A full backup/restore of everything (folders, assets, types, custom fields,
|
A full backup/restore of everything (folders, assets, types, custom fields,
|
||||||
statuses) is just `inventree database backup` — it's a consistent snapshot
|
statuses, users) is just `inventree database backup` — it's a consistent
|
||||||
of the whole SQLite database.
|
snapshot of the whole SQLite database.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module inventree
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
|||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
|
|||||||
@@ -106,6 +106,24 @@ func (s *Store) tagExists(ctx context.Context, tag string, excludeID int64) (boo
|
|||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveQuantity clamps the requested quantity against the asset's item
|
||||||
|
// type: unique types are always exactly 1; non-unique types default to 1
|
||||||
|
// when the request is less than 1 (0 or unset), since AdjustQuantity is the
|
||||||
|
// intended path for deliberately going to 0 ("out of stock").
|
||||||
|
func (s *Store) resolveQuantity(ctx context.Context, itemTypeID int64, requested int) (int, error) {
|
||||||
|
var isUnique bool
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT is_unique FROM item_types WHERE id = ?`, itemTypeID).Scan(&isUnique); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if isUnique {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
if requested < 1 {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
return requested, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create inserts a new asset. If a.AssetTag is empty, one is generated.
|
// Create inserts a new asset. If a.AssetTag is empty, one is generated.
|
||||||
// fieldValues maps custom_field_defs.id to the submitted value.
|
// fieldValues maps custom_field_defs.id to the submitted value.
|
||||||
func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int64]string) error {
|
func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int64]string) error {
|
||||||
@@ -123,6 +141,11 @@ func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int
|
|||||||
} else if taken {
|
} else if taken {
|
||||||
return ErrTagTaken
|
return ErrTagTaken
|
||||||
}
|
}
|
||||||
|
quantity, err := s.resolveQuantity(ctx, a.ItemTypeID, a.Quantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.Quantity = quantity
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,9 +154,9 @@ func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
res, err := tx.ExecContext(ctx,
|
res, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes)
|
`INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes, quantity)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes)
|
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.Quantity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -173,6 +196,11 @@ func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int
|
|||||||
} else if taken {
|
} else if taken {
|
||||||
return ErrTagTaken
|
return ErrTagTaken
|
||||||
}
|
}
|
||||||
|
quantity, err := s.resolveQuantity(ctx, a.ItemTypeID, a.Quantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.Quantity = quantity
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -181,9 +209,9 @@ func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
_, err = tx.ExecContext(ctx,
|
||||||
`UPDATE assets SET asset_id = ?, name = ?, item_type_id = ?, folder_id = ?, status_id = ?, assigned_user_id = ?, notes = ?,
|
`UPDATE assets SET asset_id = ?, name = ?, item_type_id = ?, folder_id = ?, status_id = ?, assigned_user_id = ?, notes = ?, quantity = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||||
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.ID)
|
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.Quantity, a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -209,6 +237,42 @@ func (s *Store) Move(ctx context.Context, id int64, folderID *int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssignUser checks an asset out to a user, or checks it in (unassigns)
|
||||||
|
// when userID is nil. It touches only the assignment, nothing else.
|
||||||
|
func (s *Store) AssignUser(ctx context.Context, id int64, userID *int64) error {
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE assets SET assigned_user_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||||
|
userID, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrQuantityNotTracked = errors.New("quantity is not tracked for unique item types")
|
||||||
|
|
||||||
|
// AdjustQuantity increments or decrements an asset's quantity by delta,
|
||||||
|
// floored at 0. It fails for assets whose item type is unique (quantity
|
||||||
|
// stays fixed at 1 for those; use duplicate/create for more distinct
|
||||||
|
// units instead).
|
||||||
|
func (s *Store) AdjustQuantity(ctx context.Context, id int64, delta int) (*models.Asset, error) {
|
||||||
|
a, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a.ItemTypeIsUnique {
|
||||||
|
return nil, ErrQuantityNotTracked
|
||||||
|
}
|
||||||
|
newQty := a.Quantity + delta
|
||||||
|
if newQty < 0 {
|
||||||
|
newQty = 0
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE assets SET quantity = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||||
|
newQty, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.Quantity = newQty
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicate copies an asset's fields and custom field values into a new
|
// Duplicate copies an asset's fields and custom field values into a new
|
||||||
// asset with a freshly generated asset tag.
|
// asset with a freshly generated asset tag.
|
||||||
func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error) {
|
func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error) {
|
||||||
@@ -228,6 +292,7 @@ func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error)
|
|||||||
StatusID: orig.StatusID,
|
StatusID: orig.StatusID,
|
||||||
AssignedUserID: orig.AssignedUserID,
|
AssignedUserID: orig.AssignedUserID,
|
||||||
Notes: orig.Notes,
|
Notes: orig.Notes,
|
||||||
|
Quantity: orig.Quantity,
|
||||||
}
|
}
|
||||||
values := map[int64]string{}
|
values := map[int64]string{}
|
||||||
for _, fv := range orig.CustomFields {
|
for _, fv := range orig.CustomFields {
|
||||||
@@ -242,8 +307,8 @@ func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assetSelect = `
|
const assetSelect = `
|
||||||
SELECT a.id, a.asset_id, a.name, a.item_type_id, a.folder_id, a.status_id, a.assigned_user_id, a.notes, a.created_at, a.updated_at,
|
SELECT a.id, a.asset_id, a.name, a.item_type_id, a.folder_id, a.status_id, a.assigned_user_id, a.notes, a.quantity, a.created_at, a.updated_at,
|
||||||
it.name, it.icon, st.name, st.color, COALESCE(u.username, '')
|
it.name, it.icon, it.is_unique, st.name, st.color, COALESCE(u.username, '')
|
||||||
FROM assets a
|
FROM assets a
|
||||||
JOIN item_types it ON it.id = a.item_type_id
|
JOIN item_types it ON it.id = a.item_type_id
|
||||||
JOIN statuses st ON st.id = a.status_id
|
JOIN statuses st ON st.id = a.status_id
|
||||||
@@ -253,8 +318,8 @@ func scanAsset(row interface{ Scan(...any) error }) (*models.Asset, error) {
|
|||||||
var a models.Asset
|
var a models.Asset
|
||||||
var folderID, assignedUserID sql.NullInt64
|
var folderID, assignedUserID sql.NullInt64
|
||||||
var created, updated string
|
var created, updated string
|
||||||
if err := row.Scan(&a.ID, &a.AssetTag, &a.Name, &a.ItemTypeID, &folderID, &a.StatusID, &assignedUserID, &a.Notes, &created, &updated,
|
if err := row.Scan(&a.ID, &a.AssetTag, &a.Name, &a.ItemTypeID, &folderID, &a.StatusID, &assignedUserID, &a.Notes, &a.Quantity, &created, &updated,
|
||||||
&a.ItemTypeName, &a.ItemTypeIcon, &a.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil {
|
&a.ItemTypeName, &a.ItemTypeIcon, &a.ItemTypeIsUnique, &a.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if folderID.Valid {
|
if folderID.Valid {
|
||||||
|
|||||||
+12
-1
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"inventree/internal/models"
|
"inventree/internal/models"
|
||||||
@@ -19,6 +20,7 @@ const (
|
|||||||
colStatus = "status"
|
colStatus = "status"
|
||||||
colAssignedUser = "assigned user"
|
colAssignedUser = "assigned user"
|
||||||
colNotes = "notes"
|
colNotes = "notes"
|
||||||
|
colQuantity = "quantity"
|
||||||
cfColPrefix = "cf_"
|
cfColPrefix = "cf_"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -201,6 +203,13 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
quantity := 1
|
||||||
|
if qtyStr := get(row, colQuantity); qtyStr != "" {
|
||||||
|
if n, err := strconv.Atoi(qtyStr); err == nil {
|
||||||
|
quantity = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
asset := &models.Asset{
|
asset := &models.Asset{
|
||||||
AssetTag: get(row, colAssetID),
|
AssetTag: get(row, colAssetID),
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -209,6 +218,7 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
|
|||||||
StatusID: statusID,
|
StatusID: statusID,
|
||||||
AssignedUserID: assignedUserID,
|
AssignedUserID: assignedUserID,
|
||||||
Notes: get(row, colNotes),
|
Notes: get(row, colNotes),
|
||||||
|
Quantity: quantity,
|
||||||
}
|
}
|
||||||
|
|
||||||
if asset.AssetTag != "" {
|
if asset.AssetTag != "" {
|
||||||
@@ -282,7 +292,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cw := csv.NewWriter(w)
|
cw := csv.NewWriter(w)
|
||||||
header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Created At", "Updated At"}
|
header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Quantity", "Created At", "Updated At"}
|
||||||
for _, k := range fieldDefs {
|
for _, k := range fieldDefs {
|
||||||
header = append(header, cfColPrefix+k)
|
header = append(header, cfColPrefix+k)
|
||||||
}
|
}
|
||||||
@@ -304,6 +314,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
|
|||||||
}
|
}
|
||||||
row := []string{
|
row := []string{
|
||||||
a.AssetTag, a.Name, a.ItemTypeName, folderPathString(a.FolderPath), a.StatusName, a.AssignedUsername, a.Notes,
|
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"),
|
a.CreatedAt.Format("2006-01-02 15:04:05"), a.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
values := map[string]string{}
|
values := map[string]string{}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package assets_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"inventree/internal/assets"
|
||||||
|
"inventree/internal/itemtypes"
|
||||||
|
"inventree/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQuantityFixedAtOneForUniqueTypes(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
as, _, _ := testStores(t)
|
||||||
|
|
||||||
|
a := &models.Asset{Name: "Laptop 1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, Quantity: 7}
|
||||||
|
if err := as.Create(ctx, a, nil); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if a.Quantity != 1 {
|
||||||
|
t.Fatalf("expected quantity forced to 1 for a unique item type, got %d", a.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := as.AdjustQuantity(ctx, a.ID, 1); err != assets.ErrQuantityNotTracked {
|
||||||
|
t.Fatalf("expected ErrQuantityNotTracked, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuantityTrackedForNonUniqueTypes(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
as, _, db := testStores(t)
|
||||||
|
itStore := itemtypes.NewStore(db)
|
||||||
|
|
||||||
|
cable := &models.ItemType{Name: "Cable", Enabled: true, IsUnique: false}
|
||||||
|
if err := itStore.Create(ctx, cable); err != nil {
|
||||||
|
t.Fatalf("create item type: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &models.Asset{Name: "USB-A Cable", ItemTypeID: cable.ID, StatusID: statusInUse, Quantity: 10}
|
||||||
|
if err := as.Create(ctx, a, nil); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if a.Quantity != 10 {
|
||||||
|
t.Fatalf("expected quantity 10, got %d", a.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := as.AdjustQuantity(ctx, a.ID, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adjust +5: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Quantity != 15 {
|
||||||
|
t.Fatalf("expected 15, got %d", updated.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err = as.AdjustQuantity(ctx, a.ID, -100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adjust -100: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Quantity != 0 {
|
||||||
|
t.Fatalf("expected quantity floored at 0, got %d", updated.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemTypeBecomingUniqueClampsExistingQuantities(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
as, _, db := testStores(t)
|
||||||
|
itStore := itemtypes.NewStore(db)
|
||||||
|
|
||||||
|
cable := &models.ItemType{Name: "Cable", Enabled: true, IsUnique: false}
|
||||||
|
if err := itStore.Create(ctx, cable); err != nil {
|
||||||
|
t.Fatalf("create item type: %v", err)
|
||||||
|
}
|
||||||
|
a := &models.Asset{Name: "USB-A Cable", ItemTypeID: cable.ID, StatusID: statusInUse, Quantity: 20}
|
||||||
|
if err := as.Create(ctx, a, nil); err != nil {
|
||||||
|
t.Fatalf("create asset: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cable.IsUnique = true
|
||||||
|
if err := itStore.Update(ctx, cable); err != nil {
|
||||||
|
t.Fatalf("update item type: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := as.Get(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Quantity != 1 {
|
||||||
|
t.Fatalf("expected quantity clamped to 1 after type became unique, got %d", got.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignUserChecksInAndOut(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
as, _, _ := testStores(t)
|
||||||
|
|
||||||
|
a := &models.Asset{Name: "Laptop 1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse}
|
||||||
|
if err := as.Create(ctx, a, nil); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := int64(1) // seeded admin
|
||||||
|
if err := as.AssignUser(ctx, a.ID, &userID); err != nil {
|
||||||
|
t.Fatalf("assign user: %v", err)
|
||||||
|
}
|
||||||
|
got, err := as.Get(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.AssignedUserID == nil || *got.AssignedUserID != userID {
|
||||||
|
t.Fatalf("expected asset checked out to user %d, got %+v", userID, got.AssignedUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := as.AssignUser(ctx, a.ID, nil); err != nil {
|
||||||
|
t.Fatalf("check in: %v", err)
|
||||||
|
}
|
||||||
|
got, err = as.Get(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.AssignedUserID != nil {
|
||||||
|
t.Fatalf("expected asset checked in (unassigned), got %+v", got.AssignedUserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Track quantity for non-unique (bulk/consumable) item types, e.g. cables,
|
||||||
|
-- while leaving serialized items (laptops, servers) fixed at quantity 1.
|
||||||
|
|
||||||
|
ALTER TABLE item_types ADD COLUMN is_unique INTEGER NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE assets ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Add a "contact" role: an assignable person (end user) who cannot sign
|
||||||
|
-- into the application, as distinct from admin/user login accounts.
|
||||||
|
-- SQLite can't alter a CHECK constraint in place, so the table is rebuilt.
|
||||||
|
|
||||||
|
CREATE TABLE users_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('admin', 'user', 'contact')),
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users_new (id, username, password_hash, role, enabled, created_at, updated_at)
|
||||||
|
SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users;
|
||||||
|
|
||||||
|
DROP TABLE users;
|
||||||
|
ALTER TABLE users_new RENAME TO users;
|
||||||
@@ -58,7 +58,7 @@ func validFieldType(t models.FieldType) bool {
|
|||||||
// List returns all item types, optionally including disabled ones, each with
|
// List returns all item types, optionally including disabled ones, each with
|
||||||
// its custom field definitions loaded.
|
// its custom field definitions loaded.
|
||||||
func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemType, error) {
|
func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemType, error) {
|
||||||
q := `SELECT id, name, description, icon, enabled, sort_order, created_at, updated_at FROM item_types`
|
q := `SELECT id, name, description, icon, enabled, sort_order, is_unique, created_at, updated_at FROM item_types`
|
||||||
if !includeDisabled {
|
if !includeDisabled {
|
||||||
q += ` WHERE enabled = 1`
|
q += ` WHERE enabled = 1`
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemTy
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var it models.ItemType
|
var it models.ItemType
|
||||||
var created, updated string
|
var created, updated string
|
||||||
if err := rows.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &created, &updated); err != nil {
|
if err := rows.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &it.IsUnique, &created, &updated); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
it.CreatedAt = parseTime(created)
|
it.CreatedAt = parseTime(created)
|
||||||
@@ -93,10 +93,10 @@ func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemTy
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Get(ctx context.Context, id int64) (*models.ItemType, error) {
|
func (s *Store) Get(ctx context.Context, id int64) (*models.ItemType, error) {
|
||||||
row := s.db.QueryRowContext(ctx, `SELECT id, name, description, icon, enabled, sort_order, created_at, updated_at FROM item_types WHERE id = ?`, id)
|
row := s.db.QueryRowContext(ctx, `SELECT id, name, description, icon, enabled, sort_order, is_unique, created_at, updated_at FROM item_types WHERE id = ?`, id)
|
||||||
var it models.ItemType
|
var it models.ItemType
|
||||||
var created, updated string
|
var created, updated string
|
||||||
err := row.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &created, &updated)
|
err := row.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &it.IsUnique, &created, &updated)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -146,8 +146,8 @@ func (s *Store) Create(ctx context.Context, it *models.ItemType) error {
|
|||||||
it.Icon = "bi-box-seam"
|
it.Icon = "bi-box-seam"
|
||||||
}
|
}
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO item_types (name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?)`,
|
`INSERT INTO item_types (name, description, icon, enabled, sort_order, is_unique) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder)
|
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.IsUnique)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -164,8 +164,17 @@ func (s *Store) Update(ctx context.Context, it *models.ItemType) error {
|
|||||||
return ErrNameRequired
|
return ErrNameRequired
|
||||||
}
|
}
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
`UPDATE item_types SET name = ?, description = ?, icon = ?, enabled = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
`UPDATE item_types SET name = ?, description = ?, icon = ?, enabled = ?, sort_order = ?, is_unique = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||||
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.ID)
|
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.IsUnique, it.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if it.IsUnique {
|
||||||
|
// Item type just became (or remained) unique: bring every asset
|
||||||
|
// of this type back to quantity 1 so the two concepts can't drift
|
||||||
|
// out of sync.
|
||||||
|
_, err = s.db.ExecContext(ctx, `UPDATE assets SET quantity = 1 WHERE item_type_id = ? AND quantity != 1`, it.ID)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ type Role string
|
|||||||
const (
|
const (
|
||||||
RoleAdmin Role = "admin"
|
RoleAdmin Role = "admin"
|
||||||
RoleUser Role = "user"
|
RoleUser Role = "user"
|
||||||
|
// RoleContact is an assignable person (end user) with no ability to
|
||||||
|
// sign in - useful for tracking who a checked-out asset belongs to
|
||||||
|
// without needing to create a login account for everyone in a
|
||||||
|
// building.
|
||||||
|
RoleContact Role = "contact"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
@@ -22,6 +27,10 @@ type User struct {
|
|||||||
|
|
||||||
func (u User) IsAdmin() bool { return u.Role == RoleAdmin }
|
func (u User) IsAdmin() bool { return u.Role == RoleAdmin }
|
||||||
|
|
||||||
|
// CanLogin reports whether this account is allowed to sign in at all;
|
||||||
|
// contacts exist only to be assigned assets.
|
||||||
|
func (u User) CanLogin() bool { return u.Role == RoleAdmin || u.Role == RoleUser }
|
||||||
|
|
||||||
type Folder struct {
|
type Folder struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
@@ -96,6 +105,12 @@ type ItemType struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
// IsUnique means each asset of this type is its own distinct item
|
||||||
|
// (a laptop with its own serial number, tracked as one row per unit).
|
||||||
|
// When false, assets of this type are bulk/consumable line items
|
||||||
|
// (e.g. "USB-A Cable") tracked by Quantity instead of one row each.
|
||||||
|
IsUnique bool
|
||||||
|
|
||||||
Fields []CustomFieldDef
|
Fields []CustomFieldDef
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,12 +144,16 @@ type Asset struct {
|
|||||||
StatusID int64
|
StatusID int64
|
||||||
AssignedUserID *int64
|
AssignedUserID *int64
|
||||||
Notes string
|
Notes string
|
||||||
CreatedAt time.Time
|
// Quantity is meaningful only when the asset's item type is not
|
||||||
UpdatedAt time.Time
|
// IsUnique; unique-type assets are always kept at 1.
|
||||||
|
Quantity int
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
|
||||||
// Populated by joined list/detail queries.
|
// Populated by joined list/detail queries.
|
||||||
ItemTypeName string
|
ItemTypeName string
|
||||||
ItemTypeIcon string
|
ItemTypeIcon string
|
||||||
|
ItemTypeIsUnique bool
|
||||||
StatusName string
|
StatusName string
|
||||||
StatusColor string
|
StatusColor string
|
||||||
AssignedUsername string
|
AssignedUsername string
|
||||||
|
|||||||
+10
-3
@@ -16,10 +16,14 @@ var (
|
|||||||
ErrNotFound = errors.New("user not found")
|
ErrNotFound = errors.New("user not found")
|
||||||
ErrUsernameRequired = errors.New("username is required")
|
ErrUsernameRequired = errors.New("username is required")
|
||||||
ErrUsernameTaken = errors.New("username is already taken")
|
ErrUsernameTaken = errors.New("username is already taken")
|
||||||
ErrInvalidRole = errors.New("role must be admin or user")
|
ErrInvalidRole = errors.New("role must be admin, user, or contact")
|
||||||
ErrLastAdmin = errors.New("cannot remove the last enabled admin")
|
ErrLastAdmin = errors.New("cannot remove the last enabled admin")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func validRole(r models.Role) bool {
|
||||||
|
return r == models.RoleAdmin || r == models.RoleUser || r == models.RoleContact
|
||||||
|
}
|
||||||
|
|
||||||
type Store struct{ db *sql.DB }
|
type Store struct{ db *sql.DB }
|
||||||
|
|
||||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||||
@@ -82,7 +86,7 @@ func (s *Store) Create(ctx context.Context, u *models.User, plaintextPassword st
|
|||||||
if u.Username == "" {
|
if u.Username == "" {
|
||||||
return ErrUsernameRequired
|
return ErrUsernameRequired
|
||||||
}
|
}
|
||||||
if u.Role != models.RoleAdmin && u.Role != models.RoleUser {
|
if !validRole(u.Role) {
|
||||||
return ErrInvalidRole
|
return ErrInvalidRole
|
||||||
}
|
}
|
||||||
if _, err := s.GetByUsername(ctx, u.Username); err == nil {
|
if _, err := s.GetByUsername(ctx, u.Username); err == nil {
|
||||||
@@ -113,7 +117,7 @@ func (s *Store) Update(ctx context.Context, u *models.User) error {
|
|||||||
if u.Username == "" {
|
if u.Username == "" {
|
||||||
return ErrUsernameRequired
|
return ErrUsernameRequired
|
||||||
}
|
}
|
||||||
if u.Role != models.RoleAdmin && u.Role != models.RoleUser {
|
if !validRole(u.Role) {
|
||||||
return ErrInvalidRole
|
return ErrInvalidRole
|
||||||
}
|
}
|
||||||
if existing, err := s.GetByUsername(ctx, u.Username); err == nil && existing.ID != u.ID {
|
if existing, err := s.GetByUsername(ctx, u.Username); err == nil && existing.ID != u.ID {
|
||||||
@@ -179,6 +183,9 @@ func (s *Store) Authenticate(ctx context.Context, username, password string) (*m
|
|||||||
if !u.Enabled {
|
if !u.Enabled {
|
||||||
return nil, errors.New("account is disabled")
|
return nil, errors.New("account is disabled")
|
||||||
}
|
}
|
||||||
|
if !u.CanLogin() {
|
||||||
|
return nil, errors.New("this account cannot sign in")
|
||||||
|
}
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||||
return nil, errors.New("invalid credentials")
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type assetRequest struct {
|
|||||||
StatusID int64 `json:"status_id"`
|
StatusID int64 `json:"status_id"`
|
||||||
AssignedUserID *int64 `json:"assigned_user_id"`
|
AssignedUserID *int64 `json:"assigned_user_id"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
Fields map[string]string `json:"fields"`
|
Fields map[string]string `json:"fields"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ func (a *App) handleAssetCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
asset := &models.Asset{
|
asset := &models.Asset{
|
||||||
AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: req.ItemTypeID, FolderID: req.FolderID,
|
AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: req.ItemTypeID, FolderID: req.FolderID,
|
||||||
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes,
|
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes, Quantity: req.Quantity,
|
||||||
}
|
}
|
||||||
if err := a.Assets.Create(r.Context(), asset, req.fieldValues()); err != nil {
|
if err := a.Assets.Create(r.Context(), asset, req.fieldValues()); err != nil {
|
||||||
jsonErr(w, http.StatusBadRequest, err)
|
jsonErr(w, http.StatusBadRequest, err)
|
||||||
@@ -65,7 +66,7 @@ func (a *App) handleAssetUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
asset := &models.Asset{
|
asset := &models.Asset{
|
||||||
ID: id, AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: existing.ItemTypeID, FolderID: req.FolderID,
|
ID: id, AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: existing.ItemTypeID, FolderID: req.FolderID,
|
||||||
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes,
|
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes, Quantity: req.Quantity,
|
||||||
}
|
}
|
||||||
if err := a.Assets.Update(r.Context(), asset, req.fieldValues()); err != nil {
|
if err := a.Assets.Update(r.Context(), asset, req.fieldValues()); err != nil {
|
||||||
jsonErr(w, http.StatusBadRequest, err)
|
jsonErr(w, http.StatusBadRequest, err)
|
||||||
@@ -127,3 +128,69 @@ func (a *App) handleAssetMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
jsonOK(w, map[string]any{"id": id, "folder_id": req.FolderID})
|
jsonOK(w, map[string]any{"id": id, "folder_id": req.FolderID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAssetGet returns a single asset as JSON, for lightweight lookups
|
||||||
|
// (e.g. the checkout modal) that don't need the full HTML detail panel.
|
||||||
|
func (a *App) handleAssetGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asset, err := a.Assets.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
type assetCheckoutRequest struct {
|
||||||
|
AssignedUserID *int64 `json:"assigned_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAssetCheckout assigns (checks out) or unassigns (checks in) an
|
||||||
|
// asset's user without touching any other field.
|
||||||
|
func (a *App) handleAssetCheckout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req assetCheckoutRequest
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
jsonErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Assets.AssignUser(r.Context(), id, req.AssignedUserID); err != nil {
|
||||||
|
jsonErr(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]any{"id": id, "assigned_user_id": req.AssignedUserID})
|
||||||
|
}
|
||||||
|
|
||||||
|
type assetQuantityRequest struct {
|
||||||
|
Delta int `json:"delta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAssetQuantity increments or decrements a non-unique asset's
|
||||||
|
// quantity in place, for the +/- steppers on the asset panel and folder
|
||||||
|
// tables.
|
||||||
|
func (a *App) handleAssetQuantity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req assetQuantityRequest
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
jsonErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asset, err := a.Assets.AdjustQuantity(r.Context(), id, req.Delta)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]any{"id": id, "quantity": asset.Quantity})
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ type InventoryPageData struct {
|
|||||||
Tree []models.TreeNode
|
Tree []models.TreeNode
|
||||||
ItemTypes []models.ItemType
|
ItemTypes []models.ItemType
|
||||||
Statuses []models.Status
|
Statuses []models.Status
|
||||||
|
|
||||||
|
// AutoOpenAssetID and ScanError come from a redirect out of
|
||||||
|
// /scan/<tag> (a QR/barcode label scan): either the asset to select
|
||||||
|
// on load, or the tag that didn't match anything.
|
||||||
|
AutoOpenAssetID string
|
||||||
|
ScanError string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -42,6 +48,7 @@ func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
a.Render(w, http.StatusOK, "inventory", InventoryPageData{
|
a.Render(w, http.StatusOK, "inventory", InventoryPageData{
|
||||||
User: u, Tree: tree, ItemTypes: itemTypes, Statuses: statusList,
|
User: u, Tree: tree, ItemTypes: itemTypes, Statuses: statusList,
|
||||||
|
AutoOpenAssetID: r.URL.Query().Get("asset"), ScanError: r.URL.Query().Get("scan_error"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,14 +215,16 @@ func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// AssetFormData backs both the "new asset" and "edit asset" forms.
|
// AssetFormData backs both the "new asset" and "edit asset" forms.
|
||||||
type AssetFormData struct {
|
type AssetFormData struct {
|
||||||
Asset *models.Asset // nil when creating
|
Asset *models.Asset // nil when creating
|
||||||
FolderID *int64
|
FolderID *int64
|
||||||
ItemTypes []models.ItemType
|
ItemTypes []models.ItemType
|
||||||
Statuses []models.Status
|
Statuses []models.Status
|
||||||
Users []models.User
|
Users []models.User
|
||||||
FolderOptions []FolderOption
|
FolderOptions []FolderOption
|
||||||
SelectedIT int64
|
SelectedIT int64
|
||||||
Fields []models.AssetFieldValue
|
SelectedITUnique bool
|
||||||
|
Quantity int
|
||||||
|
Fields []models.AssetFieldValue
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -247,15 +256,17 @@ func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var selectedIT int64
|
var selectedIT int64
|
||||||
|
var selectedITUnique bool
|
||||||
var fields []models.AssetFieldValue
|
var fields []models.AssetFieldValue
|
||||||
if len(itemTypes) > 0 {
|
if len(itemTypes) > 0 {
|
||||||
selectedIT = itemTypes[0].ID
|
selectedIT = itemTypes[0].ID
|
||||||
|
selectedITUnique = itemTypes[0].IsUnique
|
||||||
fields = fieldValuesForCreate(itemTypes[0].Fields)
|
fields = fieldValuesForCreate(itemTypes[0].Fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
|
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
|
||||||
FolderID: folderID, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
|
FolderID: folderID, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
|
||||||
SelectedIT: selectedIT, Fields: fields,
|
SelectedIT: selectedIT, SelectedITUnique: selectedITUnique, Quantity: 1, Fields: fields,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,13 +305,23 @@ func (a *App) handlePanelAssetEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
|
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
|
||||||
Asset: asset, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
|
Asset: asset, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
|
||||||
SelectedIT: asset.ItemTypeID, Fields: asset.CustomFields,
|
SelectedIT: asset.ItemTypeID, SelectedITUnique: asset.ItemTypeIsUnique, Quantity: asset.Quantity, Fields: asset.CustomFields,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAssetFieldsFragment renders just the dynamic custom-field inputs for
|
// AssetDynamicFieldsData backs the "asset_fields_only" partial: the
|
||||||
// a chosen item type, used when the item type dropdown changes on the
|
// quantity input (only relevant for non-unique item types) plus the
|
||||||
// "new asset" form.
|
// type's custom fields. It's swapped in whenever the item type dropdown
|
||||||
|
// changes on the "new asset" form, and also used for the initial render.
|
||||||
|
type AssetDynamicFieldsData struct {
|
||||||
|
IsUnique bool
|
||||||
|
Quantity int
|
||||||
|
Fields []models.AssetFieldValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAssetFieldsFragment renders just the dynamic quantity/custom-field
|
||||||
|
// inputs for a chosen item type, used when the item type dropdown changes
|
||||||
|
// on the "new asset" form.
|
||||||
func (a *App) handleAssetFieldsFragment(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleAssetFieldsFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
itemTypeID := queryInt64(r, "item_type_id")
|
itemTypeID := queryInt64(r, "item_type_id")
|
||||||
@@ -309,5 +330,7 @@ func (a *App) handleAssetFieldsFragment(w http.ResponseWriter, r *http.Request)
|
|||||||
writeErr(w, http.StatusNotFound, err.Error())
|
writeErr(w, http.StatusNotFound, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.RenderFragment(w, http.StatusOK, "asset_fields_only", fieldValuesForCreate(it.Fields))
|
a.RenderFragment(w, http.StatusOK, "asset_fields_only", AssetDynamicFieldsData{
|
||||||
|
IsUnique: it.IsUnique, Quantity: 1, Fields: fieldValuesForCreate(it.Fields),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func (a *App) handleItemTypeCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
it := &models.ItemType{
|
it := &models.ItemType{
|
||||||
Name: r.FormValue("name"), Description: r.FormValue("description"),
|
Name: r.FormValue("name"), Description: r.FormValue("description"),
|
||||||
Icon: r.FormValue("icon"), Enabled: r.FormValue("enabled") == "on", SortOrder: atoiDefault(r.FormValue("sort_order"), 0),
|
Icon: r.FormValue("icon"), Enabled: r.FormValue("enabled") == "on", SortOrder: atoiDefault(r.FormValue("sort_order"), 0),
|
||||||
|
IsUnique: r.FormValue("is_unique") == "on",
|
||||||
}
|
}
|
||||||
if err := a.ItemTypes.Create(r.Context(), it); err != nil {
|
if err := a.ItemTypes.Create(r.Context(), it); err != nil {
|
||||||
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
|
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
|
||||||
@@ -83,6 +84,7 @@ func (a *App) handleItemTypeUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
it.Icon = r.FormValue("icon")
|
it.Icon = r.FormValue("icon")
|
||||||
it.Enabled = r.FormValue("enabled") == "on"
|
it.Enabled = r.FormValue("enabled") == "on"
|
||||||
it.SortOrder = atoiDefault(r.FormValue("sort_order"), it.SortOrder)
|
it.SortOrder = atoiDefault(r.FormValue("sort_order"), it.SortOrder)
|
||||||
|
it.IsUnique = r.FormValue("is_unique") == "on"
|
||||||
if err := a.ItemTypes.Update(r.Context(), it); err != nil {
|
if err := a.ItemTypes.Update(r.Context(), it); err != nil {
|
||||||
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
|
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"inventree/internal/assets"
|
||||||
|
"inventree/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
// full (often expensive/waterproof) sheet to the printer.
|
||||||
|
Approximate bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var labelTemplates = map[string]LabelTemplate{
|
||||||
|
"5160": {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLabelTemplate = "5160"
|
||||||
|
|
||||||
|
func (t LabelTemplate) css() template.CSS {
|
||||||
|
return template.CSS(fmt.Sprintf(`
|
||||||
|
.label-sheet {
|
||||||
|
width: 8.5in;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: %.4fin %.4fin;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(%d, %.4fin);
|
||||||
|
column-gap: %.4fin;
|
||||||
|
row-gap: %.4fin;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
width: %.4fin;
|
||||||
|
height: %.4fin;
|
||||||
|
box-sizing: border-box;
|
||||||
|
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; }
|
||||||
|
`,
|
||||||
|
t.MarginTopIn, t.MarginLeftIn, t.Cols, t.LabelWidthIn, t.ColGapIn, t.RowGapIn,
|
||||||
|
t.LabelWidthIn, t.LabelHeightIn, t.QRSizeIn, t.QRSizeIn))
|
||||||
|
}
|
||||||
|
|
||||||
|
type labelItem struct {
|
||||||
|
Asset models.Asset
|
||||||
|
// template.URL marks this as pre-vetted safe (it's server-generated,
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
type labelsPageData struct {
|
||||||
|
User *models.User
|
||||||
|
Labels []labelItem
|
||||||
|
SheetCSS template.CSS
|
||||||
|
Templates map[string]LabelTemplate
|
||||||
|
TemplateKeys []string
|
||||||
|
Selected LabelTemplate
|
||||||
|
AssetID string
|
||||||
|
FolderID string
|
||||||
|
Recursive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
origin := requestOrigin(r)
|
||||||
|
|
||||||
|
tmpl, ok := labelTemplates[r.URL.Query().Get("template")]
|
||||||
|
if !ok {
|
||||||
|
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"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list = batch
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := make([]labelItem, 0, len(list))
|
||||||
|
for _, as := range list {
|
||||||
|
qr, 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)})
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Render(w, http.StatusOK, "labels", labelsPageData{
|
||||||
|
User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(),
|
||||||
|
Templates: labelTemplates, TemplateKeys: []string{"5160", "61526"},
|
||||||
|
Selected: tmpl, AssetID: assetIDParam, FolderID: folderIDParam, Recursive: recursive,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleScan resolves a scanned or printed asset tag (the payload of a
|
||||||
|
// label's QR code) and redirects into the inventory page with that asset
|
||||||
|
// selected. Scanning with a phone camera opens this URL directly; a USB
|
||||||
|
// barcode scanner focused on the browser's address bar works the same way.
|
||||||
|
func (a *App) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tag := r.PathValue("tag")
|
||||||
|
asset, err := a.Assets.GetByTag(r.Context(), tag)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/inventory?scan_error="+url.QueryEscape(tag), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/inventory?asset=%d", asset.ID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -1,12 +1,38 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"inventree/internal/models"
|
"inventree/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// parseRole maps a form's role value to a models.Role, defaulting to
|
||||||
|
// RoleUser for anything unrecognized.
|
||||||
|
func parseRole(s string) models.Role {
|
||||||
|
switch s {
|
||||||
|
case "admin":
|
||||||
|
return models.RoleAdmin
|
||||||
|
case "contact":
|
||||||
|
return models.RoleContact
|
||||||
|
default:
|
||||||
|
return models.RoleUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomPassword generates an unguessable password for contact accounts,
|
||||||
|
// which can never sign in (see models.User.CanLogin) but still need some
|
||||||
|
// non-empty password_hash in storage.
|
||||||
|
func randomPassword() (string, error) {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
type usersListData struct {
|
type usersListData struct {
|
||||||
User *models.User
|
User *models.User
|
||||||
Users []models.User
|
Users []models.User
|
||||||
@@ -21,6 +47,28 @@ func (a *App) handleUsersList(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.Render(w, http.StatusOK, "users_list", usersListData{User: currentUser(r), Users: list})
|
a.Render(w, http.StatusOK, "users_list", usersListData{User: currentUser(r), Users: list})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUserOptions returns enabled users as JSON, for pickers like the
|
||||||
|
// asset checkout modal (a currently-assigned but since-disabled user still
|
||||||
|
// displays correctly elsewhere; they just won't appear as a new choice).
|
||||||
|
func (a *App) handleUserOptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := a.Users.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type option struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
out := make([]option, 0, len(list))
|
||||||
|
for _, u := range list {
|
||||||
|
if u.Enabled {
|
||||||
|
out = append(out, option{ID: u.ID, Username: u.Username})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonOK(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
type userFormData struct {
|
type userFormData struct {
|
||||||
User *models.User
|
User *models.User
|
||||||
EditUser *models.User
|
EditUser *models.User
|
||||||
@@ -36,15 +84,20 @@ func (a *App) handleUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusBadRequest, err.Error())
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
role := models.RoleUser
|
role := parseRole(r.FormValue("role"))
|
||||||
if r.FormValue("role") == "admin" {
|
|
||||||
role = models.RoleAdmin
|
|
||||||
}
|
|
||||||
u := &models.User{Username: r.FormValue("username"), Role: role, Enabled: r.FormValue("enabled") == "on"}
|
u := &models.User{Username: r.FormValue("username"), Role: role, Enabled: r.FormValue("enabled") == "on"}
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
if password == "" {
|
if password == "" {
|
||||||
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: "Password is required."})
|
if role != models.RoleContact {
|
||||||
return
|
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: "Password is required."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
generated, err := randomPassword()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password = generated
|
||||||
}
|
}
|
||||||
if err := a.Users.Create(r.Context(), u, password); err != nil {
|
if err := a.Users.Create(r.Context(), u, password); err != nil {
|
||||||
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
|
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
|
||||||
@@ -83,10 +136,7 @@ func (a *App) handleUserUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.Username = r.FormValue("username")
|
u.Username = r.FormValue("username")
|
||||||
u.Role = models.RoleUser
|
u.Role = parseRole(r.FormValue("role"))
|
||||||
if r.FormValue("role") == "admin" {
|
|
||||||
u.Role = models.RoleAdmin
|
|
||||||
}
|
|
||||||
u.Enabled = r.FormValue("enabled") == "on"
|
u.Enabled = r.FormValue("enabled") == "on"
|
||||||
if err := a.Users.Update(r.Context(), u); err != nil {
|
if err := a.Users.Update(r.Context(), u); err != nil {
|
||||||
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
|
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// qrDataURI renders content as a QR code PNG and returns it as a data: URI
|
||||||
|
// suitable for an <img src>, so labels need no separate image requests.
|
||||||
|
func qrDataURI(content string, size int) (string, error) {
|
||||||
|
png, err := qrcode.Encode(content, qrcode.Medium, size)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), 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 {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
}
|
||||||
|
return scheme + "://" + r.Host
|
||||||
|
}
|
||||||
@@ -49,16 +49,26 @@ func (a *App) Router() http.Handler {
|
|||||||
mux.HandleFunc("DELETE /app/folders/{id}", a.requireAdmin(a.handleFolderDelete))
|
mux.HandleFunc("DELETE /app/folders/{id}", a.requireAdmin(a.handleFolderDelete))
|
||||||
|
|
||||||
// Asset mutations (JSON in/out)
|
// Asset mutations (JSON in/out)
|
||||||
|
mux.HandleFunc("GET /app/assets/{id}", a.requireAdmin(a.handleAssetGet))
|
||||||
mux.HandleFunc("POST /app/assets", a.requireAdmin(a.handleAssetCreate))
|
mux.HandleFunc("POST /app/assets", a.requireAdmin(a.handleAssetCreate))
|
||||||
mux.HandleFunc("PATCH /app/assets/{id}", a.requireAdmin(a.handleAssetUpdate))
|
mux.HandleFunc("PATCH /app/assets/{id}", a.requireAdmin(a.handleAssetUpdate))
|
||||||
mux.HandleFunc("DELETE /app/assets/{id}", a.requireAdmin(a.handleAssetDelete))
|
mux.HandleFunc("DELETE /app/assets/{id}", a.requireAdmin(a.handleAssetDelete))
|
||||||
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}/quantity", a.requireAdmin(a.handleAssetQuantity))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /app/users/options", a.requireAdmin(a.handleUserOptions))
|
||||||
|
|
||||||
// CSV import/export
|
// CSV import/export
|
||||||
mux.HandleFunc("GET /export/assets.csv", a.requireLogin(a.handleExportCSV))
|
mux.HandleFunc("GET /export/assets.csv", a.requireLogin(a.handleExportCSV))
|
||||||
mux.HandleFunc("POST /import/assets.csv", a.requireAdmin(a.handleImportCSV))
|
mux.HandleFunc("POST /import/assets.csv", a.requireAdmin(a.handleImportCSV))
|
||||||
|
|
||||||
|
// QR asset tags: scan a printed label to jump to that asset, or print
|
||||||
|
// a sheet of labels for a folder/asset.
|
||||||
|
mux.HandleFunc("GET /scan/{tag}", a.requireLogin(a.handleScan))
|
||||||
|
mux.HandleFunc("GET /labels", a.requireLogin(a.handleLabelsPage))
|
||||||
|
|
||||||
// Settings (admin, traditional pages) - Item Types, Users, and general
|
// Settings (admin, traditional pages) - Item Types, Users, and general
|
||||||
// settings all live under one unified /settings section with shared
|
// settings all live under one unified /settings section with shared
|
||||||
// sub-navigation, rather than as separate top-level nav items.
|
// sub-navigation, rather than as separate top-level nav items.
|
||||||
|
|||||||
@@ -243,6 +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'); } });
|
||||||
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); } });
|
||||||
@@ -252,8 +253,10 @@
|
|||||||
|
|
||||||
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'); } });
|
||||||
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: 'Duplicate', icon: 'bi-files', action: function () { duplicateAsset(id); } });
|
items.push({ label: 'Duplicate', icon: 'bi-files', action: function () { duplicateAsset(id); } });
|
||||||
items.push({ label: 'Move to…', icon: 'bi-arrows-move', action: function () { openMoveAssetModal(id); } });
|
items.push({ label: 'Move to…', icon: 'bi-arrows-move', action: function () { openMoveAssetModal(id); } });
|
||||||
items.push({ divider: true });
|
items.push({ divider: true });
|
||||||
@@ -615,6 +618,55 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Check out / check in modal
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
var checkoutModalEl = document.getElementById('checkoutModal');
|
||||||
|
var checkoutModal = (checkoutModalEl && window.bootstrap) ? new bootstrap.Modal(checkoutModalEl) : null;
|
||||||
|
|
||||||
|
async function openCheckoutModal(id) {
|
||||||
|
if (!checkoutModal) return;
|
||||||
|
checkoutModalEl.dataset.assetId = id;
|
||||||
|
var sel = document.getElementById('checkoutUserSelect');
|
||||||
|
var currentText = document.getElementById('checkoutCurrent');
|
||||||
|
sel.innerHTML = '<option value="">Unassigned (check in)</option>';
|
||||||
|
currentText.textContent = 'Loading…';
|
||||||
|
try {
|
||||||
|
var asset = await apiFetch('/app/assets/' + id);
|
||||||
|
var users = await apiFetch('/app/users/options');
|
||||||
|
(users || []).forEach(function (u) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = u.id;
|
||||||
|
opt.textContent = u.username;
|
||||||
|
if (asset.AssignedUserID === u.id) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
currentText.textContent = asset.AssignedUsername
|
||||||
|
? 'Currently checked out to ' + asset.AssignedUsername + '.'
|
||||||
|
: 'Not currently checked out to anyone.';
|
||||||
|
document.getElementById('checkoutModalTitle').textContent = 'Check Out "' + asset.Name + '"';
|
||||||
|
} catch (err) {
|
||||||
|
currentText.textContent = '';
|
||||||
|
}
|
||||||
|
checkoutModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmCheckoutBtn = document.getElementById('confirmCheckoutBtn');
|
||||||
|
if (confirmCheckoutBtn) {
|
||||||
|
confirmCheckoutBtn.addEventListener('click', async function () {
|
||||||
|
var id = checkoutModalEl.dataset.assetId;
|
||||||
|
var val = document.getElementById('checkoutUserSelect').value;
|
||||||
|
try {
|
||||||
|
await apiFetch('/app/assets/' + id + '/checkout', { method: 'POST', json: { assigned_user_id: val ? Number(val) : null } });
|
||||||
|
checkoutModal.hide();
|
||||||
|
await navigateAndSelect('asset', id);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Checkout failed: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Asset form (create / edit) - shown in a modal
|
// Asset form (create / edit) - shown in a modal
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -701,6 +753,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adjustAssetQuantity handles the +/- steppers shown next to non-unique
|
||||||
|
// assets (in the asset detail panel and folder asset tables), updating
|
||||||
|
// the displayed number in place without reloading the whole panel.
|
||||||
|
async function adjustAssetQuantity(stepBtn) {
|
||||||
|
var wrapper = stepBtn.closest('[data-quantity-stepper]');
|
||||||
|
if (!wrapper) return;
|
||||||
|
var id = wrapper.dataset.assetId;
|
||||||
|
var valueEl = wrapper.querySelector('[data-quantity-value]');
|
||||||
|
var delta = parseInt(stepBtn.dataset.step, 10) || 0;
|
||||||
|
try {
|
||||||
|
var res = await apiFetch('/app/assets/' + id + '/quantity', { method: 'POST', json: { delta: delta } });
|
||||||
|
if (valueEl) valueEl.textContent = res.quantity;
|
||||||
|
} catch (err) {
|
||||||
|
alert('Could not update quantity: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Folder listing filters / pagination
|
// Folder listing filters / pagination
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -879,6 +948,9 @@
|
|||||||
|
|
||||||
if (mainPanel) {
|
if (mainPanel) {
|
||||||
mainPanel.addEventListener('click', function (e) {
|
mainPanel.addEventListener('click', function (e) {
|
||||||
|
var stepBtn = e.target.closest('[data-action="panel-quantity-step"], [data-action="table-quantity-step"]');
|
||||||
|
if (stepBtn) { e.preventDefault(); e.stopPropagation(); adjustAssetQuantity(stepBtn); return; }
|
||||||
|
|
||||||
var folderLink = e.target.closest('.folder-link');
|
var folderLink = e.target.closest('.folder-link');
|
||||||
if (folderLink) { e.preventDefault(); navigateAndSelect('folder', folderLink.dataset.id); return; }
|
if (folderLink) { e.preventDefault(); navigateAndSelect('folder', folderLink.dataset.id); return; }
|
||||||
|
|
||||||
@@ -936,6 +1008,14 @@
|
|||||||
submitAssetForm(e.target);
|
submitAssetForm(e.target);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
assetModalEl.addEventListener('click', function (e) {
|
||||||
|
var stepBtn = e.target.closest('[data-action="quantity-step"]');
|
||||||
|
if (!stepBtn) return;
|
||||||
|
var input = document.getElementById('assetFormQuantity');
|
||||||
|
if (!input) return;
|
||||||
|
var next = (parseInt(input.value, 10) || 0) + parseInt(stepBtn.dataset.step, 10);
|
||||||
|
input.value = Math.max(0, next);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -971,4 +1051,25 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// QR/barcode scan landing: /scan/<tag> redirects here with ?asset=<id>
|
||||||
|
// (or ?scan_error=<tag> if the tag didn't match anything).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
var inventoryShell = document.querySelector('.inventory-shell');
|
||||||
|
if (inventoryShell) {
|
||||||
|
var autoOpenId = inventoryShell.dataset.autoOpenAsset;
|
||||||
|
if (autoOpenId) {
|
||||||
|
navigateAndSelect('asset', autoOpenId);
|
||||||
|
}
|
||||||
|
var scanError = inventoryShell.dataset.scanError;
|
||||||
|
if (scanError) {
|
||||||
|
alert('No asset found for scanned tag "' + scanError + '".');
|
||||||
|
}
|
||||||
|
if (autoOpenId || scanError) {
|
||||||
|
// Drop the query string so a page refresh doesn't re-trigger this.
|
||||||
|
window.history.replaceState({}, '', '/inventory');
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="inventory-shell d-flex">
|
<div class="inventory-shell d-flex" {{if .AutoOpenAssetID}}data-auto-open-asset="{{.AutoOpenAssetID}}"{{end}} {{if .ScanError}}data-scan-error="{{.ScanError}}"{{end}}>
|
||||||
<aside id="tree-pane" class="tree-pane border-end d-flex flex-column">
|
<aside id="tree-pane" class="tree-pane border-end d-flex flex-column">
|
||||||
<div class="p-2 border-bottom">
|
<div class="p-2 border-bottom">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
|
|||||||
@@ -51,6 +51,18 @@
|
|||||||
<label class="form-check-label" for="enabled">Enabled</label>
|
<label class="form-check-label" for="enabled">Enabled</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_unique" id="isUnique" {{if .ItemType}}{{if .ItemType.IsUnique}}checked{{end}}{{else}}checked{{end}}>
|
||||||
|
<label class="form-check-label" for="isUnique">
|
||||||
|
Unique — each asset of this type has its own identity (e.g. a laptop's serial number)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
Uncheck this for bulk/consumable types (e.g. a USB-A cable) that are tracked as one row
|
||||||
|
with a quantity instead of one row per unit.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary mt-3">Save</button>
|
<button type="submit" class="btn btn-primary mt-3">Save</button>
|
||||||
{{if .ItemType}}
|
{{if .ItemType}}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<style>{{.SheetCSS}}
|
||||||
|
@media print {
|
||||||
|
.app-navbar, .print-toolbar { display: none !important; }
|
||||||
|
body { margin: 0; background: #fff; }
|
||||||
|
.label-sheet { margin: 0; }
|
||||||
|
}
|
||||||
|
.label-tag { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; font-size: 11pt; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.label-name { font-size: 8pt; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="print-toolbar d-flex flex-wrap align-items-center gap-3 p-3 border-bottom">
|
||||||
|
<button class="btn btn-primary" onclick="window.print()"><i class="bi bi-printer"></i> Print</button>
|
||||||
|
<span class="text-body-secondary">{{len .Labels}} label(s)</span>
|
||||||
|
<form method="get" class="d-flex align-items-center gap-2 ms-auto">
|
||||||
|
{{if .AssetID}}<input type="hidden" name="asset_id" value="{{.AssetID}}">{{end}}
|
||||||
|
{{if .FolderID}}<input type="hidden" name="folder_id" value="{{.FolderID}}">{{end}}
|
||||||
|
{{if .Recursive}}<input type="hidden" name="recursive" value="1">{{end}}
|
||||||
|
<label class="form-label mb-0 small text-body-secondary" for="labelTemplateSelect">Label sheet</label>
|
||||||
|
<select class="form-select form-select-sm" id="labelTemplateSelect" name="template" onchange="this.form.submit()">
|
||||||
|
{{range $key := .TemplateKeys}}
|
||||||
|
{{$t := index $.Templates $key}}
|
||||||
|
<option value="{{$key}}" {{if eq $key $.Selected.Key}}selected{{end}}>{{$t.DisplayName}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Selected.Approximate}}
|
||||||
|
<div class="alert alert-warning m-3">
|
||||||
|
This layout's grid/margins aren't from a verified spec sheet - print one test page on plain paper and
|
||||||
|
hold it up to a real label sheet before printing a full (often waterproof, non-refundable) sheet.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="label-sheet">
|
||||||
|
{{range .Labels}}
|
||||||
|
<div class="label">
|
||||||
|
<img src="{{.QRData}}" alt="QR code for {{.Asset.AssetTag}}">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="label-tag">{{.Asset.AssetTag}}</div>
|
||||||
|
<div class="label-name">{{.Asset.Name}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if not .Labels}}
|
||||||
|
<p class="text-body-secondary p-3">No assets to print.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -41,17 +41,19 @@
|
|||||||
Required: <code>Name</code>, <code>Item Type</code>, <code>Status</code>. Optional:
|
Required: <code>Name</code>, <code>Item Type</code>, <code>Status</code>. Optional:
|
||||||
<code>Asset ID</code> (auto-generated if blank; matches an existing asset to update it),
|
<code>Asset ID</code> (auto-generated if blank; matches an existing asset to update it),
|
||||||
<code>Folder</code> (a <code>/</code>-separated path such as <code>Main Office/Network</code>),
|
<code>Folder</code> (a <code>/</code>-separated path such as <code>Main Office/Network</code>),
|
||||||
<code>Assigned User</code>, <code>Notes</code>, and one <code>cf_<field_key></code> column
|
<code>Assigned User</code>, <code>Notes</code>, <code>Quantity</code> (defaults to 1; only
|
||||||
per custom field (e.g. <code>cf_serial_number</code>).
|
meaningful for item types that aren't marked Unique), and one <code>cf_<field_key></code>
|
||||||
|
column per custom field (e.g. <code>cf_serial_number</code>).
|
||||||
</p>
|
</p>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-bordered mb-0 small">
|
<table class="table table-sm table-bordered mb-0 small">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Asset ID</th><th>Name</th><th>Item Type</th><th>Folder</th><th>Status</th><th>Assigned User</th><th>Notes</th><th>cf_serial_number</th></tr>
|
<tr><th>Asset ID</th><th>Name</th><th>Item Type</th><th>Folder</th><th>Status</th><th>Assigned User</th><th>Notes</th><th>Quantity</th><th>cf_serial_number</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td></td><td>Core Switch</td><td>Switch</td><td>Main Office/Network</td><td>In Use</td><td></td><td></td><td>SN-12345</td></tr>
|
<tr><td></td><td>Core Switch</td><td>Switch</td><td>Main Office/Network</td><td>In Use</td><td></td><td></td><td></td><td>SN-12345</td></tr>
|
||||||
<tr><td>IT-000042</td><td>Front Desk PC</td><td>Desktop</td><td>Main Office/Workstations</td><td>In Use</td><td>jdoe</td><td>Loaner unit</td><td></td></tr>
|
<tr><td>IT-000042</td><td>Front Desk PC</td><td>Desktop</td><td>Main Office/Workstations</td><td>In Use</td><td>jdoe</td><td>Loaner unit</td><td></td><td></td></tr>
|
||||||
|
<tr><td></td><td>USB-A Cable</td><td>Cable</td><td>Main Office/Spares</td><td>Spare</td><td></td><td></td><td>25</td><td></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
{{if not .EditUser}}
|
{{if not .EditUser}}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Password</label>
|
<label class="form-label">Password</label>
|
||||||
<input type="password" class="form-control" name="password" required autocomplete="new-password">
|
<input type="password" class="form-control" name="password" autocomplete="new-password">
|
||||||
|
<div class="form-text">Leave blank for a Contact - they can't sign in, so no password is needed.</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
<select class="form-select" name="role">
|
<select class="form-select" name="role">
|
||||||
<option value="user" {{if .EditUser}}{{if eq .EditUser.Role "user"}}selected{{end}}{{end}}>User</option>
|
<option value="user" {{if .EditUser}}{{if eq .EditUser.Role "user"}}selected{{end}}{{end}}>User</option>
|
||||||
<option value="admin" {{if .EditUser}}{{if eq .EditUser.Role "admin"}}selected{{end}}{{end}}>Admin</option>
|
<option value="admin" {{if .EditUser}}{{if eq .EditUser.Role "admin"}}selected{{end}}{{end}}>Admin</option>
|
||||||
|
<option value="contact" {{if .EditUser}}{{if eq .EditUser.Role "contact"}}selected{{end}}{{end}}>Contact (no login - assignable only)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check mb-3">
|
<div class="form-check mb-3">
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .EditUser}}
|
{{if and .EditUser .EditUser.CanLogin}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header py-2 fw-semibold">Change Password</div>
|
<div class="card-header py-2 fw-semibold">Change Password</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
{{range .Users}}
|
{{range .Users}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/settings/users/{{.ID}}/edit">{{.Username}}</a></td>
|
<td><a href="/settings/users/{{.ID}}/edit">{{.Username}}</a></td>
|
||||||
<td>{{if .IsAdmin}}<span class="badge text-bg-primary">Admin</span>{{else}}<span class="badge text-bg-secondary">User</span>{{end}}</td>
|
<td>
|
||||||
|
{{if eq .Role "admin"}}<span class="badge text-bg-primary">Admin</span>
|
||||||
|
{{else if eq .Role "contact"}}<span class="badge text-bg-info">Contact</span>
|
||||||
|
{{else}}<span class="badge text-bg-secondary">User</span>{{end}}
|
||||||
|
</td>
|
||||||
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
|
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
|
||||||
<td>{{fmtDateShort .CreatedAt}}</td>
|
<td>{{fmtDateShort .CreatedAt}}</td>
|
||||||
<td class="text-end"><a href="/settings/users/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
|
<td class="text-end"><a href="/settings/users/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
{{define "asset_fields_only"}}
|
{{define "asset_fields_only"}}
|
||||||
{{if .}}
|
{{if not .IsUnique}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="assetFormQuantity">Quantity</label>
|
||||||
|
<div class="input-group" style="max-width: 220px">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" data-action="quantity-step" data-step="-1">−</button>
|
||||||
|
<input type="number" class="form-control text-center" name="quantity" id="assetFormQuantity" min="0" step="1" value="{{.Quantity}}">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" data-action="quantity-step" data-step="1">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">This item type isn't Unique, so it's tracked by quantity instead of one row per unit.</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
{{end}}
|
||||||
|
{{if .Fields}}
|
||||||
<h6 class="mt-3 mb-2">Type-Specific Fields</h6>
|
<h6 class="mt-3 mb-2">Type-Specific Fields</h6>
|
||||||
{{range .}}
|
{{range .Fields}}
|
||||||
{{$fv := .}}
|
{{$fv := .}}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label" for="field-{{.Def.ID}}">{{.Def.Name}}{{if .Def.Required}} <span class="text-danger">*</span>{{end}}</label>
|
<label class="form-label" for="field-{{.Def.ID}}">{{.Def.Name}}{{if .Def.Required}} <span class="text-danger">*</span>{{end}}</label>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<select class="form-select" name="assigned_user_id">
|
<select class="form-select" name="assigned_user_id">
|
||||||
<option value="">Unassigned</option>
|
<option value="">Unassigned</option>
|
||||||
{{range .Users}}
|
{{range .Users}}
|
||||||
<option value="{{.ID}}" {{if eq $selectedUser .ID}}selected{{end}}>{{.Username}}</option>
|
<option value="{{.ID}}" {{if eq $selectedUser .ID}}selected{{end}}>{{.Username}}{{if eq .Role "contact"}} (Contact){{end}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12" id="asset-fields-container">
|
<div class="col-12" id="asset-fields-container">
|
||||||
{{template "asset_fields_only" .Fields}}
|
{{template "asset_fields_only" (dict "IsUnique" .SelectedITUnique "Quantity" .Quantity "Fields" .Fields)}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Check out / check in asset -->
|
||||||
|
<div class="modal fade" id="checkoutModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="checkoutModalTitle">Check Out Asset</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-body-secondary small mb-3" id="checkoutCurrent"></p>
|
||||||
|
<label class="form-label">Assigned User</label>
|
||||||
|
<select class="form-select" id="checkoutUserSelect">
|
||||||
|
<option value="">Unassigned (check in)</option>
|
||||||
|
</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="confirmCheckoutBtn">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">
|
||||||
|
|||||||
@@ -25,7 +25,19 @@
|
|||||||
<div class="card-header py-2 fw-semibold">Details</div>
|
<div class="card-header py-2 fw-semibold">Details</div>
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><th class="w-25">Assigned User</th><td>{{if .Asset.AssignedUsername}}{{.Asset.AssignedUsername}}{{else}}<span class="text-body-secondary">Unassigned</span>{{end}}</td></tr>
|
<tr><th class="w-25">Assigned User</th><td>{{if .Asset.AssignedUsername}}{{.Asset.AssignedUsername}}{{else}}<span class="text-body-secondary">Unassigned — right-click above to check out</span>{{end}}</td></tr>
|
||||||
|
{{if not .Asset.ItemTypeIsUnique}}
|
||||||
|
<tr>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<td>
|
||||||
|
<div class="input-group input-group-sm" style="max-width: 150px" data-quantity-stepper data-asset-id="{{.Asset.ID}}">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" data-action="panel-quantity-step" data-step="-1">−</button>
|
||||||
|
<span class="form-control text-center" data-quantity-value>{{.Asset.Quantity}}</span>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" data-action="panel-quantity-step" data-step="1">+</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
<tr><th>Created</th><td>{{fmtDate .Asset.CreatedAt}}</td></tr>
|
<tr><th>Created</th><td>{{fmtDate .Asset.CreatedAt}}</td></tr>
|
||||||
<tr><th>Updated</th><td>{{fmtDate .Asset.UpdatedAt}}</td></tr>
|
<tr><th>Updated</th><td>{{fmtDate .Asset.UpdatedAt}}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -38,13 +38,23 @@
|
|||||||
{{if .Assets}}
|
{{if .Assets}}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover asset-table">
|
<table class="table table-sm table-hover asset-table">
|
||||||
<thead><tr><th>Asset ID</th><th>Name</th><th>Type</th><th>Status</th><th>Assigned</th></tr></thead>
|
<thead><tr><th>Asset ID</th><th>Name</th><th>Type</th><th>Qty</th><th>Status</th><th>Assigned</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Assets}}
|
{{range .Assets}}
|
||||||
<tr class="asset-row" data-id="{{.ID}}">
|
<tr class="asset-row" data-id="{{.ID}}">
|
||||||
<td>{{.AssetTag}}</td>
|
<td>{{.AssetTag}}</td>
|
||||||
<td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td>
|
<td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td>
|
||||||
<td>{{.ItemTypeName}}</td>
|
<td>{{.ItemTypeName}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .ItemTypeIsUnique}}<span class="text-body-secondary">—</span>
|
||||||
|
{{else}}
|
||||||
|
<div class="input-group input-group-sm" style="width:6.5rem" data-quantity-stepper data-asset-id="{{.ID}}">
|
||||||
|
<button class="btn btn-outline-secondary py-0" type="button" data-action="table-quantity-step" data-step="-1">−</button>
|
||||||
|
<span class="form-control text-center py-0" data-quantity-value>{{.Quantity}}</span>
|
||||||
|
<button class="btn btn-outline-secondary py-0" type="button" data-action="table-quantity-step" data-step="1">+</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
<td><span class="badge text-bg-{{.StatusColor}}">{{.StatusName}}</span></td>
|
<td><span class="badge text-bg-{{.StatusColor}}">{{.StatusName}}</span></td>
|
||||||
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">—</span>{{end}}</td>
|
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">—</span>{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user