diff --git a/README.md b/README.md index 16e26bb..bafa2cf 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,12 @@ asset). never fully reloads when you browse the tree. - **Drag and drop** folders and assets onto another folder (or onto "Inventory" itself) to move them. Admin only. -- **Right-click-style actions**: each folder has a "…" action menu (New - Subfolder, Add Asset, Rename, Delete). +- **Right-click for everything**: it's meant to feel like a native file + 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 confirmation, and offers a safe "move contents elsewhere" option instead 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 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/` 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 -- **Export**: from the Inventory toolbar, or `/export/assets.csv` (optionally - scoped to a folder, with `?recursive=1` to include subfolders). Includes - one `cf_` column per distinct custom field in use. -- **Import**: CSV with columns `Asset ID` (optional — auto-generated if - blank), `Name`, `Item Type`, `Folder` (a `/`-separated path, e.g. - `Main Office/Network`), `Status`, `Assigned User`, `Notes`, plus - `cf_` columns for custom fields. Existing assets are matched - and updated by Asset ID; everything else is created. Missing folders in - the path can optionally be created automatically. +- **Export**: from the tree's right-click menu, or `/export/assets.csv` + (optionally scoped to a folder, with `?recursive=1` to include + subfolders). Includes a `Quantity` column and one `cf_` column + per distinct custom field in use. +- **Folder-scoped import**: right-click a folder → **Import CSV…** imports + every row directly into that folder. No `Folder` column needed — that's + the whole point of doing it from a specific folder. Never available at the + top level. +- **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, -statuses) is just `inventree database backup` — it's a consistent snapshot -of the whole SQLite database. +statuses, users) is just `inventree database backup` — it's a consistent +snapshot of the whole SQLite database. ## Project layout diff --git a/go.mod b/go.mod index 7f6dc33..84e8e22 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module inventree go 1.25.0 require ( + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/crypto v0.55.0 modernc.org/sqlite v1.56.0 ) diff --git a/go.sum b/go.sum index e1dbd7b..3961fd7 100644 --- a/go.sum +++ b/go.sum @@ -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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= diff --git a/internal/assets/assets.go b/internal/assets/assets.go index 0d7c404..568a8eb 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -106,6 +106,24 @@ func (s *Store) tagExists(ctx context.Context, tag string, excludeID int64) (boo 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. // 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 { @@ -123,6 +141,11 @@ func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int } else if taken { 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) if err != nil { @@ -131,9 +154,9 @@ func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int defer tx.Rollback() res, err := tx.ExecContext(ctx, - `INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes) + `INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes, quantity) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.Quantity) if err != nil { return err } @@ -173,6 +196,11 @@ func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int } else if taken { 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) if err != nil { @@ -181,9 +209,9 @@ func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int defer tx.Rollback() _, 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 = ?`, - 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 { return err } @@ -209,6 +237,42 @@ func (s *Store) Move(ctx context.Context, id int64, folderID *int64) error { 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 // asset with a freshly generated asset tag. 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, AssignedUserID: orig.AssignedUserID, Notes: orig.Notes, + Quantity: orig.Quantity, } values := map[int64]string{} for _, fv := range orig.CustomFields { @@ -242,8 +307,8 @@ func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error) } 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, - it.name, it.icon, st.name, st.color, COALESCE(u.username, '') + 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, it.is_unique, st.name, st.color, COALESCE(u.username, '') FROM assets a JOIN item_types it ON it.id = a.item_type_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 folderID, assignedUserID sql.NullInt64 var created, updated string - if err := row.Scan(&a.ID, &a.AssetTag, &a.Name, &a.ItemTypeID, &folderID, &a.StatusID, &assignedUserID, &a.Notes, &created, &updated, - &a.ItemTypeName, &a.ItemTypeIcon, &a.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil { + 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.ItemTypeIsUnique, &a.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil { return nil, err } if folderID.Valid { diff --git a/internal/assets/csv.go b/internal/assets/csv.go index 93cef71..d17c577 100644 --- a/internal/assets/csv.go +++ b/internal/assets/csv.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "sort" + "strconv" "strings" "inventree/internal/models" @@ -19,6 +20,7 @@ const ( colStatus = "status" colAssignedUser = "assigned user" colNotes = "notes" + colQuantity = "quantity" 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{ AssetTag: get(row, colAssetID), Name: name, @@ -209,6 +218,7 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions, StatusID: statusID, AssignedUserID: assignedUserID, Notes: get(row, colNotes), + Quantity: quantity, } if asset.AssetTag != "" { @@ -282,7 +292,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error { } 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 { header = append(header, cfColPrefix+k) } @@ -304,6 +314,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error { } row := []string{ a.AssetTag, a.Name, a.ItemTypeName, folderPathString(a.FolderPath), a.StatusName, a.AssignedUsername, a.Notes, + strconv.Itoa(a.Quantity), a.CreatedAt.Format("2006-01-02 15:04:05"), a.UpdatedAt.Format("2006-01-02 15:04:05"), } values := map[string]string{} diff --git a/internal/assets/quantity_test.go b/internal/assets/quantity_test.go new file mode 100644 index 0000000..c162966 --- /dev/null +++ b/internal/assets/quantity_test.go @@ -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) + } +} diff --git a/internal/database/migrations/0003_quantity.sql b/internal/database/migrations/0003_quantity.sql new file mode 100644 index 0000000..8c05a57 --- /dev/null +++ b/internal/database/migrations/0003_quantity.sql @@ -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; diff --git a/internal/database/migrations/0004_contacts.sql b/internal/database/migrations/0004_contacts.sql new file mode 100644 index 0000000..4607c5d --- /dev/null +++ b/internal/database/migrations/0004_contacts.sql @@ -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; diff --git a/internal/itemtypes/itemtypes.go b/internal/itemtypes/itemtypes.go index b417410..c348b34 100644 --- a/internal/itemtypes/itemtypes.go +++ b/internal/itemtypes/itemtypes.go @@ -58,7 +58,7 @@ func validFieldType(t models.FieldType) bool { // List returns all item types, optionally including disabled ones, each with // its custom field definitions loaded. 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 { q += ` WHERE enabled = 1` } @@ -72,7 +72,7 @@ func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemTy for rows.Next() { var it models.ItemType 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 } 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) { - 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 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) { return nil, ErrNotFound } @@ -146,8 +146,8 @@ func (s *Store) Create(ctx context.Context, it *models.ItemType) error { it.Icon = "bi-box-seam" } res, err := s.db.ExecContext(ctx, - `INSERT INTO item_types (name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?)`, - it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder) + `INSERT INTO item_types (name, description, icon, enabled, sort_order, is_unique) VALUES (?, ?, ?, ?, ?, ?)`, + it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.IsUnique) if err != nil { return err } @@ -164,8 +164,17 @@ func (s *Store) Update(ctx context.Context, it *models.ItemType) error { return ErrNameRequired } _, 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 = ?`, - it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.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.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 } diff --git a/internal/models/models.go b/internal/models/models.go index 6bef54f..09519b1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -8,6 +8,11 @@ type Role string const ( RoleAdmin Role = "admin" 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 { @@ -22,6 +27,10 @@ type User struct { 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 { ID int64 Name string @@ -96,6 +105,12 @@ type ItemType struct { CreatedAt 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 } @@ -129,12 +144,16 @@ type Asset struct { StatusID int64 AssignedUserID *int64 Notes string - CreatedAt time.Time - UpdatedAt time.Time + // Quantity is meaningful only when the asset's item type is not + // IsUnique; unique-type assets are always kept at 1. + Quantity int + CreatedAt time.Time + UpdatedAt time.Time // Populated by joined list/detail queries. ItemTypeName string ItemTypeIcon string + ItemTypeIsUnique bool StatusName string StatusColor string AssignedUsername string diff --git a/internal/users/users.go b/internal/users/users.go index 7687393..3861902 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -16,10 +16,14 @@ var ( ErrNotFound = errors.New("user not found") ErrUsernameRequired = errors.New("username is required") 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") ) +func validRole(r models.Role) bool { + return r == models.RoleAdmin || r == models.RoleUser || r == models.RoleContact +} + type Store struct{ db *sql.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 == "" { return ErrUsernameRequired } - if u.Role != models.RoleAdmin && u.Role != models.RoleUser { + if !validRole(u.Role) { return ErrInvalidRole } 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 == "" { return ErrUsernameRequired } - if u.Role != models.RoleAdmin && u.Role != models.RoleUser { + if !validRole(u.Role) { return ErrInvalidRole } 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 { 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 { return nil, errors.New("invalid credentials") } diff --git a/internal/web/handlers_assets.go b/internal/web/handlers_assets.go index 4ebc03c..8c6fff8 100644 --- a/internal/web/handlers_assets.go +++ b/internal/web/handlers_assets.go @@ -15,6 +15,7 @@ type assetRequest struct { StatusID int64 `json:"status_id"` AssignedUserID *int64 `json:"assigned_user_id"` Notes string `json:"notes"` + Quantity int `json:"quantity"` Fields map[string]string `json:"fields"` } @@ -38,7 +39,7 @@ func (a *App) handleAssetCreate(w http.ResponseWriter, r *http.Request) { } asset := &models.Asset{ 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 { jsonErr(w, http.StatusBadRequest, err) @@ -65,7 +66,7 @@ func (a *App) handleAssetUpdate(w http.ResponseWriter, r *http.Request) { } asset := &models.Asset{ 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 { 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}) } + +// 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}) +} diff --git a/internal/web/handlers_inventory.go b/internal/web/handlers_inventory.go index 6a03875..d860159 100644 --- a/internal/web/handlers_inventory.go +++ b/internal/web/handlers_inventory.go @@ -13,6 +13,12 @@ type InventoryPageData struct { Tree []models.TreeNode ItemTypes []models.ItemType Statuses []models.Status + + // AutoOpenAssetID and ScanError come from a redirect out of + // /scan/ (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) { @@ -42,6 +48,7 @@ func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) { a.Render(w, http.StatusOK, "inventory", InventoryPageData{ 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. type AssetFormData struct { - Asset *models.Asset // nil when creating - FolderID *int64 - ItemTypes []models.ItemType - Statuses []models.Status - Users []models.User - FolderOptions []FolderOption - SelectedIT int64 - Fields []models.AssetFieldValue + Asset *models.Asset // nil when creating + FolderID *int64 + ItemTypes []models.ItemType + Statuses []models.Status + Users []models.User + FolderOptions []FolderOption + SelectedIT int64 + SelectedITUnique bool + Quantity int + Fields []models.AssetFieldValue } 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 selectedITUnique bool var fields []models.AssetFieldValue if len(itemTypes) > 0 { selectedIT = itemTypes[0].ID + selectedITUnique = itemTypes[0].IsUnique fields = fieldValuesForCreate(itemTypes[0].Fields) } a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{ 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{ 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 -// a chosen item type, used when the item type dropdown changes on the -// "new asset" form. +// AssetDynamicFieldsData backs the "asset_fields_only" partial: the +// quantity input (only relevant for non-unique item types) plus the +// 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) { ctx := r.Context() 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()) 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), + }) } diff --git a/internal/web/handlers_itemtypes.go b/internal/web/handlers_itemtypes.go index 818c4c0..a20df37 100644 --- a/internal/web/handlers_itemtypes.go +++ b/internal/web/handlers_itemtypes.go @@ -41,6 +41,7 @@ func (a *App) handleItemTypeCreate(w http.ResponseWriter, r *http.Request) { it := &models.ItemType{ Name: r.FormValue("name"), Description: r.FormValue("description"), 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 { 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.Enabled = r.FormValue("enabled") == "on" 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 { a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()}) return diff --git a/internal/web/handlers_labels.go b/internal/web/handlers_labels.go new file mode 100644 index 0000000..7c6e09c --- /dev/null +++ b/internal/web/handlers_labels.go @@ -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/ 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, + }) +} diff --git a/internal/web/handlers_scan.go b/internal/web/handlers_scan.go new file mode 100644 index 0000000..18e5f97 --- /dev/null +++ b/internal/web/handlers_scan.go @@ -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) +} diff --git a/internal/web/handlers_users.go b/internal/web/handlers_users.go index 64b440b..a75970f 100644 --- a/internal/web/handlers_users.go +++ b/internal/web/handlers_users.go @@ -1,12 +1,38 @@ package web import ( + "crypto/rand" + "encoding/hex" "net/http" "strconv" "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 { User *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}) } +// 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 { User *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()) return } - role := models.RoleUser - if r.FormValue("role") == "admin" { - role = models.RoleAdmin - } + role := parseRole(r.FormValue("role")) u := &models.User{Username: r.FormValue("username"), Role: role, Enabled: r.FormValue("enabled") == "on"} password := r.FormValue("password") if password == "" { - a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: "Password is required."}) - return + if role != models.RoleContact { + 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 { 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 } u.Username = r.FormValue("username") - u.Role = models.RoleUser - if r.FormValue("role") == "admin" { - u.Role = models.RoleAdmin - } + u.Role = parseRole(r.FormValue("role")) u.Enabled = r.FormValue("enabled") == "on" 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()}) diff --git a/internal/web/qr.go b/internal/web/qr.go new file mode 100644 index 0000000..b90365a --- /dev/null +++ b/internal/web/qr.go @@ -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 , 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 +} diff --git a/internal/web/router.go b/internal/web/router.go index 8b9ec05..d3e19a8 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -49,16 +49,26 @@ func (a *App) Router() http.Handler { mux.HandleFunc("DELETE /app/folders/{id}", a.requireAdmin(a.handleFolderDelete)) // 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("PATCH /app/assets/{id}", a.requireAdmin(a.handleAssetUpdate)) 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}/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 mux.HandleFunc("GET /export/assets.csv", a.requireLogin(a.handleExportCSV)) 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 all live under one unified /settings section with shared // sub-navigation, rather than as separate top-level nav items. diff --git a/web/static/js/app.js b/web/static/js/app.js index 2f1b736..e4fc14e 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -243,6 +243,7 @@ items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(id); } }); } items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?folder_id=' + id + '&recursive=1'; } }); + items.push({ label: 'Print Labels…', icon: 'bi-qr-code', action: function () { window.open('/labels?folder_id=' + id + '&recursive=1', '_blank'); } }); if (isAdmin) { items.push({ divider: true }); items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { startDeleteFolder(id); } }); @@ -252,8 +253,10 @@ function assetMenuItems(id) { var items = [{ label: 'Open', icon: 'bi-box-arrow-up-right', action: function () { navigateAndSelect('asset', id); } }]; + items.push({ label: 'Print Label', icon: 'bi-qr-code', action: function () { window.open('/labels?asset_id=' + id, '_blank'); } }); if (isAdmin) { items.push({ label: 'Edit', icon: 'bi-pencil', action: function () { loadAssetEditForm(id); } }); + items.push({ label: 'Check Out…', icon: 'bi-person-check', action: function () { openCheckoutModal(id); } }); 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({ 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 = ''; + 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 // --------------------------------------------------------------------- @@ -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 // --------------------------------------------------------------------- @@ -879,6 +948,9 @@ if (mainPanel) { 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'); if (folderLink) { e.preventDefault(); navigateAndSelect('folder', folderLink.dataset.id); return; } @@ -936,6 +1008,14 @@ 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/ redirects here with ?asset= + // (or ?scan_error= 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'); + } + } })(); diff --git a/web/templates/pages/inventory.html b/web/templates/pages/inventory.html index 8c56741..cafa900 100644 --- a/web/templates/pages/inventory.html +++ b/web/templates/pages/inventory.html @@ -1,5 +1,5 @@ {{define "content"}} -
+
{{if .ItemType}} diff --git a/web/templates/pages/labels.html b/web/templates/pages/labels.html new file mode 100644 index 0000000..5d6f732 --- /dev/null +++ b/web/templates/pages/labels.html @@ -0,0 +1,50 @@ +{{define "content"}} + + + + +{{if .Selected.Approximate}} +
+ 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. +
+{{end}} + +
+ {{range .Labels}} +
+ QR code for {{.Asset.AssetTag}} +
+
{{.Asset.AssetTag}}
+
{{.Asset.Name}}
+
+
+ {{end}} +
+{{if not .Labels}} +

