package web import ( "net/http" "vibetree/internal/assets" "vibetree/internal/models" ) // InventoryPageData is the top-level data passed to templates/pages/inventory.html. type InventoryPageData struct { User *models.User 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) { ctx := r.Context() u := currentUser(r) expanded, err := a.expandedFolderIDs(ctx, u.ID) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } tree, err := a.buildTree(ctx, nil, expanded) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } itemTypes, err := a.ItemTypes.List(ctx, false) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } statusList, err := a.Statuses.List(ctx) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } 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"), }) } // handleTreeChildren serves one level of the tree lazily for AJAX expand. // When persist=1, the expanded state is remembered for the current user. func (a *App) handleTreeChildren(w http.ResponseWriter, r *http.Request) { ctx := r.Context() u := currentUser(r) parentID, err := queryOptionalID(r, "parent_id") if err != nil { writeErr(w, http.StatusBadRequest, "invalid parent_id") return } if r.URL.Query().Get("persist") == "1" && parentID != nil { if err := a.persistExpand(ctx, u.ID, *parentID); err != nil { a.Log.Warn("persist expand failed", "error", err) } } // Honor already-persisted expand state for descendants so that // re-fetching a branch (e.g. after a drag-and-drop move) doesn't // visually collapse folders the user had already opened. expanded, err := a.expandedFolderIDs(ctx, u.ID) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } nodes, err := a.buildTree(ctx, parentID, expanded) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } a.RenderFragment(w, http.StatusOK, "tree_children", nodes) } func (a *App) handleTreeCollapse(w http.ResponseWriter, r *http.Request) { u := currentUser(r) if err := r.ParseForm(); err != nil { writeErr(w, http.StatusBadRequest, "invalid form") return } folderID := queryInt64(r, "folder_id") if folderID == 0 { writeErr(w, http.StatusBadRequest, "folder_id is required") return } if err := a.persistCollapse(r.Context(), u.ID, folderID); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (a *App) handleTreeCollapseAll(w http.ResponseWriter, r *http.Request) { u := currentUser(r) if err := a.persistCollapseAll(r.Context(), u.ID); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // handlePanelRoot backs the main panel when nothing is selected. It is // intentionally blank (no dashboard/summary) - the panel only shows // something once a folder or asset is actually clicked; browsing happens // in the tree. The fragment still carries a data-panel-type="root" hook so // right-click still offers "New Folder" / "Add Asset" / import / export. func (a *App) handlePanelRoot(w http.ResponseWriter, r *http.Request) { a.RenderFragment(w, http.StatusOK, "panel_root", nil) } // FolderPanelData backs the main panel when a folder is selected. type FolderPanelData struct { Folder *models.Folder Breadcrumb []models.BreadcrumbItem Subfolders []models.Folder Assets []models.Asset Total int Page int PageSize int ItemTypes []models.ItemType Statuses []models.Status FilterType int64 FilterStat int64 Query string } func (a *App) handlePanelFolder(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, err := pathID(r, "id") if err != nil { writeErr(w, http.StatusBadRequest, "invalid folder id") return } folder, err := a.Folders.Get(ctx, id) if err != nil { writeErr(w, http.StatusNotFound, err.Error()) return } breadcrumb, err := a.Folders.Path(ctx, id) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } subfolders, err := a.Folders.Children(ctx, &id) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } page := queryInt(r, "page", 1) filterType := queryInt64(r, "item_type_id") filterStatus := queryInt64(r, "status_id") query := r.URL.Query().Get("q") assetList, total, err := a.Assets.List(ctx, assets.ListFilter{ FolderID: &id, FolderSet: true, ItemTypeID: filterType, StatusID: filterStatus, Query: query, Page: page, PageSize: 50, }) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } itemTypes, err := a.ItemTypes.List(ctx, false) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } statusList, err := a.Statuses.List(ctx) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } a.RenderFragment(w, http.StatusOK, "panel_folder", FolderPanelData{ Folder: folder, Breadcrumb: breadcrumb, Subfolders: subfolders, Assets: assetList, Total: total, Page: page, PageSize: 50, ItemTypes: itemTypes, Statuses: statusList, FilterType: filterType, FilterStat: filterStatus, Query: query, }) } // AssetPanelData backs the main panel when an asset is selected (view mode). type AssetPanelData struct { Asset *models.Asset } func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, err := pathID(r, "id") if err != nil { writeErr(w, http.StatusBadRequest, "invalid asset id") return } asset, err := a.Assets.Get(ctx, id) if err != nil { writeErr(w, http.StatusNotFound, err.Error()) return } a.RenderFragment(w, http.StatusOK, "panel_asset", AssetPanelData{Asset: asset}) } // 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 SelectedITUnique bool Quantity int Fields []models.AssetFieldValue } func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) { ctx := r.Context() folderID, err := queryOptionalID(r, "folder_id") if err != nil { writeErr(w, http.StatusBadRequest, "invalid folder_id") return } itemTypes, err := a.ItemTypes.List(ctx, false) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } statusList, err := a.Statuses.List(ctx) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } userList, err := a.Users.List(ctx) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } folderOpts, err := a.folderOptions(ctx) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } 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, SelectedITUnique: selectedITUnique, Quantity: 1, Fields: fields, }) } func (a *App) handlePanelAssetEdit(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, err := pathID(r, "id") if err != nil { writeErr(w, http.StatusBadRequest, "invalid asset id") return } asset, err := a.Assets.Get(ctx, id) if err != nil { writeErr(w, http.StatusNotFound, err.Error()) return } itemTypes, err := a.ItemTypes.List(ctx, true) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } statusList, err := a.Statuses.List(ctx) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } userList, err := a.Users.List(ctx) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } folderOpts, err := a.folderOptions(ctx) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{ Asset: asset, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts, SelectedIT: asset.ItemTypeID, SelectedITUnique: asset.ItemTypeIsUnique, Quantity: asset.Quantity, Fields: asset.CustomFields, }) } // 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") it, err := a.ItemTypes.Get(ctx, itemTypeID) if err != nil { writeErr(w, http.StatusNotFound, err.Error()) return } a.RenderFragment(w, http.StatusOK, "asset_fields_only", AssetDynamicFieldsData{ IsUnique: it.IsUnique, Quantity: 1, Fields: fieldValuesForCreate(it.Fields), }) }