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:
Owen
2026-08-17 16:28:14 -05:00
parent 42e7b2a21c
commit fb85c9edbb
31 changed files with 975 additions and 77 deletions
+17 -8
View File
@@ -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
}