No assets to print.

+{{end}} +{{end}} diff --git a/web/templates/pages/settings_import.html b/web/templates/pages/settings_import.html index 9330ecd..fdac465 100644 --- a/web/templates/pages/settings_import.html +++ b/web/templates/pages/settings_import.html @@ -41,17 +41,19 @@ Required: Name, Item Type, Status. Optional: Asset ID (auto-generated if blank; matches an existing asset to update it), Folder (a /-separated path such as Main Office/Network), - Assigned User, Notes, and one cf_<field_key> column - per custom field (e.g. cf_serial_number). + Assigned User, Notes, Quantity (defaults to 1; only + meaningful for item types that aren't marked Unique), and one cf_<field_key> + column per custom field (e.g. cf_serial_number).

- + - - + + +
Asset IDNameItem TypeFolderStatusAssigned UserNotescf_serial_number
Asset IDNameItem TypeFolderStatusAssigned UserNotesQuantitycf_serial_number
Core SwitchSwitchMain Office/NetworkIn UseSN-12345
IT-000042Front Desk PCDesktopMain Office/WorkstationsIn UsejdoeLoaner unit
Core SwitchSwitchMain Office/NetworkIn UseSN-12345
IT-000042Front Desk PCDesktopMain Office/WorkstationsIn UsejdoeLoaner unit
USB-A CableCableMain Office/SparesSpare25
diff --git a/web/templates/pages/users_form.html b/web/templates/pages/users_form.html index 60df824..294eb80 100644 --- a/web/templates/pages/users_form.html +++ b/web/templates/pages/users_form.html @@ -23,7 +23,8 @@ {{if not .EditUser}}
- + +
Leave blank for a Contact - they can't sign in, so no password is needed.
{{end}}
@@ -31,6 +32,7 @@
@@ -47,7 +49,7 @@
- {{if .EditUser}} + {{if and .EditUser .EditUser.CanLogin}}
Change Password
diff --git a/web/templates/pages/users_list.html b/web/templates/pages/users_list.html index 5c38bcb..79f34df 100644 --- a/web/templates/pages/users_list.html +++ b/web/templates/pages/users_list.html @@ -17,7 +17,11 @@ {{range .Users}} {{.Username}} - {{if .IsAdmin}}Admin{{else}}User{{end}} + + {{if eq .Role "admin"}}Admin + {{else if eq .Role "contact"}}Contact + {{else}}User{{end}} + {{if .Enabled}}Enabled{{else}}Disabled{{end}} {{fmtDateShort .CreatedAt}} Edit diff --git a/web/templates/partials/asset_fields.html b/web/templates/partials/asset_fields.html index f4824c3..873cd48 100644 --- a/web/templates/partials/asset_fields.html +++ b/web/templates/partials/asset_fields.html @@ -1,7 +1,20 @@ {{define "asset_fields_only"}} -{{if .}} +{{if not .IsUnique}} +
+ +
+ + + +
+
This item type isn't Unique, so it's tracked by quantity instead of one row per unit.
+
+{{else}} + +{{end}} +{{if .Fields}}
Type-Specific Fields
-{{range .}} +{{range .Fields}} {{$fv := .}}
diff --git a/web/templates/partials/asset_form.html b/web/templates/partials/asset_form.html index da8fcaf..87fd04a 100644 --- a/web/templates/partials/asset_form.html +++ b/web/templates/partials/asset_form.html @@ -49,7 +49,7 @@
@@ -60,7 +60,7 @@
- {{template "asset_fields_only" .Fields}} + {{template "asset_fields_only" (dict "IsUnique" .SelectedITUnique "Quantity" .Quantity "Fields" .Fields)}}
diff --git a/web/templates/partials/modals.html b/web/templates/partials/modals.html index 6d51f1a..3b50c54 100644 --- a/web/templates/partials/modals.html +++ b/web/templates/partials/modals.html @@ -128,6 +128,29 @@ + + +