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
+37 -14
View File
@@ -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/<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) {
@@ -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),
})
}