Compare commits

...
10 Commits
Author SHA1 Message Date
Owen f3a959334f Rename project from inventree to vibetree
- go.mod module path (and every internal/vibetree import) inventree -> vibetree
- cmd/inventree/ -> cmd/vibetree/, deploy/inventree.service -> vibetree.service
- Default site name (seed data + the siteName template func's fallback)
  is now "vibetree", shown in the navbar, <title>, and login page
- Session cookie renamed inventree_session -> vibetree_session
- Default DB path data/inventree.db -> data/vibetree.db, CLI/log text,
  Dockerfile, docker-compose.yml, README, .gitignore/.dockerignore all
  updated to match

Existing installs: this changes the session cookie name (users will be
signed out once) and the default DB filename (point -db at your existing
file, or rename it, to keep your data).
2026-08-17 17:14:52 -05:00
Owen 14e2f3bb77 Bulk multi-select actions, barcode format, batch printing, site name on login
Multi-select (Ctrl/Cmd+click toggle, Shift+click range, right-click the
active selection for bulk actions - same conventions as a native file
manager):
- Print Labels (N) / Export CSV (N) for any mix of individually-picked
  assets and whole folders. Labels/export now take asset_ids/folder_ids
  instead of a single asset_id/folder_id.
- Edit Status..., Check Out..., Duplicate, Move to..., and Delete, bulk
  versions of the existing single-asset actions. Checkout/Move modals
  were generalized to accept an array of ids instead of just one; status
  editing got its own lightweight POST /app/assets/{id}/status endpoint
  (assets.SetStatus) so a bulk status change doesn't need to resend every
  other field the way a full PATCH would.

Labels:
- Choose QR (a /scan/<tag> URL, for a phone camera) or Code128 barcode
  (the bare Asset ID, for a USB scanner typing into the search box) via a
  format selector on the print page. New barcodeDataURI using
  github.com/boombuler/barcode alongside the existing QR generator.
- "Start at #" lets printing resume partway into an already-used sheet
  instead of wasting it on a fresh one.
- Added the Avery 5260 label template (same grid as 5160, rounded
  corners, so it isn't flagged approximate).

Also: the configured Site Name now shows on the login page too, not just
the authenticated navbar.
2026-08-17 17:12:17 -05:00
Owen 4e4313723e Fix quantity sent as string when saving an asset
submitAssetForm() converted item_type_id/folder_id/status_id/
assigned_user_id from FormData's string values to numbers before
JSON-encoding the payload, but missed quantity, so it stayed a string
and the server's json.Decode into assetRequest.Quantity (int) rejected
it as "cannot unmarshal string into Go struct field".
2026-08-17 17:00:08 -05:00
Owen e86ffe765c Add Avery 5260 label template
Same 30-per-sheet, 1" x 2-5/8" grid as 5160 (rounded corners are the
only difference), so unlike 61526 this one isn't flagged approximate.
2026-08-17 16:50:17 -05:00
Owen fb85c9edbb 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.
2026-08-17 16:28:14 -05:00
Owen 42e7b2a21c Statuses import, an icon picker, folder-scoped CSV import, and site name
- Statuses can now be bulk-imported/updated via CSV (Name, Color columns;
  matches and updates existing statuses by name). New
  internal/statuses/csv.go, tested.
- Icon fields (folder create/rename, item type form) are now a searchable
  dropdown of the 50 most common Bootstrap Icons instead of a free-text
  box, via a small reusable .icon-picker widget in app.js/app.css.
- CSV asset import is now folder-scoped when triggered from the tree:
  right-click a folder (or its background) > Import CSV... imports every
  row directly into that folder, so the CSV needs no Folder column at
  all, and the option no longer appears at the top level. The original
  global, Folder-path-aware import (with auto-create-missing-folders)
  moved to a new admin-only Settings > Import / Export page, alongside
  the new Statuses import and format examples for both. assets.Import
  gained ImportOptions.TargetFolderID to drive the folder-scoped mode.
- Site Name (Settings > General) now actually renders in the navbar
  brand and <title>, via a per-App "siteName" template func bound in
  loadTemplates. The brand is plain text now, not a link to /inventory.
2026-08-17 15:57:59 -05:00
Owen 8d9106542c Blank root panel, asset form as a modal, and a dedicated Statuses page
- Main panel is now blank at the top level ("Select a folder or asset
  from the tree") instead of showing a dashboard - browsing happens in
  the tree, the panel only shows something once you click into it.
  Removed the now-dead DashboardStats/Stats() machinery it depended on.
- Add/Edit Asset no longer takes over the main panel; it opens in
  #assetModal instead; asset_form.html was trimmed down to just the form
  (no more breadcrumb/panel wrapper) since the modal chrome provides the
  title and footer buttons now. The dynamic item-type-fields and submit
  listeners moved from #main-panel's delegation to the modal element's.
- Statuses split out of the General settings page into its own
  /settings/statuses page and sub-nav entry, alongside General, Item
  Types, and Users.
2026-08-17 15:45:38 -05:00
Owen 4d7ebed68e Move Settings link into the user profile dropdown
Settings was still a top-level navbar item; per feedback it belongs in
the profile dropdown instead, above Sign out. The navbar now only shows
Inventory for everyone.
2026-08-17 15:39:09 -05:00
Owen 62bdcb2428 Consolidate Item Types, Users, and Settings into one Settings section
The navbar previously listed Item Types, Users, and Settings as three
separate top-level links. They now live under a single "Settings" entry
in the navbar, backed by one unified section with a left sub-nav (General,
Item Types, Users) shared across all three areas so it reads as one
settings panel rather than three disconnected admin pages.

- New templates/partials/settings_nav.html shared sub-nav, included by
  settings.html, itemtypes_list/form.html, and users_list/form.html.
- Routes moved under /settings/: /item-types* -> /settings/item-types*,
  /users* -> /settings/users*. All redirects, form actions, and the
  item-type custom-field JS base action updated to match.
2026-08-17 15:30:50 -05:00
Owen 4ceb1cbc03 Replace on-screen action buttons with right-click context menus
The tree and main panels now behave like a native file manager: right
click (or the keyboard Menu key) on a folder, asset, breadcrumb crumb, or
empty tree/panel background brings up the relevant actions instead of a
row of always-visible buttons.

- New generic showContextMenu() in app.js with per-target-kind menu
  builders (folder/asset/tree background/panel background), gated by
  admin role the same way the old buttons were.
- Removed: the "..." tree-row action button, and the New Folder/Add
  Asset/Rename/Delete/Edit/Move/Duplicate/Import/Export buttons from the
  inventory toolbar and every panel. Expand All/Collapse All shrank to two
  icon-only buttons next to the search box; everything else creation- or
  mutation-related is right-click only.
- Replaced the per-asset embedded "Move" modal with one shared
  #moveAssetModal populated via a new GET /app/folders/options endpoint,
  since move can now be triggered from any asset row, not just its own
  detail view.
- Breadcrumb crumbs (including the current folder) and folder/asset table
  rows are now valid right-click targets too, not just tree rows.
2026-08-17 15:24:32 -05:00
75 changed files with 2962 additions and 1000 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ data/
*.db-shm
*.db-wal
.git/
inventree
vibetree
+1 -1
View File
@@ -1,4 +1,4 @@
/inventree
/vibetree
/data/
*.db
*.db-shm
+7 -7
View File
@@ -9,19 +9,19 @@ RUN go mod download
COPY . .
ARG VERSION=dev
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/inventree ./cmd/inventree
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/vibetree ./cmd/vibetree
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
addgroup -S inventree && adduser -S inventree -G inventree
addgroup -S vibetree && adduser -S vibetree -G vibetree
WORKDIR /app
COPY --from=build /out/inventree /usr/local/bin/inventree
RUN mkdir -p /app/data && chown -R inventree:inventree /app/data
COPY --from=build /out/vibetree /usr/local/bin/vibetree
RUN mkdir -p /app/data && chown -R vibetree:vibetree /app/data
USER inventree
USER vibetree
VOLUME ["/app/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["inventree"]
CMD ["serve", "-addr", ":8080", "-db", "/app/data/inventree.db"]
ENTRYPOINT ["vibetree"]
CMD ["serve", "-addr", ":8080", "-db", "/app/data/vibetree.db"]
+98 -37
View File
@@ -1,6 +1,6 @@
# inventree
# vibetree
A small, self-contained IT asset inventory system. The heart of inventree is a
A small, self-contained IT asset inventory system. The heart of vibetree is a
nested folder tree — like a file manager — for organizing physical and
virtual IT assets (servers, switches, laptops, printers, etc.).
@@ -14,8 +14,8 @@ virtual IT assets (servers, switches, laptops, printers, etc.).
## Quick start
```sh
go build -o inventree ./cmd/inventree
./inventree serve
go build -o vibetree ./cmd/vibetree
./vibetree serve
```
Then open http://localhost:8080 and log in with:
@@ -28,18 +28,18 @@ Password: admin
(This default account is created intentionally on first run. Change or
disable it via the Users admin page once you're up and running.)
The database file is created automatically at `data/inventree.db` on first
The database file is created automatically at `data/vibetree.db` on first
run, with migrations applied and seed data (default statuses, item types,
and the admin account) inserted.
## CLI
```sh
inventree # same as `inventree serve`
inventree serve # start the web server
inventree version # print the version
inventree database migrate # apply pending migrations and exit
inventree database backup [path] # write a consistent VACUUM INTO snapshot
vibetree # same as `vibetree serve`
vibetree serve # start the web server
vibetree version # print the version
vibetree database migrate # apply pending migrations and exit
vibetree database backup [path] # write a consistent VACUUM INTO snapshot
```
`serve` flags:
@@ -47,7 +47,7 @@ inventree database backup [path] # write a consistent VACUUM INTO snapshot
| Flag | Default | Description |
|---------------------|------------------------|--------------------------------------------|
| `-addr` | `:8080` | Listen address |
| `-db` | `data/inventree.db` | Path to the SQLite database file |
| `-db` | `data/vibetree.db` | Path to the SQLite database file |
| `-secure-cookies` | `false` | Mark the session cookie `Secure` (enable when serving over HTTPS) |
`/healthz` is available for load balancer / container health checks.
@@ -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,26 +88,83 @@ 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/barcode asset tags and printable labels
Every asset can have a scannable 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). Choose the code format on the print
page itself:
- **QR code** — encodes an absolute `/scan/<Asset ID>` URL, so scanning it
with a phone camera opens straight to that asset.
- **Barcode (Code128)** — encodes the bare Asset ID, meant for a dedicated
USB barcode scanner typing it into whatever's focused (e.g. the app's
search box).
The print page also supports three label sheet layouts, switchable from a
dropdown:
- **Avery 5160 / 5260** (1" x 2-5/8", 30/sheet) — standard address labels
(5260 is the same grid, just rounded corners), 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.
**Printing a batch without wasting sheet space**: Ctrl/Cmd+click (toggle)
or Shift+click (range) multiple folders/assets in the tree, the same way
you would in a file manager, then right-click the selection for **Print
Labels (N)** — along with **Export CSV (N)**, **Edit Status…**,
**Check Out…**, **Duplicate**, **Move to…**, and **Delete**, all bulk
versions of the single-item actions. On the print page itself, **Start at
#** lets you resume printing partway into an already-used sheet (position 1
= top-left, counting left-to-right, top-to-bottom) instead of starting a
fresh one over a few peeled-off labels.
## 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_<field_key>` 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_<field_key>` 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_<field_key>` 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 `vibetree database backup` — it's a consistent
snapshot of the whole SQLite database.
## Project layout
```
cmd/inventree/ CLI entrypoint (serve, version, database migrate/backup)
cmd/vibetree/ CLI entrypoint (serve, version, database migrate/backup)
internal/
auth/ session management, secure cookies
database/ SQLite connection, pragmas, migrations
@@ -140,33 +201,33 @@ the compiled binary has no C library dependencies.
docker compose up --build
```
This builds the image, starts inventree on port 8080, and persists the
database in a named volume (`inventree-data`, mounted at `/app/data`).
This builds the image, starts vibetree on port 8080, and persists the
database in a named volume (`vibetree-data`, mounted at `/app/data`).
Or directly:
```sh
docker build -t inventree .
docker run -p 8080:8080 -v inventree-data:/app/data inventree
docker build -t vibetree .
docker run -p 8080:8080 -v vibetree-data:/app/data vibetree
```
## systemd
See `deploy/inventree.service` for an example unit. Adjust the paths, then:
See `deploy/vibetree.service` for an example unit. Adjust the paths, then:
```sh
sudo useradd --system --home /opt/inventree --shell /usr/sbin/nologin inventree
sudo mkdir -p /opt/inventree/data
sudo cp inventree /opt/inventree/inventree
sudo chown -R inventree:inventree /opt/inventree
sudo cp deploy/inventree.service /etc/systemd/system/
sudo useradd --system --home /opt/vibetree --shell /usr/sbin/nologin vibetree
sudo mkdir -p /opt/vibetree/data
sudo cp vibetree /opt/vibetree/vibetree
sudo chown -R vibetree:vibetree /opt/vibetree
sudo cp deploy/vibetree.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now inventree
sudo systemctl enable --now vibetree
```
## Scope
inventree is intentionally a focused MVP: folders, assets, custom types,
vibetree is intentionally a focused MVP: folders, assets, custom types,
statuses, users, search, and CSV import/export. It does not include
ticketing, purchasing, monitoring, RMM, MDM, or accounting features — if you
need those, look elsewhere and integrate via CSV.
+14 -14
View File
@@ -1,4 +1,4 @@
// Command inventree runs the inventree IT asset inventory server, or one of
// Command vibetree runs the vibetree IT asset inventory server, or one of
// its supporting subcommands (version, database migrate, database backup).
package main
@@ -14,8 +14,8 @@ import (
"syscall"
"time"
"inventree/internal/database"
"inventree/internal/web"
"vibetree/internal/database"
"vibetree/internal/web"
)
// version is set at build time via -ldflags "-X main.version=...".
@@ -36,11 +36,11 @@ func run(args []string) error {
case "serve":
return runServe(args[1:])
case "version":
fmt.Println("inventree", version)
fmt.Println("vibetree", version)
return nil
case "database":
if len(args) < 2 {
return fmt.Errorf("usage: inventree database <migrate|backup>")
return fmt.Errorf("usage: vibetree database <migrate|backup>")
}
switch args[1] {
case "migrate":
@@ -60,22 +60,22 @@ func run(args []string) error {
}
func printUsage() {
fmt.Println(`inventree - a small IT asset inventory system
fmt.Println(`vibetree - a small IT asset inventory system
Usage:
inventree [serve] Start the web server (default command)
inventree version Print the version
inventree database migrate Apply pending database migrations
inventree database backup [path] Write a consistent backup snapshot
vibetree [serve] Start the web server (default command)
vibetree version Print the version
vibetree database migrate Apply pending database migrations
vibetree database backup [path] Write a consistent backup snapshot
Flags for "serve":
-addr string listen address (default ":8080")
-db string path to the SQLite database file (default "data/inventree.db")
-db string path to the SQLite database file (default "data/vibetree.db")
-secure-cookies mark the session cookie Secure (enable when serving over HTTPS)`)
}
func dbPathFlag(args []string) (dbPath string, rest []string) {
dbPath = "data/inventree.db"
dbPath = "data/vibetree.db"
for i := 0; i < len(args); i++ {
if args[i] == "-db" && i+1 < len(args) {
dbPath = args[i+1]
@@ -137,7 +137,7 @@ func ensureDir(path string) error {
func runServe(args []string) error {
var (
addr = ":8080"
dbPath = "data/inventree.db"
dbPath = "data/vibetree.db"
secureCookies = false
)
for i := 0; i < len(args); i++ {
@@ -196,7 +196,7 @@ func runServe(args []string) error {
}
}()
log.Info("inventree listening", "addr", addr, "db", dbPath)
log.Info("vibetree listening", "addr", addr, "db", dbPath)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
@@ -1,13 +1,13 @@
[Unit]
Description=inventree - IT asset inventory
Description=vibetree - IT asset inventory
After=network.target
[Service]
Type=simple
User=inventree
Group=inventree
WorkingDirectory=/opt/inventree
ExecStart=/opt/inventree/inventree serve -addr :8080 -db /opt/inventree/data/inventree.db
User=vibetree
Group=vibetree
WorkingDirectory=/opt/vibetree
ExecStart=/opt/vibetree/vibetree serve -addr :8080 -db /opt/vibetree/data/vibetree.db
Restart=on-failure
RestartSec=2
@@ -15,7 +15,7 @@ RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/inventree/data
ReadWritePaths=/opt/vibetree/data
PrivateTmp=true
[Install]
+5 -5
View File
@@ -1,13 +1,13 @@
services:
inventree:
vibetree:
build: .
image: inventree:latest
container_name: inventree
image: vibetree:latest
container_name: vibetree
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- inventree-data:/app/data
- vibetree-data:/app/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
@@ -16,4 +16,4 @@ services:
start_period: 5s
volumes:
inventree-data:
vibetree-data:
+3 -1
View File
@@ -1,8 +1,10 @@
module inventree
module vibetree
go 1.25.0
require (
github.com/boombuler/barcode v1.1.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.0
)
+4
View File
@@ -1,3 +1,5 @@
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
@@ -12,6 +14,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=
+86 -93
View File
@@ -11,9 +11,9 @@ import (
"strings"
"time"
"inventree/internal/folders"
"inventree/internal/models"
"inventree/internal/settings"
"vibetree/internal/folders"
"vibetree/internal/models"
"vibetree/internal/settings"
)
var (
@@ -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,51 @@ 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
}
// SetStatus changes only an asset's status, independent of the full edit
// form - used by the bulk "Edit Status" multi-select action.
func (s *Store) SetStatus(ctx context.Context, id, statusID int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE assets SET status_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
statusID, 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 +301,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 +316,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 +327,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 {
@@ -466,84 +540,3 @@ func (s *Store) descendantFolderIDs(ctx context.Context, rootID int64) ([]int64,
walk(rootID)
return out, nil
}
// DashboardStats summarizes inventory counts for the small dashboard.
type DashboardStats struct {
Total int
ByStatus []StatusCount
ByType []TypeCount
RecentlyAdded []models.Asset
}
type StatusCount struct {
Name string
Color string
Count int
}
type TypeCount struct {
Name string
Icon string
Count int
}
func (s *Store) Stats(ctx context.Context) (*DashboardStats, error) {
stats := &DashboardStats{}
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets`).Scan(&stats.Total); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT st.name, st.color, COUNT(a.id)
FROM statuses st LEFT JOIN assets a ON a.status_id = st.id
GROUP BY st.id ORDER BY st.sort_order ASC`)
if err != nil {
return nil, err
}
for rows.Next() {
var sc StatusCount
if err := rows.Scan(&sc.Name, &sc.Color, &sc.Count); err != nil {
rows.Close()
return nil, err
}
stats.ByStatus = append(stats.ByStatus, sc)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = s.db.QueryContext(ctx, `
SELECT it.name, it.icon, COUNT(a.id)
FROM item_types it LEFT JOIN assets a ON a.item_type_id = it.id
GROUP BY it.id HAVING COUNT(a.id) > 0 ORDER BY COUNT(a.id) DESC`)
if err != nil {
return nil, err
}
for rows.Next() {
var tc TypeCount
if err := rows.Scan(&tc.Name, &tc.Icon, &tc.Count); err != nil {
rows.Close()
return nil, err
}
stats.ByType = append(stats.ByType, tc)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
recentRows, err := s.db.QueryContext(ctx, assetSelect+` ORDER BY a.created_at DESC LIMIT 8`)
if err != nil {
return nil, err
}
defer recentRows.Close()
for recentRows.Next() {
a, err := scanAsset(recentRows)
if err != nil {
return nil, err
}
stats.RecentlyAdded = append(stats.RecentlyAdded, *a)
}
return stats, recentRows.Err()
}
+5 -5
View File
@@ -8,11 +8,11 @@ import (
"strings"
"testing"
"inventree/internal/assets"
"inventree/internal/database"
"inventree/internal/folders"
"inventree/internal/models"
"inventree/internal/settings"
"vibetree/internal/assets"
"vibetree/internal/database"
"vibetree/internal/folders"
"vibetree/internal/models"
"vibetree/internal/settings"
)
// Seed data from migrations/0002_seed.sql gives us stable IDs to rely on:
+73 -21
View File
@@ -6,9 +6,10 @@ import (
"fmt"
"io"
"sort"
"strconv"
"strings"
"inventree/internal/models"
"vibetree/internal/models"
)
const (
@@ -19,12 +20,20 @@ const (
colStatus = "status"
colAssignedUser = "assigned user"
colNotes = "notes"
colQuantity = "quantity"
cfColPrefix = "cf_"
)
// ImportOptions controls CSV import behavior.
type ImportOptions struct {
CreateMissingFolders bool
// TargetFolderID, when set, imports every row directly into this
// folder and ignores any Folder column in the CSV entirely (used for
// the folder-scoped "import here" context menu action). When nil,
// each row's folder is resolved from its Folder column instead
// (global import), optionally creating missing folders.
TargetFolderID *int64
}
type ImportResult struct {
@@ -157,8 +166,9 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
}
var folderID *int64
folderPath := get(row, colFolder)
if folderPath != "" {
if opts.TargetFolderID != nil {
folderID = opts.TargetFolderID
} else if folderPath := get(row, colFolder); folderPath != "" {
id, err := s.resolveFolderPath(ctx, lk, folderPath, opts.CreateMissingFolders)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: folder %q: %v", rowNum, folderPath, err))
@@ -193,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,
@@ -201,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 != "" {
@@ -272,13 +290,8 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
if err != nil {
return err
}
cw := csv.NewWriter(w)
header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Created At", "Updated At"}
for _, k := range fieldDefs {
header = append(header, cfColPrefix+k)
}
if err := cw.Write(header); err != nil {
if err := writeAssetHeader(cw, fieldDefs); err != nil {
return err
}
@@ -294,18 +307,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
if err != nil {
return err
}
row := []string{
a.AssetTag, a.Name, a.ItemTypeName, folderPathString(a.FolderPath), a.StatusName, a.AssignedUsername, a.Notes,
a.CreatedAt.Format("2006-01-02 15:04:05"), a.UpdatedAt.Format("2006-01-02 15:04:05"),
}
values := map[string]string{}
for _, fv := range a.CustomFields {
values[fv.Def.FieldKey] = fv.Value
}
for _, k := range fieldDefs {
row = append(row, values[k])
}
if err := cw.Write(row); err != nil {
if err := writeAssetRow(cw, a, fieldDefs); err != nil {
return err
}
}
@@ -318,6 +320,56 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
return cw.Error()
}
// ExportByIDs writes exactly the given assets (in the given order) as CSV -
// used for a multi-select "Export CSV" batch that doesn't map onto a single
// ListFilter (an arbitrary mix of individually-picked assets and whole
// folders).
func (s *Store) ExportByIDs(ctx context.Context, w io.Writer, ids []int64) error {
fieldDefs, err := s.allFieldKeys(ctx)
if err != nil {
return err
}
cw := csv.NewWriter(w)
if err := writeAssetHeader(cw, fieldDefs); err != nil {
return err
}
for _, id := range ids {
a, err := s.Get(ctx, id)
if err != nil {
continue
}
if err := writeAssetRow(cw, a, fieldDefs); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
func writeAssetHeader(cw *csv.Writer, fieldDefs []string) error {
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)
}
return cw.Write(header)
}
func writeAssetRow(cw *csv.Writer, a *models.Asset, fieldDefs []string) 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{}
for _, fv := range a.CustomFields {
values[fv.Def.FieldKey] = fv.Value
}
for _, k := range fieldDefs {
row = append(row, values[k])
}
return cw.Write(row)
}
func folderPathString(path []models.BreadcrumbItem) string {
names := make([]string, len(path))
for i, p := range path {
+123
View File
@@ -0,0 +1,123 @@
package assets_test
import (
"context"
"testing"
"vibetree/internal/assets"
"vibetree/internal/itemtypes"
"vibetree/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)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package assets
import (
"context"
"inventree/internal/models"
"vibetree/internal/models"
)
// Search performs a global, case-insensitive substring search across asset
+3 -3
View File
@@ -10,12 +10,12 @@ import (
"net/http"
"time"
"inventree/internal/models"
"inventree/internal/users"
"vibetree/internal/models"
"vibetree/internal/users"
)
const (
CookieName = "inventree_session"
CookieName = "vibetree_session"
SessionTTL = 7 * 24 * time.Hour
contextUserK = ctxKey("current_user")
)
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"path/filepath"
"testing"
"inventree/internal/auth"
"inventree/internal/database"
"inventree/internal/users"
"vibetree/internal/auth"
"vibetree/internal/database"
"vibetree/internal/users"
)
func testDB(t *testing.T) *sql.DB {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"path/filepath"
"testing"
"inventree/internal/database"
"vibetree/internal/database"
)
func TestOpenSetsPragmasAndMigrates(t *testing.T) {
+1 -1
View File
@@ -1,4 +1,4 @@
-- Initial schema for inventree
-- Initial schema for vibetree
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+1 -1
View File
@@ -14,7 +14,7 @@ INSERT INTO settings (key, value) VALUES
('asset_id_prefix', 'IT-'),
('asset_id_padding', '6'),
('asset_id_seq', '0'),
('site_name', 'inventree');
('site_name', 'vibetree');
INSERT INTO item_types (name, description, icon, enabled, sort_order) VALUES
('Laptop', 'Portable computer', 'bi-laptop', 1, 1),
@@ -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;
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"fmt"
"time"
"inventree/internal/models"
"vibetree/internal/models"
)
var (
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"path/filepath"
"testing"
"inventree/internal/database"
"inventree/internal/folders"
"inventree/internal/models"
"vibetree/internal/database"
"vibetree/internal/folders"
"vibetree/internal/models"
)
func testDB(t *testing.T) *sql.DB {
+18 -9
View File
@@ -9,7 +9,7 @@ import (
"strings"
"time"
"inventree/internal/models"
"vibetree/internal/models"
)
var (
@@ -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
}
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"path/filepath"
"testing"
"inventree/internal/database"
"inventree/internal/itemtypes"
"inventree/internal/models"
"vibetree/internal/database"
"vibetree/internal/itemtypes"
"vibetree/internal/models"
)
func testDB(t *testing.T) *sql.DB {
+20 -1
View File
@@ -1,4 +1,4 @@
// Package models defines the core domain types shared across inventree.
// Package models defines the core domain types shared across vibetree.
package models
import "time"
@@ -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
// 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
+99
View File
@@ -0,0 +1,99 @@
package statuses
import (
"context"
"encoding/csv"
"fmt"
"io"
"strings"
"vibetree/internal/models"
)
type ImportResult struct {
Created int
Updated int
Errors []string
}
// Import creates or updates statuses from CSV data with "Name" and "Color"
// columns (case-insensitive header names, Color optional). Existing
// statuses are matched and updated by name (case-insensitive); everything
// else is created.
func (s *Store) Import(ctx context.Context, r io.Reader) (*ImportResult, error) {
existing, err := s.List(ctx)
if err != nil {
return nil, err
}
byName := map[string]int64{}
for _, st := range existing {
byName[strings.ToLower(st.Name)] = st.ID
}
cr := csv.NewReader(r)
cr.FieldsPerRecord = -1
header, err := cr.Read()
if err != nil {
if err == io.EOF {
return &ImportResult{}, nil
}
return nil, fmt.Errorf("read CSV header: %w", err)
}
colIdx := map[string]int{}
for i, h := range header {
colIdx[strings.ToLower(strings.TrimSpace(h))] = i
}
get := func(row []string, key string) string {
idx, ok := colIdx[key]
if !ok || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}
result := &ImportResult{}
rowNum := 1
for {
row, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read CSV row %d: %w", rowNum, err)
}
rowNum++
name := get(row, "name")
if name == "" {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: name is required, skipped", rowNum))
continue
}
color := get(row, "color")
if id, ok := byName[strings.ToLower(name)]; ok {
st, err := s.Get(ctx, id)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %v", rowNum, err))
continue
}
if color != "" {
st.Color = color
}
if err := s.Update(ctx, st); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: update failed: %v", rowNum, err))
continue
}
result.Updated++
continue
}
st := &models.Status{Name: name, Color: color}
if err := s.Create(ctx, st); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: create failed: %v", rowNum, err))
continue
}
byName[strings.ToLower(name)] = st.ID
result.Created++
}
return result, nil
}
+76
View File
@@ -0,0 +1,76 @@
package statuses_test
import (
"context"
"database/sql"
"path/filepath"
"strings"
"testing"
"vibetree/internal/database"
"vibetree/internal/statuses"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
dir := t.TempDir()
db, err := database.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestImportCreatesAndUpdatesByName(t *testing.T) {
ctx := context.Background()
s := statuses.NewStore(testDB(t))
// "In Use" already exists from seed data; this should update its
// color rather than create a duplicate, and create "Awaiting Parts".
csvData := "Name,Color\nIn Use,primary\nAwaiting Parts,warning\n"
result, err := s.Import(ctx, strings.NewReader(csvData))
if err != nil {
t.Fatalf("import: %v", err)
}
if result.Created != 1 || result.Updated != 1 {
t.Fatalf("expected 1 created, 1 updated, got %+v", result)
}
list, err := s.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
var foundInUse, foundAwaiting bool
for _, st := range list {
if st.Name == "In Use" {
foundInUse = true
if st.Color != "primary" {
t.Errorf("expected In Use color updated to primary, got %s", st.Color)
}
}
if st.Name == "Awaiting Parts" {
foundAwaiting = true
if st.Color != "warning" {
t.Errorf("expected Awaiting Parts color warning, got %s", st.Color)
}
}
}
if !foundInUse || !foundAwaiting {
t.Fatalf("expected both statuses present, got %+v", list)
}
}
func TestImportSkipsRowsMissingName(t *testing.T) {
ctx := context.Background()
s := statuses.NewStore(testDB(t))
csvData := "Name,Color\n,primary\n"
result, err := s.Import(ctx, strings.NewReader(csvData))
if err != nil {
t.Fatalf("import: %v", err)
}
if result.Created != 0 || len(result.Errors) != 1 {
t.Fatalf("expected 0 created and 1 error, got %+v", result)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"errors"
"time"
"inventree/internal/models"
"vibetree/internal/models"
)
var (
+11 -4
View File
@@ -9,17 +9,21 @@ import (
"golang.org/x/crypto/bcrypt"
"inventree/internal/models"
"vibetree/internal/models"
)
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")
}
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"path/filepath"
"testing"
"inventree/internal/database"
"inventree/internal/models"
"inventree/internal/users"
"vibetree/internal/database"
"vibetree/internal/models"
"vibetree/internal/users"
)
func testDB(t *testing.T) *sql.DB {
+9 -9
View File
@@ -1,5 +1,5 @@
// Package web implements the HTTP server: routing, session middleware,
// template rendering, and all page/API handlers for inventree.
// template rendering, and all page/API handlers for vibetree.
package web
import (
@@ -9,13 +9,13 @@ import (
"net/http"
"time"
"inventree/internal/assets"
"inventree/internal/auth"
"inventree/internal/folders"
"inventree/internal/itemtypes"
"inventree/internal/settings"
"inventree/internal/statuses"
"inventree/internal/users"
"vibetree/internal/assets"
"vibetree/internal/auth"
"vibetree/internal/folders"
"vibetree/internal/itemtypes"
"vibetree/internal/settings"
"vibetree/internal/statuses"
"vibetree/internal/users"
)
// App holds every dependency HTTP handlers need: data stores, templates, and
@@ -58,7 +58,7 @@ func NewApp(db *sql.DB, log *slog.Logger, secureCookies bool) (*App, error) {
SecureCookies: secureCookies,
}
tmpl, err := loadTemplates()
tmpl, err := loadTemplates(app)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -5,9 +5,9 @@ import "context"
// FolderOption is a flattened, indented folder choice for <select> dropdowns
// (asset create/edit, move-to pickers).
type FolderOption struct {
ID int64
Path string
Depth int
ID int64 `json:"id"`
Path string `json:"path"`
Depth int `json:"depth"`
}
// folderOptions returns every folder as a flat, depth-first ordered list
+95 -3
View File
@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"inventree/internal/models"
"vibetree/internal/models"
)
type assetRequest struct {
@@ -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,94 @@ 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 assetStatusRequest struct {
StatusID int64 `json:"status_id"`
}
// handleAssetSetStatus changes only an asset's status - used by the bulk
// "Edit Status" multi-select action so it doesn't have to resend every
// other field the way a full PATCH does.
func (a *App) handleAssetSetStatus(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req assetStatusRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
if err := a.Assets.SetStatus(r.Context(), id, req.StatusID); err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
jsonOK(w, map[string]any{"id": id, "status_id": req.StatusID})
}
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})
}
+1 -1
View File
@@ -3,7 +3,7 @@ package web
import (
"net/http"
"inventree/internal/auth"
"vibetree/internal/auth"
)
type loginPageData struct {
+46 -7
View File
@@ -5,11 +5,46 @@ import (
"net/http"
"time"
"inventree/internal/assets"
"vibetree/internal/assets"
)
func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
filename := fmt.Sprintf("vibetree-assets-%s.csv", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
assetIDsParam := r.URL.Query().Get("asset_ids")
folderIDsParam := r.URL.Query().Get("folder_ids")
if assetIDsParam != "" || folderIDsParam != "" {
// A multi-select batch (an arbitrary mix of individually-picked
// assets and whole folders) doesn't map onto a single ListFilter.
seen := map[int64]bool{}
var ids []int64
for _, id := range parseIDList(assetIDsParam) {
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
for _, fid := range parseIDList(folderIDsParam) {
batch, _, err := a.Assets.List(ctx, assets.ListFilter{FolderID: &fid, FolderSet: true, IncludeSub: true, Page: 1, PageSize: 2000})
if err != nil {
continue
}
for _, as := range batch {
if !seen[as.ID] {
seen[as.ID] = true
ids = append(ids, as.ID)
}
}
}
if err := a.Assets.ExportByIDs(ctx, w, ids); err != nil {
a.Log.Error("csv export failed", "error", err)
}
return
}
filter := assets.ListFilter{
ItemTypeID: queryInt64(r, "item_type_id"),
StatusID: queryInt64(r, "status_id"),
@@ -21,15 +56,16 @@ func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
filter.FolderSet = true
filter.IncludeSub = r.URL.Query().Get("recursive") == "1"
}
filename := fmt.Sprintf("inventree-assets-%s.csv", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
if err := a.Assets.Export(ctx, w, filter); err != nil {
a.Log.Error("csv export failed", "error", err)
}
}
// handleImportCSV backs the folder-scoped "Import CSV..." context-menu
// action: every row imports directly into the given folder_id, so the CSV
// needs no Folder column at all. It's only ever invoked from within a
// specific folder (never at the top level) and responds with JSON since
// it's called via fetch from the tree/panel context menu.
func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseMultipartForm(32 << 20); err != nil {
@@ -43,7 +79,10 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()
createMissing := r.FormValue("create_missing_folders") == "1"
opts := assets.ImportOptions{}
if folderID, err := formOptionalID(r, "folder_id"); err == nil && folderID != nil {
opts.TargetFolderID = folderID
}
itemTypes, err := a.ItemTypes.List(ctx, true)
if err != nil {
@@ -61,7 +100,7 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
return
}
result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList)
result, err := a.Assets.Import(ctx, file, opts, itemTypes, statusList, userList)
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
+13 -2
View File
@@ -4,10 +4,21 @@ import (
"errors"
"net/http"
"inventree/internal/folders"
"inventree/internal/models"
"vibetree/internal/folders"
"vibetree/internal/models"
)
// handleFolderOptions returns every folder as a flat, indented list for
// generic "move to..." pickers (e.g. the asset move context-menu action).
func (a *App) handleFolderOptions(w http.ResponseWriter, r *http.Request) {
opts, err := a.folderOptions(r.Context())
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
jsonOK(w, opts)
}
func (a *App) handleFolderGet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
+39 -56
View File
@@ -1,20 +1,24 @@
package web
import (
"context"
"net/http"
"inventree/internal/assets"
"inventree/internal/models"
"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
Root *RootPanelData
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) {
@@ -31,11 +35,6 @@ func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
root, err := a.rootPanelData(ctx)
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())
@@ -48,7 +47,8 @@ func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
}
a.Render(w, http.StatusOK, "inventory", InventoryPageData{
User: u, Tree: tree, Root: root, 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"),
})
}
@@ -112,39 +112,13 @@ func (a *App) handleTreeCollapseAll(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// RootPanelData backs the main panel when nothing is selected: the virtual
// "Inventory" root, showing top-level folders, unfiled assets, and the
// small dashboard.
type RootPanelData struct {
Folders []models.Folder
UnfiledAssets []models.Asset
UnfiledTotal int
Stats *assets.DashboardStats
}
func (a *App) rootPanelData(ctx context.Context) (*RootPanelData, error) {
folderList, err := a.Folders.Children(ctx, nil)
if err != nil {
return nil, err
}
unfiled, total, err := a.Assets.List(ctx, assets.ListFilter{FolderSet: true, FolderID: nil, Page: 1, PageSize: 50})
if err != nil {
return nil, err
}
stats, err := a.Assets.Stats(ctx)
if err != nil {
return nil, err
}
return &RootPanelData{Folders: folderList, UnfiledAssets: unfiled, UnfiledTotal: total, Stats: stats}, nil
}
// 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) {
data, err := a.rootPanelData(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "panel_root", data)
a.RenderFragment(w, http.StatusOK, "panel_root", nil)
}
// FolderPanelData backs the main panel when a folder is selected.
@@ -222,8 +196,6 @@ func (a *App) handlePanelFolder(w http.ResponseWriter, r *http.Request) {
// AssetPanelData backs the main panel when an asset is selected (view mode).
type AssetPanelData struct {
Asset *models.Asset
CanEdit bool
FolderOptions []FolderOption
}
func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
@@ -238,12 +210,7 @@ func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, err.Error())
return
}
opts, err := a.folderOptions(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "panel_asset", AssetPanelData{Asset: asset, CanEdit: currentUser(r).IsAdmin(), FolderOptions: opts})
a.RenderFragment(w, http.StatusOK, "panel_asset", AssetPanelData{Asset: asset})
}
// AssetFormData backs both the "new asset" and "edit asset" forms.
@@ -255,6 +222,8 @@ type AssetFormData struct {
Users []models.User
FolderOptions []FolderOption
SelectedIT int64
SelectedITUnique bool
Quantity int
Fields []models.AssetFieldValue
}
@@ -287,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,
})
}
@@ -334,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")
@@ -349,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),
})
}
+10 -8
View File
@@ -5,7 +5,7 @@ import (
"strconv"
"strings"
"inventree/internal/models"
"vibetree/internal/models"
)
type itemTypesListData struct {
@@ -41,12 +41,13 @@ 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()})
return
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(it.ID, 10)+"/edit", http.StatusSeeOther)
http.Redirect(w, r, "/settings/item-types/"+strconv.FormatInt(it.ID, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleItemTypeEditForm(w http.ResponseWriter, r *http.Request) {
@@ -83,11 +84,12 @@ 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
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
http.Redirect(w, r, "/settings/item-types/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleItemTypeDelete(w http.ResponseWriter, r *http.Request) {
@@ -97,10 +99,10 @@ func (a *App) handleItemTypeDelete(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.ItemTypes.Delete(r.Context(), id); err != nil {
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(id, 10)+"/edit?error="+err.Error(), http.StatusSeeOther)
http.Redirect(w, r, "/settings/item-types/"+strconv.FormatInt(id, 10)+"/edit?error="+err.Error(), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/item-types", http.StatusSeeOther)
http.Redirect(w, r, "/settings/item-types", http.StatusSeeOther)
}
func (a *App) handleFieldCreate(w http.ResponseWriter, r *http.Request) {
@@ -124,7 +126,7 @@ func (a *App) handleFieldCreate(w http.ResponseWriter, r *http.Request) {
if err := a.ItemTypes.CreateField(r.Context(), f); err != nil {
a.Log.Warn("create field failed", "error", err)
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
http.Redirect(w, r, "/settings/item-types/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleFieldUpdate(w http.ResponseWriter, r *http.Request) {
@@ -159,7 +161,7 @@ func (a *App) handleFieldUpdate(w http.ResponseWriter, r *http.Request) {
if err := a.ItemTypes.UpdateField(r.Context(), f); err != nil {
a.Log.Warn("update field failed", "error", err)
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(itemTypeID, 10)+"/edit", http.StatusSeeOther)
http.Redirect(w, r, "/settings/item-types/"+strconv.FormatInt(itemTypeID, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleFieldDelete(w http.ResponseWriter, r *http.Request) {
@@ -176,7 +178,7 @@ func (a *App) handleFieldDelete(w http.ResponseWriter, r *http.Request) {
if err := a.ItemTypes.DeleteField(r.Context(), fieldID); err != nil {
a.Log.Warn("delete field failed", "error", err)
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(itemTypeID, 10)+"/edit", http.StatusSeeOther)
http.Redirect(w, r, "/settings/item-types/"+strconv.FormatInt(itemTypeID, 10)+"/edit", http.StatusSeeOther)
}
func atoiDefault(s string, def int) int {
+238
View File
@@ -0,0 +1,238 @@
package web
import (
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"vibetree/internal/assets"
"vibetree/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
BarcodeWidthIn float64
BarcodeHeightIn 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, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4,
},
"5260": {
// Same 30-per-sheet grid as 5160 (1" x 2-5/8"); 5260 is just the
// rounded-corner version of the same physical layout.
Key: "5260", DisplayName: `Avery 5260 - 1" x 2-5/8" (30/sheet, rounded corners)`,
LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10,
MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0,
QRSizeIn: 0.85, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4,
},
"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, BarcodeWidthIn: 1.7, BarcodeHeightIn: 0.3, 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, .label-blank {
width: %.4fin;
height: %.4fin;
box-sizing: border-box;
}
.label {
padding: 0.06in 0.08in;
overflow: hidden;
page-break-inside: avoid;
}
.label.format-qr {
display: flex;
align-items: center;
gap: 0.08in;
}
.label.format-qr img { width: %.4fin; height: %.4fin; flex-shrink: 0; }
.label.format-barcode {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.03in;
}
.label.format-barcode img { width: %.4fin; height: %.4fin; }
`,
t.MarginTopIn, t.MarginLeftIn, t.Cols, t.LabelWidthIn, t.ColGapIn, t.RowGapIn,
t.LabelWidthIn, t.LabelHeightIn,
t.QRSizeIn, t.QRSizeIn,
t.BarcodeWidthIn, t.BarcodeHeightIn))
}
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.
ImageData template.URL
}
type labelsPageData struct {
User *models.User
Labels []labelItem
SheetCSS template.CSS
Templates map[string]LabelTemplate
TemplateKeys []string
Selected LabelTemplate
Format string // "qr" or "barcode"
AssetIDs string
FolderIDs string
StartPos int
BlankCells []int // len == StartPos-1, just for {{range}} to emit that many placeholder cells
}
func parseIDList(s string) []int64 {
var out []int64
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if id, err := strconv.ParseInt(part, 10, 64); err == nil {
out = append(out, id)
}
}
return out
}
// handleLabelsPage renders a print-ready sheet of QR-coded asset labels for
// any combination of individual assets (?asset_ids=1,2,3) and whole folders
// (?folder_ids=5,9, expanded recursively), or the entire inventory when
// neither is given. ?start=N (1-indexed) leaves the first N-1 cells blank,
// so a partially-used sheet can be resumed instead of wasted. 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]
}
startPos := queryInt(r, "start", 1)
if startPos < 1 {
startPos = 1
}
assetIDsParam := r.URL.Query().Get("asset_ids")
folderIDsParam := r.URL.Query().Get("folder_ids")
seen := map[int64]bool{}
var list []models.Asset
addAsset := func(as *models.Asset) {
if !seen[as.ID] {
seen[as.ID] = true
list = append(list, *as)
}
}
if assetIDsParam == "" && folderIDsParam == "" {
batch, _, err := a.Assets.List(ctx, assets.ListFilter{Page: 1, PageSize: 2000})
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
for i := range batch {
addAsset(&batch[i])
}
} else {
for _, id := range parseIDList(assetIDsParam) {
if as, err := a.Assets.Get(ctx, id); err == nil {
addAsset(as)
}
}
for _, fid := range parseIDList(folderIDsParam) {
batch, _, err := a.Assets.List(ctx, assets.ListFilter{
FolderID: &fid, FolderSet: true, IncludeSub: true, Page: 1, PageSize: 2000,
})
if err != nil {
continue
}
for i := range batch {
addAsset(&batch[i])
}
}
}
format := r.URL.Query().Get("format")
if format != "barcode" {
format = "qr"
}
labels := make([]labelItem, 0, len(list))
for _, as := range list {
var (
data string
err error
)
if format == "barcode" {
// A linear barcode is meant for a dedicated scanner typing the
// raw tag into whatever's focused (e.g. the app's search box),
// so it encodes the bare Asset ID rather than a URL.
data, err = barcodeDataURI(as.AssetTag, int(tmpl.BarcodeWidthIn*300), int(tmpl.BarcodeHeightIn*300))
} else {
// A QR code is meant for a phone camera, so it encodes a full
// URL that opens straight to the asset.
data, err = qrDataURI(origin+"/scan/"+as.AssetTag, 220)
}
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
labels = append(labels, labelItem{Asset: as, ImageData: template.URL(data)})
}
a.Render(w, http.StatusOK, "labels", labelsPageData{
User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(),
Templates: labelTemplates, TemplateKeys: []string{"5160", "5260", "61526"},
Selected: tmpl, Format: format, AssetIDs: assetIDsParam, FolderIDs: folderIDsParam,
StartPos: startPos, BlankCells: make([]int, startPos-1),
})
}
+21
View File
@@ -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 -1
View File
@@ -3,7 +3,7 @@ package web
import (
"net/http"
"inventree/internal/models"
"vibetree/internal/models"
)
type SearchPanelData struct {
+34 -31
View File
@@ -4,48 +4,37 @@ import (
"net/http"
"strconv"
"inventree/internal/models"
"vibetree/internal/models"
)
type settingsPageData struct {
User *models.User
Statuses []models.Status
SiteName string
AssetPrefix string
AssetPadding string
Error string
}
func (a *App) settingsData(r *http.Request) (settingsPageData, error) {
ctx := r.Context()
statusList, err := a.Statuses.List(ctx)
if err != nil {
return settingsPageData{}, err
}
siteName, err := a.Settings.Get(ctx, "site_name", "inventree")
if err != nil {
return settingsPageData{}, err
}
prefix, err := a.Settings.Get(ctx, "asset_id_prefix", "IT-")
if err != nil {
return settingsPageData{}, err
}
padding, err := a.Settings.Get(ctx, "asset_id_padding", "6")
if err != nil {
return settingsPageData{}, err
}
return settingsPageData{
User: currentUser(r), Statuses: statusList, SiteName: siteName, AssetPrefix: prefix, AssetPadding: padding,
}, nil
}
func (a *App) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
data, err := a.settingsData(r)
ctx := r.Context()
siteName, err := a.Settings.Get(ctx, "site_name", "vibetree")
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.Render(w, http.StatusOK, "settings", data)
prefix, err := a.Settings.Get(ctx, "asset_id_prefix", "IT-")
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
padding, err := a.Settings.Get(ctx, "asset_id_padding", "6")
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.Render(w, http.StatusOK, "settings", settingsPageData{
User: currentUser(r), SiteName: siteName, AssetPrefix: prefix, AssetPadding: padding,
})
}
func (a *App) handleSettingsGeneral(w http.ResponseWriter, r *http.Request) {
@@ -62,6 +51,20 @@ func (a *App) handleSettingsGeneral(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
type statusesPageData struct {
User *models.User
Statuses []models.Status
}
func (a *App) handleSettingsStatusesPage(w http.ResponseWriter, r *http.Request) {
statusList, err := a.Statuses.List(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.Render(w, http.StatusOK, "settings_statuses", statusesPageData{User: currentUser(r), Statuses: statusList})
}
func (a *App) handleStatusCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
@@ -71,7 +74,7 @@ func (a *App) handleStatusCreate(w http.ResponseWriter, r *http.Request) {
if err := a.Statuses.Create(r.Context(), st); err != nil {
a.Log.Warn("create status failed", "error", err)
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
http.Redirect(w, r, "/settings/statuses", http.StatusSeeOther)
}
func (a *App) handleStatusUpdate(w http.ResponseWriter, r *http.Request) {
@@ -94,7 +97,7 @@ func (a *App) handleStatusUpdate(w http.ResponseWriter, r *http.Request) {
if err := a.Statuses.Update(r.Context(), st); err != nil {
a.Log.Warn("update status failed", "error", err)
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
http.Redirect(w, r, "/settings/statuses", http.StatusSeeOther)
}
func (a *App) handleStatusDelete(w http.ResponseWriter, r *http.Request) {
@@ -106,7 +109,7 @@ func (a *App) handleStatusDelete(w http.ResponseWriter, r *http.Request) {
if err := a.Statuses.Delete(r.Context(), id); err != nil {
a.Log.Warn("delete status failed", "error", err)
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
http.Redirect(w, r, "/settings/statuses", http.StatusSeeOther)
}
func (a *App) handleStatusReorder(w http.ResponseWriter, r *http.Request) {
@@ -152,5 +155,5 @@ func (a *App) handleStatusReorder(w http.ResponseWriter, r *http.Request) {
}
}
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
http.Redirect(w, r, "/settings/statuses", http.StatusSeeOther)
}
+95
View File
@@ -0,0 +1,95 @@
package web
import (
"fmt"
"net/http"
"net/url"
"vibetree/internal/assets"
"vibetree/internal/models"
)
type settingsImportPageData struct {
User *models.User
Kind string
Imported int
Updated int
Errs int
Error string
}
// handleSettingsImportPage backs the admin "Import / Export" settings page:
// global CSV import for assets (with a Folder column, optionally
// auto-creating missing folders) and for statuses, plus format examples.
// This is distinct from the folder-scoped "Import CSV..." context-menu
// action in the tree, which never needs a Folder column.
func (a *App) handleSettingsImportPage(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
a.Render(w, http.StatusOK, "settings_import", settingsImportPageData{
User: currentUser(r),
Kind: q.Get("kind"),
Imported: queryInt(r, "imported", 0),
Updated: queryInt(r, "updated", 0),
Errs: queryInt(r, "errs", 0),
Error: q.Get("error"),
})
}
func (a *App) handleSettingsImportAssets(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
defer file.Close()
createMissing := r.FormValue("create_missing_folders") == "1"
itemTypes, err := a.ItemTypes.List(ctx, true)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
statusList, err := a.Statuses.List(ctx)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
userList, err := a.Users.List(ctx)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, fmt.Sprintf("/settings/import?kind=assets&imported=%d&updated=%d&errs=%d", result.Created, result.Updated, len(result.Errors)), http.StatusSeeOther)
}
func (a *App) handleSettingsImportStatuses(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
defer file.Close()
result, err := a.Statuses.Import(r.Context(), file)
if err != nil {
http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, fmt.Sprintf("/settings/import?kind=statuses&imported=%d&updated=%d&errs=%d", result.Created, result.Updated, len(result.Errors)), http.StatusSeeOther)
}
+63 -13
View File
@@ -1,12 +1,38 @@
package web
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strconv"
"inventree/internal/models"
"vibetree/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,21 +84,26 @@ 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 == "" {
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()})
return
}
http.Redirect(w, r, "/users", http.StatusSeeOther)
http.Redirect(w, r, "/settings/users", http.StatusSeeOther)
}
func (a *App) handleUserEditForm(w http.ResponseWriter, r *http.Request) {
@@ -83,16 +136,13 @@ 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()})
return
}
http.Redirect(w, r, "/users", http.StatusSeeOther)
http.Redirect(w, r, "/settings/users", http.StatusSeeOther)
}
func (a *App) handleUserPassword(w http.ResponseWriter, r *http.Request) {
@@ -111,7 +161,7 @@ func (a *App) handleUserPassword(w http.ResponseWriter, r *http.Request) {
a.Log.Warn("set password failed", "error", err)
}
}
http.Redirect(w, r, "/users/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
http.Redirect(w, r, "/settings/users/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleUserDelete(w http.ResponseWriter, r *http.Request) {
@@ -123,5 +173,5 @@ func (a *App) handleUserDelete(w http.ResponseWriter, r *http.Request) {
if err := a.Users.Delete(r.Context(), id); err != nil {
a.Log.Warn("delete user failed", "error", err)
}
http.Redirect(w, r, "/users", http.StatusSeeOther)
http.Redirect(w, r, "/settings/users", http.StatusSeeOther)
}
+14
View File
@@ -25,6 +25,20 @@ func queryOptionalID(r *http.Request, name string) (*int64, error) {
return &id, nil
}
// formOptionalID parses a POST form/multipart-form field (already parsed via
// ParseForm/ParseMultipartForm) as *int64; empty or missing returns nil.
func formOptionalID(r *http.Request, name string) (*int64, error) {
v := r.FormValue(name)
if v == "" {
return nil, nil
}
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, err
}
return &id, nil
}
func queryInt64(r *http.Request, name string) int64 {
v := r.URL.Query().Get(name)
if v == "" {
+2 -2
View File
@@ -3,8 +3,8 @@ package web
import (
"net/http"
"inventree/internal/auth"
"inventree/internal/models"
"vibetree/internal/auth"
"vibetree/internal/models"
)
// loadSession attaches the current user (if any) to the request context
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"bytes"
"encoding/base64"
"image/png"
"net/http"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/code128"
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
}
// barcodeDataURI renders content as a Code128 linear barcode PNG, scaled to
// widthPx x heightPx, and returns it as a data: URI.
func barcodeDataURI(content string, widthPx, heightPx int) (string, error) {
bc, err := code128.Encode(content)
if err != nil {
return "", err
}
scaled, err := barcode.Scale(bc, widthPx, heightPx)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := png.Encode(&buf, scaled); err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), 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
}
+18 -4
View File
@@ -1,6 +1,7 @@
package web
import (
"context"
"fmt"
"html/template"
"io/fs"
@@ -9,7 +10,7 @@ import (
"strings"
"time"
content "inventree/web"
content "vibetree/web"
)
// templateFuncs are available to every template (pages and fragments).
@@ -94,8 +95,21 @@ type templateSet struct {
// each page can define a block named "content" without colliding with the
// others (html/template templates sharing one *template.Template must have
// unique names, but isolated clones may reuse the same name freely).
func loadTemplates() (*templateSet, error) {
shared := template.New("root").Funcs(templateFuncs)
func loadTemplates(a *App) (*templateSet, error) {
// siteName is bound per-App (rather than living in the package-level
// templateFuncs) since it needs a live DB read through a.Settings; it's
// used by layout.html for the <title> and navbar brand text.
appFuncs := template.FuncMap{
"siteName": func() string {
name, err := a.Settings.Get(context.Background(), "site_name", "vibetree")
if err != nil || name == "" {
return "vibetree"
}
return name
},
}
shared := template.New("root").Funcs(templateFuncs).Funcs(appFuncs)
shared, err := shared.ParseFS(content.TemplatesFS, "templates/layout.html")
if err != nil {
@@ -124,7 +138,7 @@ func loadTemplates() (*templateSet, error) {
pages[name] = clone
}
login := template.New("login-root").Funcs(templateFuncs)
login := template.New("login-root").Funcs(templateFuncs).Funcs(appFuncs)
login, err = login.ParseFS(content.TemplatesFS, "templates/login.html")
if err != nil {
return nil, fmt.Errorf("parse login: %w", err)
+39 -22
View File
@@ -3,10 +3,10 @@ package web
import (
"net/http"
content "inventree/web"
content "vibetree/web"
)
// Router builds the complete HTTP handler for inventree: static assets,
// Router builds the complete HTTP handler for vibetree: static assets,
// authentication, the AJAX-driven inventory tree, and the traditional
// full-page admin sections (item types, users, settings).
func (a *App) Router() http.Handler {
@@ -40,6 +40,7 @@ func (a *App) Router() http.Handler {
mux.HandleFunc("GET /app/search", a.requireLogin(a.handleSearch))
// Folder mutations (JSON in/out)
mux.HandleFunc("GET /app/folders/options", a.requireAdmin(a.handleFolderOptions))
mux.HandleFunc("GET /app/folders/{id}", a.requireAdmin(a.handleFolderGet))
mux.HandleFunc("POST /app/folders", a.requireAdmin(a.handleFolderCreate))
mux.HandleFunc("PATCH /app/folders/{id}", a.requireAdmin(a.handleFolderUpdate))
@@ -48,44 +49,60 @@ 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}/status", a.requireAdmin(a.handleAssetSetStatus))
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))
// Item types (admin, traditional pages)
mux.HandleFunc("GET /item-types", a.requireAdmin(a.handleItemTypesList))
mux.HandleFunc("GET /item-types/new", a.requireAdmin(a.handleItemTypeNewForm))
mux.HandleFunc("POST /item-types", a.requireAdmin(a.handleItemTypeCreate))
mux.HandleFunc("GET /item-types/{id}/edit", a.requireAdmin(a.handleItemTypeEditForm))
mux.HandleFunc("POST /item-types/{id}", a.requireAdmin(a.handleItemTypeUpdate))
mux.HandleFunc("POST /item-types/{id}/delete", a.requireAdmin(a.handleItemTypeDelete))
mux.HandleFunc("POST /item-types/{id}/fields", a.requireAdmin(a.handleFieldCreate))
mux.HandleFunc("POST /item-types/{id}/fields/{fieldID}", a.requireAdmin(a.handleFieldUpdate))
mux.HandleFunc("POST /item-types/{id}/fields/{fieldID}/delete", a.requireAdmin(a.handleFieldDelete))
// 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))
// Users (admin, traditional pages)
mux.HandleFunc("GET /users", a.requireAdmin(a.handleUsersList))
mux.HandleFunc("GET /users/new", a.requireAdmin(a.handleUserNewForm))
mux.HandleFunc("POST /users", a.requireAdmin(a.handleUserCreate))
mux.HandleFunc("GET /users/{id}/edit", a.requireAdmin(a.handleUserEditForm))
mux.HandleFunc("POST /users/{id}", a.requireAdmin(a.handleUserUpdate))
mux.HandleFunc("POST /users/{id}/password", a.requireAdmin(a.handleUserPassword))
mux.HandleFunc("POST /users/{id}/delete", a.requireAdmin(a.handleUserDelete))
// Settings (admin, traditional page)
// 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.
mux.HandleFunc("GET /settings", a.requireAdmin(a.handleSettingsPage))
mux.HandleFunc("POST /settings/general", a.requireAdmin(a.handleSettingsGeneral))
mux.HandleFunc("GET /settings/statuses", a.requireAdmin(a.handleSettingsStatusesPage))
mux.HandleFunc("POST /settings/statuses", a.requireAdmin(a.handleStatusCreate))
mux.HandleFunc("POST /settings/statuses/{id}", a.requireAdmin(a.handleStatusUpdate))
mux.HandleFunc("POST /settings/statuses/{id}/delete", a.requireAdmin(a.handleStatusDelete))
mux.HandleFunc("POST /settings/statuses/{id}/reorder", a.requireAdmin(a.handleStatusReorder))
mux.HandleFunc("GET /settings/item-types", a.requireAdmin(a.handleItemTypesList))
mux.HandleFunc("GET /settings/item-types/new", a.requireAdmin(a.handleItemTypeNewForm))
mux.HandleFunc("POST /settings/item-types", a.requireAdmin(a.handleItemTypeCreate))
mux.HandleFunc("GET /settings/item-types/{id}/edit", a.requireAdmin(a.handleItemTypeEditForm))
mux.HandleFunc("POST /settings/item-types/{id}", a.requireAdmin(a.handleItemTypeUpdate))
mux.HandleFunc("POST /settings/item-types/{id}/delete", a.requireAdmin(a.handleItemTypeDelete))
mux.HandleFunc("POST /settings/item-types/{id}/fields", a.requireAdmin(a.handleFieldCreate))
mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}", a.requireAdmin(a.handleFieldUpdate))
mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}/delete", a.requireAdmin(a.handleFieldDelete))
mux.HandleFunc("GET /settings/import", a.requireAdmin(a.handleSettingsImportPage))
mux.HandleFunc("POST /settings/import/assets", a.requireAdmin(a.handleSettingsImportAssets))
mux.HandleFunc("POST /settings/import/statuses", a.requireAdmin(a.handleSettingsImportStatuses))
mux.HandleFunc("GET /settings/users", a.requireAdmin(a.handleUsersList))
mux.HandleFunc("GET /settings/users/new", a.requireAdmin(a.handleUserNewForm))
mux.HandleFunc("POST /settings/users", a.requireAdmin(a.handleUserCreate))
mux.HandleFunc("GET /settings/users/{id}/edit", a.requireAdmin(a.handleUserEditForm))
mux.HandleFunc("POST /settings/users/{id}", a.requireAdmin(a.handleUserUpdate))
mux.HandleFunc("POST /settings/users/{id}/password", a.requireAdmin(a.handleUserPassword))
mux.HandleFunc("POST /settings/users/{id}/delete", a.requireAdmin(a.handleUserDelete))
return a.loadSession(mux)
}
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
"inventree/internal/assets"
"inventree/internal/models"
"vibetree/internal/assets"
"vibetree/internal/models"
)
// treeAssetLimit bounds how many assets are rendered directly inside a
+4 -4
View File
@@ -11,9 +11,9 @@ import (
"strings"
"testing"
"inventree/internal/database"
"inventree/internal/models"
"inventree/internal/web"
"vibetree/internal/database"
"vibetree/internal/models"
"vibetree/internal/web"
)
func testApp(t *testing.T) *web.App {
@@ -49,7 +49,7 @@ func loginAs(t *testing.T, srv *httptest.Server, username, password string) *htt
t.Fatalf("expected redirect on login, got %d", res.StatusCode)
}
for _, c := range res.Cookies() {
if c.Name == "inventree_session" {
if c.Name == "vibetree_session" {
return c
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Package content embeds inventree's HTML templates and static assets
// Package content embeds vibetree's HTML templates and static assets
// (Bootstrap CSS/JS, icons, vanilla JS) into the compiled binary.
package content
+108 -25
View File
@@ -77,6 +77,15 @@ ul.tree-children {
font-weight: 500;
}
/* Ctrl/Shift-click multi-selection (right-click one of these for bulk
actions). Distinct from .selected, which marks the single item shown in
the main panel - a row can carry both at once. */
.tree-row.multi-selected {
background-color: #fff3cd;
outline: 1px solid #ffe69c;
outline-offset: -1px;
}
.tree-row.drop-target {
outline: 2px dashed #0d6efd;
outline-offset: -2px;
@@ -129,44 +138,118 @@ ul.tree-children {
flex-shrink: 0;
}
.tree-actions {
flex-shrink: 0;
}
.btn-tree-action {
border: none;
background: none;
color: #6c757d;
padding: 0 0.25rem;
}
.btn-tree-action:hover {
color: #212529;
}
.tree-node.dragging {
opacity: 0.4;
}
.stat-card .card-body {
/* Generic right-click context menu (tree + panels feel like a native app:
most actions live here instead of on-screen buttons). */
.ctx-menu {
position: fixed;
z-index: 1080;
min-width: 190px;
display: none;
}
.ctx-menu .dropdown-item i {
width: 1.1rem;
display: inline-block;
text-align: center;
margin-right: 0.15rem;
}
.stat-value {
font-size: 1.4rem;
font-weight: 600;
line-height: 1.2;
}
.stat-label {
font-size: 0.75rem;
color: #6c757d;
[data-context="asset-header"],
[data-context="folder-header"] {
cursor: default;
}
.panel-content {
max-width: 1100px;
}
.panel-blank {
max-width: none;
}
/* Icon picker: a searchable dropdown of common Bootstrap Icons, standing
in for a free-text icon class field. */
.icon-picker {
position: relative;
}
.icon-picker-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
text-align: left;
}
.icon-picker-preview {
font-size: 1.1rem;
width: 1.1rem;
flex-shrink: 0;
}
.icon-picker-label {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-picker-caret {
flex-shrink: 0;
font-size: 0.75rem;
color: #6c757d;
}
.icon-picker-menu {
display: none;
position: absolute;
z-index: 1085;
top: 100%;
left: 0;
margin-top: 0.25rem;
width: 280px;
max-width: 90vw;
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
padding: 0.5rem;
}
.icon-picker-menu.show {
display: block;
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.25rem;
max-height: 190px;
overflow-y: auto;
margin-top: 0.5rem;
}
.icon-picker-item {
border: 1px solid transparent;
background: none;
border-radius: 0.25rem;
padding: 0.4rem;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
color: #212529;
}
.icon-picker-item:hover,
.icon-picker-item:focus {
background-color: #f1f3f5;
border-color: rgba(0, 0, 0, 0.1);
}
.asset-row, .folder-link, .asset-link {
cursor: pointer;
}
+660 -103
View File
@@ -1,5 +1,12 @@
// inventree - vanilla JS driving the inventory tree and AJAX panels.
// vibetree - vanilla JS driving the inventory tree and AJAX panels.
// No build step, no frameworks: plain DOM APIs + fetch.
//
// Interaction model: the tree and panels behave like a native file manager.
// Left click selects/opens. Right click (or the keyboard Menu key, which
// browsers also fire as a "contextmenu" event) opens a context menu with
// the actions available for whatever was clicked - a folder, an asset, or
// empty background (which offers "New Folder" / "Add Asset" for wherever
// you right-clicked). There are intentionally very few standalone buttons.
(function () {
'use strict';
@@ -7,7 +14,6 @@
var mainPanel = document.getElementById('main-panel');
var isAdmin = document.body.classList.contains('role-admin');
var draggedNode = null;
var formReturnContext = { type: 'root', id: '' };
// ---------------------------------------------------------------------
// Generic helpers
@@ -158,7 +164,7 @@
}
// ---------------------------------------------------------------------
// Tree context menu ("...")
// Generic right-click context menu
// ---------------------------------------------------------------------
var ctxMenu = null;
@@ -166,14 +172,8 @@
function ensureCtxMenu() {
if (ctxMenu) return ctxMenu;
ctxMenu = document.createElement('div');
ctxMenu.className = 'dropdown-menu tree-ctx-menu';
ctxMenu.style.position = 'fixed';
ctxMenu.className = 'dropdown-menu ctx-menu';
ctxMenu.style.display = 'none';
ctxMenu.innerHTML =
'<button type="button" class="dropdown-item" data-menu-action="new-subfolder"><i class="bi bi-folder-plus"></i> New Subfolder</button>' +
'<button type="button" class="dropdown-item" data-menu-action="new-asset"><i class="bi bi-plus-lg"></i> Add Asset</button>' +
'<button type="button" class="dropdown-item" data-menu-action="rename"><i class="bi bi-pencil"></i> Rename</button>' +
'<button type="button" class="dropdown-item text-danger" data-menu-action="delete"><i class="bi bi-trash"></i> Delete</button>';
document.body.appendChild(ctxMenu);
return ctxMenu;
}
@@ -182,30 +182,360 @@
if (ctxMenu) ctxMenu.style.display = 'none';
}
function openTreeContextMenu(btn) {
// showContextMenu renders `items` (each {label, icon, danger, action} or
// {divider:true}) as a floating menu at the given viewport coordinates,
// clamped so it never runs off-screen.
function showContextMenu(x, y, items) {
items = items.filter(function (item, i) {
// Collapse a divider that would be first, last, or doubled.
if (!item.divider) return true;
var prevIsDivider = i === 0 || items[i - 1].divider;
var isLast = i === items.length - 1;
return !prevIsDivider && !isLast;
});
if (items.length === 0) return;
var menu = ensureCtxMenu();
var rect = btn.getBoundingClientRect();
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
menu.style.top = rect.bottom + 'px';
menu.innerHTML = items.map(function (item) {
if (item.divider) return '<div class="dropdown-divider"></div>';
var cls = 'dropdown-item' + (item.danger ? ' text-danger' : '');
return '<button type="button" class="' + cls + '"><i class="bi ' + (item.icon || '') + '"></i> ' + escapeHtml(item.label) + '</button>';
}).join('');
var buttons = menu.querySelectorAll('button.dropdown-item');
var actionable = items.filter(function (i) { return !i.divider; });
buttons.forEach(function (btn, i) {
btn.addEventListener('click', function () {
closeCtxMenu();
actionable[i].action();
});
});
menu.style.display = 'block';
menu.dataset.folderId = btn.dataset.id;
menu.style.left = '0px';
menu.style.top = '0px';
var rect = menu.getBoundingClientRect();
var left = Math.min(x, window.innerWidth - rect.width - 8);
var top = Math.min(y, window.innerHeight - rect.height - 8);
menu.style.left = Math.max(4, left) + 'px';
menu.style.top = Math.max(4, top) + 'px';
}
document.addEventListener('click', function (e) {
if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target) && !e.target.closest('.btn-tree-action')) {
closeCtxMenu();
if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target)) closeCtxMenu();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeCtxMenu();
});
window.addEventListener('scroll', closeCtxMenu, true);
window.addEventListener('resize', closeCtxMenu);
// ---- Menu builders per target kind -----------------------------------
function folderMenuItems(id) {
var items = [];
if (isAdmin) {
items.push({ label: 'New Subfolder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: id }); } });
items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: id }); } });
items.push({ divider: true });
items.push({ label: 'Rename', icon: 'bi-pencil', action: function () { openRenameFolder(id); } });
items.push({ divider: true });
items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(id); } });
}
var menuAction = e.target.closest('[data-menu-action]');
if (menuAction && ctxMenu) {
var id = ctxMenu.dataset.folderId;
var action = menuAction.dataset.menuAction;
closeCtxMenu();
if (action === 'new-subfolder') openFolderModal({ parentId: id });
else if (action === 'new-asset') loadAssetForm({ folderId: id });
else if (action === 'rename') openRenameFolder(id);
else if (action === 'delete') startDeleteFolder(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_ids=' + id, '_blank'); } });
if (isAdmin) {
items.push({ divider: true });
items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { startDeleteFolder(id); } });
}
return items;
}
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_ids=' + 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 });
items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { deleteAsset(id); } });
}
return items;
}
// Import is intentionally folder-scoped only (see openImportModal) - it
// never appears at the top level, since a global admin import with
// folder-path resolution lives under Settings > Import / Export instead.
function treeBackgroundMenuItems() {
var items = [];
if (isAdmin) {
items.push({ label: 'New Folder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: null }); } });
items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: null }); } });
items.push({ divider: true });
}
items.push({ label: 'Expand All', icon: 'bi-arrows-expand', action: expandAllHandler });
items.push({ label: 'Collapse All', icon: 'bi-arrows-collapse', action: collapseAllHandler });
items.push({ divider: true });
items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv'; } });
return items;
}
function panelBackgroundMenuItems(folderId) {
var items = [];
if (isAdmin) {
items.push({ label: folderId ? 'New Subfolder' : 'New Folder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: folderId }); } });
items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: folderId }); } });
if (folderId) {
items.push({ divider: true });
items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(folderId); } });
}
}
var exportUrl = folderId ? ('/export/assets.csv?folder_id=' + folderId + '&recursive=1') : '/export/assets.csv';
items.push({ divider: true });
items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = exportUrl; } });
return items;
}
// ---- Multi-selection: Ctrl/Cmd+click toggles, Shift+click selects a
// range, right-clicking a member of an active (2+) selection shows bulk
// actions instead of that one row's normal menu. -----------------------
var multiSelected = new Map(); // "type:id" -> {type, id, row}
var selectionAnchorRow = null;
function selKey(type, id) { return type + ':' + id; }
function clearMultiSelection() {
multiSelected.forEach(function (item) { item.row.classList.remove('multi-selected'); });
multiSelected.clear();
}
function addToMultiSelection(row) {
var key = selKey(row.dataset.type, row.dataset.id);
if (!multiSelected.has(key)) {
multiSelected.set(key, { type: row.dataset.type, id: row.dataset.id, row: row });
row.classList.add('multi-selected');
}
}
function toggleMultiSelection(row) {
var key = selKey(row.dataset.type, row.dataset.id);
if (multiSelected.has(key)) {
multiSelected.delete(key);
row.classList.remove('multi-selected');
} else {
addToMultiSelection(row);
}
}
function visibleSelectableRows() {
return Array.prototype.slice.call(
document.querySelectorAll('#tree-root .tree-row[data-type="folder"], #tree-root .tree-row[data-type="asset"]')
);
}
function selectRange(fromRow, toRow) {
var rows = visibleSelectableRows();
var i1 = rows.indexOf(fromRow), i2 = rows.indexOf(toRow);
if (i1 === -1 || i2 === -1) { addToMultiSelection(toRow); return; }
var start = Math.min(i1, i2), end = Math.max(i1, i2);
for (var i = start; i <= end; i++) addToMultiSelection(rows[i]);
}
function multiSelectionMenuItems() {
var assetIds = [], folderIds = [];
multiSelected.forEach(function (item) {
if (item.type === 'asset') assetIds.push(item.id);
else if (item.type === 'folder') folderIds.push(item.id);
});
var count = multiSelected.size;
var idParams = function () {
var params = [];
if (assetIds.length) params.push('asset_ids=' + assetIds.join(','));
if (folderIds.length) params.push('folder_ids=' + folderIds.join(','));
return params.join('&');
};
var items = [];
items.push({ label: 'Print Labels (' + count + ')', icon: 'bi-qr-code', action: function () { window.open('/labels?' + idParams(), '_blank'); } });
items.push({ label: 'Export CSV (' + count + ')', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?' + idParams(); } });
if (isAdmin && assetIds.length) {
items.push({ divider: true });
items.push({ label: 'Edit Status… (' + assetIds.length + ')', icon: 'bi-pencil-square', action: function () { openBulkStatusModal(assetIds); } });
items.push({ label: 'Check Out… (' + assetIds.length + ')', icon: 'bi-person-check', action: function () { openCheckoutModal(assetIds); } });
items.push({ label: 'Duplicate (' + assetIds.length + ')', icon: 'bi-files', action: function () { duplicateAssets(assetIds); } });
items.push({ label: 'Move to… (' + assetIds.length + ')', icon: 'bi-arrows-move', action: function () { openMoveAssetModal(assetIds); } });
items.push({ divider: true });
items.push({ label: 'Delete (' + assetIds.length + ')', icon: 'bi-trash', danger: true, action: function () { deleteAssets(assetIds); } });
}
return items;
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') clearMultiSelection();
});
// ---- Wiring: tree pane -------------------------------------------------
if (treePane) {
treePane.addEventListener('contextmenu', function (e) {
if (e.target.closest('input, button')) return; // let native menus work on the search box etc.
var row = e.target.closest('.tree-row');
if (!row || row.dataset.type === 'root') {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, treeBackgroundMenuItems());
return;
}
var type = row.dataset.type;
if (type !== 'folder' && type !== 'asset') return; // e.g. the "N more assets" row: no menu
e.preventDefault();
var key = selKey(type, row.dataset.id);
if (multiSelected.size > 1 && multiSelected.has(key)) {
showContextMenu(e.clientX, e.clientY, multiSelectionMenuItems());
return;
}
clearMultiSelection();
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
row.classList.add('selected');
selectionAnchorRow = row;
showContextMenu(e.clientX, e.clientY, type === 'folder' ? folderMenuItems(row.dataset.id) : assetMenuItems(row.dataset.id));
});
}
// ---- Wiring: main panel -------------------------------------------------
if (mainPanel) {
mainPanel.addEventListener('contextmenu', function (e) {
var folderLink = e.target.closest('.folder-link');
if (folderLink) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, folderMenuItems(folderLink.dataset.id)); return; }
var assetRow = e.target.closest('.asset-link, .asset-row');
if (assetRow) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, assetMenuItems(assetRow.dataset.id)); return; }
var assetHeader = e.target.closest('[data-context="asset-header"]');
if (assetHeader) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, assetMenuItems(assetHeader.dataset.id)); return; }
var folderHeader = e.target.closest('[data-context="folder-header"]');
if (folderHeader) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, folderMenuItems(folderHeader.dataset.id)); return; }
var rootCrumb = e.target.closest('.breadcrumb [data-type="root"]');
if (rootCrumb) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, panelBackgroundMenuItems(null)); return; }
var folderCrumb = e.target.closest('.breadcrumb [data-type="folder"]');
if (folderCrumb) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, folderMenuItems(folderCrumb.dataset.id)); return; }
if (e.target.closest('a, button, input, select, textarea, form, .list-group-item')) return;
var panelContent = e.target.closest('.panel-content');
var panelType = panelContent && panelContent.dataset.panelType;
if (panelType === 'root' || panelType === 'folder') {
e.preventDefault();
var folderId = panelType === 'folder' ? panelContent.dataset.folderId : null;
showContextMenu(e.clientX, e.clientY, panelBackgroundMenuItems(folderId));
}
});
}
// ---------------------------------------------------------------------
// Icon picker: a searchable dropdown of common Bootstrap Icons standing
// in for a free-text icon-class input (used by the folder modal and the
// item type form).
// ---------------------------------------------------------------------
var COMMON_ICONS = [
'bi-folder-fill', 'bi-folder', 'bi-archive', 'bi-box-seam', 'bi-boxes',
'bi-laptop', 'bi-pc-display', 'bi-pc', 'bi-display', 'bi-tv', 'bi-projector',
'bi-phone', 'bi-telephone', 'bi-tablet', 'bi-smartwatch',
'bi-camera-video', 'bi-camera', 'bi-printer', 'bi-keyboard', 'bi-mouse',
'bi-hdd', 'bi-hdd-rack', 'bi-hdd-stack', 'bi-server', 'bi-cpu', 'bi-memory',
'bi-disc', 'bi-usb-drive', 'bi-usb-plug', 'bi-ethernet', 'bi-router', 'bi-wifi',
'bi-shield-lock', 'bi-shield-check', 'bi-lock', 'bi-unlock', 'bi-plug',
'bi-battery', 'bi-battery-charging', 'bi-gear', 'bi-gear-fill', 'bi-tools',
'bi-wrench', 'bi-tag', 'bi-tags', 'bi-bookmark', 'bi-house-door-fill',
'bi-building', 'bi-person', 'bi-people',
];
function setIconPickerValue(input, val) {
input.value = val || '';
var container = input.closest('.icon-picker');
if (!container) return;
var preview = container.querySelector('.icon-picker-preview');
var label = container.querySelector('.icon-picker-label');
if (val) {
preview.className = 'bi ' + val + ' icon-picker-preview';
label.textContent = val;
label.classList.remove('text-body-secondary');
} else {
preview.className = 'bi icon-picker-preview';
label.textContent = 'Choose icon…';
label.classList.add('text-body-secondary');
}
}
function closeAllIconPickers() {
document.querySelectorAll('.icon-picker-menu.show').forEach(function (m) { m.classList.remove('show'); });
}
function initIconPickers() {
document.querySelectorAll('[data-icon-picker]').forEach(function (container) {
if (container.dataset.iconPickerInit) return;
container.dataset.iconPickerInit = '1';
var input = container.querySelector('input[type="hidden"]');
var toggle = container.querySelector('.icon-picker-toggle');
var menu = container.querySelector('.icon-picker-menu');
var search = container.querySelector('.icon-picker-search');
var grid = container.querySelector('.icon-picker-grid');
var clearBtn = container.querySelector('.icon-picker-clear');
function renderGrid(filter) {
filter = (filter || '').toLowerCase();
var list = COMMON_ICONS.filter(function (ic) { return !filter || ic.indexOf(filter) !== -1; });
grid.innerHTML = list.map(function (ic) {
return '<button type="button" class="icon-picker-item" data-icon="' + ic + '" title="' + ic + '"><i class="bi ' + ic + '"></i></button>';
}).join('') || '<div class="text-body-secondary small p-2">No matches</div>';
}
toggle.addEventListener('click', function (e) {
e.stopPropagation();
var isOpen = menu.classList.contains('show');
closeAllIconPickers();
if (!isOpen) {
menu.classList.add('show');
search.value = '';
renderGrid('');
search.focus();
}
});
search.addEventListener('input', function () { renderGrid(search.value.trim()); });
grid.addEventListener('click', function (e) {
var btn = e.target.closest('.icon-picker-item');
if (!btn) return;
setIconPickerValue(input, btn.dataset.icon);
menu.classList.remove('show');
});
if (clearBtn) {
clearBtn.addEventListener('click', function () {
setIconPickerValue(input, '');
menu.classList.remove('show');
});
}
renderGrid('');
setIconPickerValue(input, input.value);
});
}
document.addEventListener('click', function (e) {
if (!e.target.closest('.icon-picker')) closeAllIconPickers();
});
initIconPickers();
// ---------------------------------------------------------------------
// Folder create / rename modal
@@ -220,7 +550,7 @@
document.getElementById('folderFormParentId').value = (opts && opts.parentId) || '';
document.getElementById('folderFormName').value = '';
document.getElementById('folderFormDescription').value = '';
document.getElementById('folderFormIcon').value = '';
setIconPickerValue(document.getElementById('folderFormIcon'), '');
document.getElementById('folderFormSortOrder').value = '0';
document.getElementById('folderModalTitle').textContent = 'New Folder';
document.getElementById('folderFormError').classList.add('d-none');
@@ -235,7 +565,7 @@
document.getElementById('folderFormParentId').value = '';
document.getElementById('folderFormName').value = f.Name;
document.getElementById('folderFormDescription').value = f.Description;
document.getElementById('folderFormIcon').value = f.Icon;
setIconPickerValue(document.getElementById('folderFormIcon'), f.Icon);
document.getElementById('folderFormSortOrder').value = f.SortOrder;
document.getElementById('folderModalTitle').textContent = 'Rename Folder';
document.getElementById('folderFormError').classList.add('d-none');
@@ -334,33 +664,219 @@
}
// ---------------------------------------------------------------------
// Asset form (create / edit)
// Move asset modal
// ---------------------------------------------------------------------
var moveAssetModalEl = document.getElementById('moveAssetModal');
var moveAssetModal = (moveAssetModalEl && window.bootstrap) ? new bootstrap.Modal(moveAssetModalEl) : null;
// openMoveAssetModal accepts either a single id or an array of ids, so
// the same modal serves both the single-asset context menu and the
// multi-selection bulk menu.
async function openMoveAssetModal(idOrIds) {
if (!moveAssetModal) return;
var ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
moveAssetModalEl.dataset.assetIds = ids.join(',');
document.getElementById('moveAssetModalTitle').textContent = ids.length > 1 ? 'Move ' + ids.length + ' Assets' : 'Move Asset';
var sel = document.getElementById('moveAssetDestination');
sel.innerHTML = '<option value="">Inventory (top level)</option>';
try {
var opts = await apiFetch('/app/folders/options');
(opts || []).forEach(function (o) {
var indent = '';
for (var i = 0; i < o.depth; i++) indent += '&nbsp;&nbsp;&nbsp;';
var opt = document.createElement('option');
opt.value = o.id;
opt.innerHTML = indent + escapeHtml(o.path);
sel.appendChild(opt);
});
} catch (err) { /* fall back to top-level-only */ }
moveAssetModal.show();
}
var confirmMoveAssetBtn = document.getElementById('confirmMoveAssetBtn');
if (confirmMoveAssetBtn) {
confirmMoveAssetBtn.addEventListener('click', async function () {
var ids = moveAssetModalEl.dataset.assetIds.split(',');
var val = document.getElementById('moveAssetDestination').value;
var folderId = val ? Number(val) : null;
try {
await Promise.all(ids.map(function (id) {
return apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: folderId } });
}));
moveAssetModal.hide();
await refreshWholeTree();
if (ids.length === 1) await navigateAndSelect('asset', ids[0]);
else await reloadCurrentPanel();
clearMultiSelection();
} catch (err) {
alert('Move failed: ' + err.message);
}
});
}
// ---------------------------------------------------------------------
// Check out / check in modal
// ---------------------------------------------------------------------
var checkoutModalEl = document.getElementById('checkoutModal');
var checkoutModal = (checkoutModalEl && window.bootstrap) ? new bootstrap.Modal(checkoutModalEl) : null;
// openCheckoutModal accepts either a single id or an array of ids. For a
// single asset it shows who currently has it; for a batch it skips that
// (no single "current" to show) and just checks all of them out/in
// together.
async function openCheckoutModal(idOrIds) {
if (!checkoutModal) return;
var ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
checkoutModalEl.dataset.assetIds = ids.join(',');
var sel = document.getElementById('checkoutUserSelect');
var currentText = document.getElementById('checkoutCurrent');
sel.innerHTML = '<option value="">Unassigned (check in)</option>';
if (ids.length > 1) {
currentText.textContent = 'Checking out ' + ids.length + ' assets.';
document.getElementById('checkoutModalTitle').textContent = 'Check Out ' + ids.length + ' Assets';
try {
var users = await apiFetch('/app/users/options');
(users || []).forEach(function (u) {
var opt = document.createElement('option');
opt.value = u.id;
opt.textContent = u.username;
sel.appendChild(opt);
});
} catch (err) { /* leave just "Unassigned" */ }
checkoutModal.show();
return;
}
currentText.textContent = 'Loading…';
try {
var asset = await apiFetch('/app/assets/' + ids[0]);
var usersList = await apiFetch('/app/users/options');
(usersList || []).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 ids = checkoutModalEl.dataset.assetIds.split(',');
var val = document.getElementById('checkoutUserSelect').value;
var userId = val ? Number(val) : null;
try {
await Promise.all(ids.map(function (id) {
return apiFetch('/app/assets/' + id + '/checkout', { method: 'POST', json: { assigned_user_id: userId } });
}));
checkoutModal.hide();
if (ids.length === 1) await navigateAndSelect('asset', ids[0]);
else await reloadCurrentPanel();
clearMultiSelection();
} catch (err) {
alert('Checkout failed: ' + err.message);
}
});
}
// ---------------------------------------------------------------------
// Bulk status change modal (multi-selection > Edit Status...)
// ---------------------------------------------------------------------
var bulkStatusModalEl = document.getElementById('bulkStatusModal');
var bulkStatusModal = (bulkStatusModalEl && window.bootstrap) ? new bootstrap.Modal(bulkStatusModalEl) : null;
function openBulkStatusModal(ids) {
if (!bulkStatusModal) return;
bulkStatusModalEl.dataset.assetIds = ids.join(',');
document.getElementById('bulkStatusModalTitle').textContent = 'Edit Status (' + ids.length + ')';
bulkStatusModal.show();
}
var confirmBulkStatusBtn = document.getElementById('confirmBulkStatusBtn');
if (confirmBulkStatusBtn) {
confirmBulkStatusBtn.addEventListener('click', async function () {
var ids = bulkStatusModalEl.dataset.assetIds.split(',');
var statusId = Number(document.getElementById('bulkStatusSelect').value);
try {
await Promise.all(ids.map(function (id) {
return apiFetch('/app/assets/' + id + '/status', { method: 'POST', json: { status_id: statusId } });
}));
bulkStatusModal.hide();
await reloadCurrentPanel();
clearMultiSelection();
} catch (err) {
alert('Status update failed: ' + err.message);
}
});
}
async function duplicateAssets(ids) {
try {
await Promise.all(ids.map(function (id) {
return apiFetch('/app/assets/' + id + '/duplicate', { method: 'POST' });
}));
await refreshWholeTree();
await reloadCurrentPanel();
clearMultiSelection();
} catch (err) {
alert('Duplicate failed: ' + err.message);
}
}
async function deleteAssets(ids) {
if (!confirm('Delete ' + ids.length + ' assets? This cannot be undone.')) return;
try {
await Promise.all(ids.map(function (id) {
return apiFetch('/app/assets/' + id, { method: 'DELETE' });
}));
await refreshWholeTree();
clearMultiSelection();
await navigateAndSelect('root', '');
} catch (err) {
alert('Delete failed: ' + err.message);
}
}
// ---------------------------------------------------------------------
// Asset form (create / edit) - shown in a modal
// ---------------------------------------------------------------------
var assetModalEl = document.getElementById('assetModal');
var assetModal = (assetModalEl && window.bootstrap) ? new bootstrap.Modal(assetModalEl) : null;
function showAssetModal(html) {
if (!assetModal) return;
document.getElementById('assetModalBody').innerHTML = html;
var form = document.getElementById('assetForm');
document.getElementById('assetModalTitle').textContent = form.dataset.title;
document.getElementById('assetModalSubmit').textContent = form.dataset.mode === 'edit' ? 'Save Changes' : 'Create Asset';
assetModal.show();
}
async function loadAssetForm(opts) {
formReturnContext = { type: mainPanel.dataset.contextType || 'root', id: mainPanel.dataset.contextId || '' };
var params = new URLSearchParams();
if (opts && opts.folderId) params.set('folder_id', opts.folderId);
var qs = params.toString();
var html = await fetchText('/app/panel/assets/new' + (qs ? '?' + qs : ''));
mainPanel.innerHTML = html;
mainPanel.dataset.contextType = 'asset-form';
mainPanel.dataset.contextId = '';
showAssetModal(html);
}
async function loadAssetEditForm(id) {
var html = await fetchText('/app/panel/assets/' + id + '/edit');
mainPanel.innerHTML = html;
mainPanel.dataset.contextType = 'asset-form';
mainPanel.dataset.contextId = id;
}
function cancelAssetForm(btn) {
if (btn.dataset.id) {
navigateAndSelect('asset', btn.dataset.id);
} else {
navigateAndSelect(formReturnContext.type, formReturnContext.id);
}
showAssetModal(html);
}
async function submitAssetForm(form) {
@@ -380,6 +896,8 @@
payload.status_id = Number(value);
} else if (key === 'assigned_user_id') {
payload.assigned_user_id = value ? Number(value) : null;
} else if (key === 'quantity') {
payload.quantity = Number(value) || 0;
} else {
payload[key] = value;
}
@@ -389,6 +907,7 @@
var method = mode === 'edit' ? 'PATCH' : 'POST';
try {
var res = await apiFetch(url, { method: method, json: payload });
if (assetModal) assetModal.hide();
await refreshWholeTree();
await navigateAndSelect('asset', res.id);
} catch (err) {
@@ -407,8 +926,8 @@
}
}
async function deleteAsset(id, name) {
if (!confirm('Delete asset "' + name + '"? This cannot be undone.')) return;
async function deleteAsset(id) {
if (!confirm('Delete this asset? This cannot be undone.')) return;
try {
var res = await apiFetch('/app/assets/' + id, { method: 'DELETE' });
await refreshWholeTree();
@@ -419,20 +938,20 @@
}
}
async function confirmMoveAsset(id, selectId) {
var select = document.getElementById(selectId);
var val = select.value;
// 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 {
await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } });
var modalEl = select.closest('.modal');
if (modalEl && window.bootstrap) {
var inst = bootstrap.Modal.getInstance(modalEl);
if (inst) inst.hide();
}
await refreshWholeTree();
await navigateAndSelect('asset', id);
var res = await apiFetch('/app/assets/' + id + '/quantity', { method: 'POST', json: { delta: delta } });
if (valueEl) valueEl.textContent = res.quantity;
} catch (err) {
alert('Move failed: ' + err.message);
alert('Could not update quantity: ' + err.message);
}
}
@@ -493,6 +1012,18 @@
// CSV import
// ---------------------------------------------------------------------
var importModalEl = document.getElementById('importModal');
var importModal = (importModalEl && window.bootstrap) ? new bootstrap.Modal(importModalEl) : null;
function openImportModal(folderId) {
if (!importModal) return;
document.getElementById('importFolderId').value = folderId || '';
document.getElementById('importResult').innerHTML = '';
var fileInput = document.getElementById('importFile');
if (fileInput) fileInput.value = '';
importModal.show();
}
var importFormEl = document.getElementById('importForm');
if (importFormEl) {
importFormEl.addEventListener('submit', async function (e) {
@@ -520,23 +1051,38 @@
}
// ---------------------------------------------------------------------
// Tree pane: click delegation
// Tree pane: click + drag/drop delegation
// ---------------------------------------------------------------------
if (treePane) {
treePane.addEventListener('click', function (e) {
if (e.target.closest('#btn-expand-all')) { expandAllHandler(); return; }
if (e.target.closest('#btn-collapse-all')) { collapseAllHandler(); return; }
if (e.target.closest('#btn-new-root-folder')) { openFolderModal({ parentId: null }); return; }
var toggle = e.target.closest('.tree-toggle');
if (toggle) { e.stopPropagation(); toggleFolder(toggle); return; }
var actionBtn = e.target.closest('.btn-tree-action');
if (actionBtn) { e.stopPropagation(); openTreeContextMenu(actionBtn); return; }
var row = e.target.closest('.tree-row');
if (row && row.dataset.type && !row.classList.contains('tree-row-more')) {
if (row.dataset.type === 'root') {
clearMultiSelection();
selectionAnchorRow = null;
selectNode(row);
return;
}
if (e.shiftKey && selectionAnchorRow) {
e.preventDefault();
selectRange(selectionAnchorRow, row);
return;
}
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleMultiSelection(row);
selectionAnchorRow = row;
return;
}
clearMultiSelection();
selectionAnchorRow = row;
selectNode(row);
}
});
@@ -606,6 +1152,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; }
@@ -617,42 +1166,9 @@
var pageLink = e.target.closest('.panel-page-link');
if (pageLink) { e.preventDefault(); loadFolderPanelWithFilters(pageLink.dataset.folderId, { page: pageLink.dataset.page }); return; }
var newSub = e.target.closest('[data-action="new-subfolder"]');
if (newSub) { openFolderModal({ parentId: newSub.dataset.id }); return; }
var renameBtn = e.target.closest('[data-action="rename-folder"]');
if (renameBtn) { openRenameFolder(renameBtn.dataset.id); return; }
var deleteBtn = e.target.closest('[data-action="delete-folder"]');
if (deleteBtn) { startDeleteFolder(deleteBtn.dataset.id); return; }
var newAssetBtn = e.target.closest('[data-action="new-asset"]');
if (newAssetBtn) { loadAssetForm({ folderId: newAssetBtn.dataset.folderId }); return; }
var editAssetBtn = e.target.closest('[data-action="edit-asset"]');
if (editAssetBtn) { loadAssetEditForm(editAssetBtn.dataset.id); return; }
var dupBtn = e.target.closest('[data-action="duplicate-asset"]');
if (dupBtn) { duplicateAsset(dupBtn.dataset.id); return; }
var delAssetBtn = e.target.closest('[data-action="delete-asset"]');
if (delAssetBtn) { deleteAsset(delAssetBtn.dataset.id, delAssetBtn.dataset.name); return; }
var confirmMoveBtn = e.target.closest('[data-action="confirm-move-asset"]');
if (confirmMoveBtn) { confirmMoveAsset(confirmMoveBtn.dataset.id, confirmMoveBtn.dataset.select); return; }
var cancelFormBtn = e.target.closest('[data-action="cancel-asset-form"]');
if (cancelFormBtn) { cancelAssetForm(cancelFormBtn); return; }
});
mainPanel.addEventListener('change', async function (e) {
if (e.target.id === 'assetFormItemType') {
var html = await fetchText('/app/forms/asset-fields?item_type_id=' + e.target.value);
var container = document.getElementById('asset-fields-container');
if (container) container.innerHTML = html;
return;
}
mainPanel.addEventListener('change', function (e) {
var filterForm = e.target.closest('.panel-filter-form');
if (filterForm) {
loadFolderPanelWithFilters(filterForm.dataset.folderId, { page: 1 });
@@ -672,18 +1188,38 @@
if (e.target.matches && e.target.matches('.panel-filter-form')) {
e.preventDefault();
loadFolderPanelWithFilters(e.target.dataset.folderId, { page: 1 });
return;
}
});
// Draggable rows within the panel (subfolder list / asset tables) drop
// onto the tree only; the tree pane is the primary drag surface.
}
// The asset create/edit form lives inside #assetModal (injected fresh
// each time it's opened), so its dynamic bits are delegated from the
// modal element itself rather than from #main-panel.
if (assetModalEl) {
assetModalEl.addEventListener('change', async function (e) {
if (e.target.id === 'assetFormItemType') {
var html = await fetchText('/app/forms/asset-fields?item_type_id=' + e.target.value);
var container = document.getElementById('asset-fields-container');
if (container) container.innerHTML = html;
}
});
assetModalEl.addEventListener('submit', function (e) {
if (e.target.id === 'assetForm') {
e.preventDefault();
submitAssetForm(e.target);
}
});
// Assets/folders are also draggable as drop-into-root targets from the
// panel view when browsing (drag onto the tree's root row still works
// via the tree pane handler above); dragstart on panel rows is not
// implemented since the tree is the primary drag surface.
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);
});
}
// ---------------------------------------------------------------------
@@ -719,4 +1255,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');
}
}
})();
+8 -9
View File
@@ -3,27 +3,22 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>inventree</title>
<title>{{siteName}}</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="role-{{.User.Role}}">
<nav class="navbar navbar-expand-lg app-navbar border-bottom px-3">
<a class="navbar-brand fw-semibold d-flex align-items-center gap-2" href="/inventory">
<i class="bi bi-diagram-3-fill"></i> inventree
</a>
<span class="navbar-brand fw-semibold d-flex align-items-center gap-2 mb-0">
<i class="bi bi-diagram-3-fill"></i> {{siteName}}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navMain">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="/inventory">Inventory</a></li>
{{if .User.IsAdmin}}
<li class="nav-item"><a class="nav-link" href="/item-types">Item Types</a></li>
<li class="nav-item"><a class="nav-link" href="/users">Users</a></li>
<li class="nav-item"><a class="nav-link" href="/settings">Settings</a></li>
{{end}}
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
@@ -32,6 +27,10 @@
{{if .User.IsAdmin}}<span class="badge text-bg-secondary ms-1">admin</span>{{end}}
</a>
<ul class="dropdown-menu dropdown-menu-end">
{{if .User.IsAdmin}}
<li><a class="dropdown-item" href="/settings"><i class="bi bi-gear me-2"></i>Settings</a></li>
<li><hr class="dropdown-divider"></li>
{{end}}
<li>
<form action="/logout" method="post" class="px-3 py-1">
<button type="submit" class="btn btn-sm btn-outline-secondary w-100">Sign out</button>
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · inventree</title>
<title>Sign in · {{siteName}}</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
<link rel="stylesheet" href="/static/css/app.css">
@@ -14,7 +14,7 @@
<div class="col-11 col-sm-8 col-md-5 col-lg-4">
<div class="text-center mb-4">
<div class="display-6"><i class="bi bi-diagram-3-fill"></i></div>
<h1 class="h4 mt-2 mb-0">inventree</h1>
<h1 class="h4 mt-2 mb-0">{{siteName}}</h1>
<div class="text-body-secondary small">IT asset inventory</div>
</div>
<div class="card shadow-sm">
+6 -12
View File
@@ -1,20 +1,14 @@
{{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">
<div class="p-2 border-bottom">
<div class="input-group input-group-sm mb-2">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" id="tree-search" class="form-control" placeholder="Search inventory&hellip;">
<button class="btn btn-outline-secondary" id="btn-expand-all" title="Expand all folders"><i class="bi bi-arrows-expand"></i></button>
<button class="btn btn-outline-secondary" id="btn-collapse-all" title="Collapse all folders"><i class="bi bi-arrows-collapse"></i></button>
</div>
<div class="d-flex gap-1 flex-wrap">
<button class="btn btn-sm btn-outline-secondary" id="btn-expand-all" title="Expand all folders"><i class="bi bi-arrows-expand"></i> Expand</button>
<button class="btn btn-sm btn-outline-secondary" id="btn-collapse-all" title="Collapse all folders"><i class="bi bi-arrows-collapse"></i> Collapse</button>
{{if .User.IsAdmin}}
<button class="btn btn-sm btn-primary ms-auto" id="btn-new-root-folder" data-parent-id="" title="New top-level folder">
<i class="bi bi-folder-plus"></i>
</button>
{{end}}
</div>
<div class="form-text">Right-click the tree to add, rename, or delete.</div>
</div>
<div class="tree-scroll flex-fill overflow-auto">
<ul class="tree list-unstyled mb-0" id="tree-root">
@@ -35,7 +29,7 @@
</aside>
<section id="main-panel" class="main-panel flex-fill overflow-auto" data-context-type="root" data-context-id="">
{{template "panel_root" .Root}}
{{template "panel_root" .}}
</section>
</div>
+42 -10
View File
@@ -1,23 +1,41 @@
{{define "content"}}
<div class="container-fluid p-3" style="max-width: 900px">
<div class="container-fluid p-3">
<h4 class="mb-3">Settings</h4>
<div class="row g-3">
<div class="col-md-3 col-lg-2">
{{template "settings_nav" "itemtypes"}}
</div>
<div class="col-md-9 col-lg-10" style="max-width: 900px">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0">{{if .ItemType}}Edit Item Type{{else}}New Item Type{{end}}</h4>
<a href="/item-types" class="btn btn-sm btn-outline-secondary ms-auto">Back to Item Types</a>
<h5 class="mb-0">{{if .ItemType}}Edit Item Type{{else}}New Item Type{{end}}</h5>
<a href="/settings/item-types" class="btn btn-sm btn-outline-secondary ms-auto">Back to Item Types</a>
</div>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card mb-4">
<div class="card-body">
<form method="post" action="{{if .ItemType}}/item-types/{{.ItemType.ID}}{{else}}/item-types{{end}}">
<form method="post" action="{{if .ItemType}}/settings/item-types/{{.ItemType.ID}}{{else}}/settings/item-types{{end}}">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" value="{{if .ItemType}}{{.ItemType.Name}}{{end}}" required>
</div>
<div class="col-md-6">
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class)</span></label>
<input type="text" class="form-control" name="icon" value="{{if .ItemType}}{{.ItemType.Icon}}{{end}}" placeholder="bi-box-seam">
<label class="form-label">Icon</label>
<div class="icon-picker" data-icon-picker>
<input type="hidden" name="icon" value="{{if .ItemType}}{{.ItemType.Icon}}{{end}}">
<button type="button" class="btn btn-outline-secondary icon-picker-toggle w-100">
<i class="bi icon-picker-preview"></i>
<span class="icon-picker-label text-body-secondary">Choose icon&hellip;</span>
<i class="bi bi-chevron-down icon-picker-caret"></i>
</button>
<div class="icon-picker-menu">
<input type="search" class="form-control form-control-sm icon-picker-search" placeholder="Search icons&hellip;">
<div class="icon-picker-grid"></div>
<button type="button" class="btn btn-sm btn-link p-0 mt-2 icon-picker-clear">Clear</button>
</div>
</div>
</div>
<div class="col-12">
<label class="form-label">Description</label>
@@ -33,10 +51,22 @@
<label class="form-check-label" for="enabled">Enabled</label>
</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 &mdash; 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>
<button type="submit" class="btn btn-primary mt-3">Save</button>
{{if .ItemType}}
<button type="submit" formaction="/item-types/{{.ItemType.ID}}/delete" class="btn btn-outline-danger mt-3" onclick="return confirm('Delete this item type? This is only possible if no assets use it.')">Delete Item Type</button>
<button type="submit" formaction="/settings/item-types/{{.ItemType.ID}}/delete" class="btn btn-outline-danger mt-3" onclick="return confirm('Delete this item type? This is only possible if no assets use it.')">Delete Item Type</button>
{{end}}
</form>
</div>
@@ -65,7 +95,7 @@
data-options="{{range $i, $o := .Options}}{{if $i}}&#10;{{end}}{{$o}}{{end}}" data-sort-order="{{.SortOrder}}">
Edit
</button>
<form method="post" action="/item-types/{{$.ItemType.ID}}/fields/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this field? Existing values will be lost.')">
<form method="post" action="/settings/item-types/{{$.ItemType.ID}}/fields/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this field? Existing values will be lost.')">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
@@ -79,7 +109,7 @@
</div>
<div class="card-body border-top">
<h6 id="fieldFormTitle">Add Field</h6>
<form method="post" action="/item-types/{{.ItemType.ID}}/fields" id="fieldForm" class="row g-2 align-items-end">
<form method="post" action="/settings/item-types/{{.ItemType.ID}}/fields" id="fieldForm" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1">Name</label>
<input type="text" class="form-control form-control-sm" name="name" id="fieldFormName" required>
@@ -112,9 +142,11 @@
<button type="button" class="btn btn-sm btn-link d-none" id="fieldFormCancel">Cancel edit</button>
</div>
</div>
<script>window.itemTypeFieldsBaseAction = "/item-types/{{.ItemType.ID}}/fields";</script>
<script>window.itemTypeFieldsBaseAction = "/settings/item-types/{{.ItemType.ID}}/fields";</script>
{{else}}
<p class="text-body-secondary">Save the item type first to add custom fields.</p>
{{end}}
</div>
</div>
</div>
{{end}}
+12 -4
View File
@@ -1,8 +1,14 @@
{{define "content"}}
<div class="container-fluid p-3">
<h4 class="mb-3">Settings</h4>
<div class="row g-3">
<div class="col-md-3 col-lg-2">
{{template "settings_nav" "itemtypes"}}
</div>
<div class="col-md-9 col-lg-10">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0">Item Types</h4>
<a href="/item-types/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-plus-lg"></i> New Item Type</a>
<h5 class="mb-0">Item Types</h5>
<a href="/settings/item-types/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-plus-lg"></i> New Item Type</a>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
@@ -11,11 +17,11 @@
{{range .ItemTypes}}
<tr>
<td><i class="bi {{iconOr .Icon "bi-box-seam"}} fs-5"></i></td>
<td><a href="/item-types/{{.ID}}/edit">{{.Name}}</a></td>
<td><a href="/settings/item-types/{{.ID}}/edit">{{.Name}}</a></td>
<td class="text-body-secondary">{{.Description}}</td>
<td>{{len .Fields}}</td>
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
<td class="text-end"><a href="/item-types/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
<td class="text-end"><a href="/settings/item-types/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
</tr>
{{else}}
<tr><td colspan="6" class="text-body-secondary">No item types defined yet.</td></tr>
@@ -24,4 +30,6 @@
</table>
</div>
</div>
</div>
</div>
{{end}}
+66
View File
@@ -0,0 +1,66 @@
{{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; max-width: 100%; }
.label-name { font-size: 8pt; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.label.format-barcode .label-text { text-align: center; }
</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 flex-wrap align-items-center gap-2 ms-auto">
{{if .AssetIDs}}<input type="hidden" name="asset_ids" value="{{.AssetIDs}}">{{end}}
{{if .FolderIDs}}<input type="hidden" name="folder_ids" value="{{.FolderIDs}}">{{end}}
<label class="form-label mb-0 small text-body-secondary" for="labelFormatSelect">Code</label>
<select class="form-select form-select-sm" id="labelFormatSelect" name="format" onchange="this.form.submit()">
<option value="qr" {{if eq .Format "qr"}}selected{{end}}>QR code (opens on scan)</option>
<option value="barcode" {{if eq .Format "barcode"}}selected{{end}}>Barcode (Code128)</option>
</select>
<label class="form-label mb-0 small text-body-secondary" for="labelTemplateSelect">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>
<label class="form-label mb-0 small text-body-secondary" for="labelStartInput">Start at #</label>
<input type="number" class="form-control form-control-sm" id="labelStartInput" name="start" value="{{.StartPos}}" min="1" style="width: 70px" onchange="this.form.submit()">
</form>
</div>
<p class="text-body-secondary small px-3 pt-2 mb-0">
Already used some labels off this sheet? Set "Start at #" to the next free position (counting left-to-right,
top-to-bottom) instead of wasting them and starting a new sheet.
</p>
{{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 .BlankCells}}<div class="label-blank"></div>{{end}}
{{range .Labels}}
<div class="label format-{{$.Format}}">
<img src="{{.ImageData}}" alt="{{if eq $.Format "barcode"}}Barcode{{else}}QR code{{end}} for {{.Asset.AssetTag}}">
<div class="min-w-0 label-text">
<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}}
+7 -57
View File
@@ -1,8 +1,13 @@
{{define "content"}}
<div class="container-fluid p-3" style="max-width: 800px">
<div class="container-fluid p-3">
<h4 class="mb-3">Settings</h4>
<div class="row g-3">
<div class="col-md-3 col-lg-2">
{{template "settings_nav" "general"}}
</div>
<div class="col-md-9 col-lg-10" style="max-width: 800px">
<div class="card mb-4">
<div class="card">
<div class="card-header py-2 fw-semibold">General</div>
<div class="card-body">
<form method="post" action="/settings/general" class="row g-3">
@@ -25,61 +30,6 @@
</div>
</div>
<div class="card">
<div class="card-header py-2 fw-semibold">Statuses</div>
<div class="table-responsive">
<table class="table mb-0 align-middle">
<thead><tr><th>Order</th><th>Preview</th><th>Name &amp; Color</th><th></th></tr></thead>
<tbody>
{{range $i, $st := .Statuses}}
<tr>
<td class="text-nowrap">
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
<input type="hidden" name="dir" value="up">
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq $i 0}}disabled{{end}}><i class="bi bi-arrow-up"></i></button>
</form>
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
<input type="hidden" name="dir" value="down">
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq (add $i 1) (len $.Statuses)}}disabled{{end}}><i class="bi bi-arrow-down"></i></button>
</form>
</td>
<td><span class="badge text-bg-{{$st.Color}}">{{$st.Name}}</span></td>
<td>
<form method="post" action="/settings/statuses/{{$st.ID}}" class="d-flex gap-2 align-items-center">
<input type="text" class="form-control form-control-sm" name="name" value="{{$st.Name}}" style="max-width:160px" required>
<select class="form-select form-select-sm" name="color" style="max-width:130px">
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}
<option value="{{.}}" {{if eq . $st.Color}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<button type="submit" class="btn btn-sm btn-outline-secondary">Save</button>
</form>
</td>
<td>
<form method="post" action="/settings/statuses/{{$st.ID}}/delete" onsubmit="return confirm('Delete this status?')">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="card-body border-top">
<h6>Add Status</h6>
<form method="post" action="/settings/statuses" class="d-flex gap-2 align-items-end">
<div>
<label class="form-label small mb-1">Name</label>
<input type="text" class="form-control form-control-sm" name="name" required>
</div>
<div>
<label class="form-label small mb-1">Color</label>
<select class="form-select form-select-sm" name="color">
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
</div>
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</form>
</div>
</div>
</div>
+96
View File
@@ -0,0 +1,96 @@
{{define "content"}}
<div class="container-fluid p-3">
<h4 class="mb-3">Settings</h4>
<div class="row g-3">
<div class="col-md-3 col-lg-2">
{{template "settings_nav" "import"}}
</div>
<div class="col-md-9 col-lg-10" style="max-width: 900px">
{{if .Error}}
<div class="alert alert-danger">{{.Error}}</div>
{{else if eq .Kind "assets"}}
<div class="alert alert-success">Imported {{.Imported}} asset(s), updated {{.Updated}}.{{if .Errs}} {{.Errs}} row(s) had warnings.{{end}}</div>
{{else if eq .Kind "statuses"}}
<div class="alert alert-success">Imported {{.Imported}} status(es), updated {{.Updated}}.{{if .Errs}} {{.Errs}} row(s) had warnings.{{end}}</div>
{{end}}
<p class="text-body-secondary">
This page is for bulk, admin-level imports that can reference any folder path (and create missing
folders along the way). To import assets straight into one specific folder without a Folder column,
right-click that folder in the Inventory tree and choose <strong>Import CSV&hellip;</strong> instead.
</p>
<div class="card mb-4">
<div class="card-header py-2 fw-semibold">Import Assets</div>
<div class="card-body">
<form method="post" action="/settings/import/assets" enctype="multipart/form-data" class="mb-3">
<div class="mb-3">
<label class="form-label">CSV File</label>
<input type="file" class="form-control" name="file" accept=".csv" required>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="create_missing_folders" id="createMissingFolders" value="1" checked>
<label class="form-check-label" for="createMissingFolders">Create missing folders referenced in the Folder column</label>
</div>
<button type="submit" class="btn btn-primary">Import Assets</button>
</form>
<p class="mb-1 fw-semibold small">Format</p>
<p class="text-body-secondary small mb-2">
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>Folder</code> (a <code>/</code>-separated path such as <code>Main Office/Network</code>),
<code>Assigned User</code>, <code>Notes</code>, <code>Quantity</code> (defaults to 1; only
meaningful for item types that aren't marked Unique), and one <code>cf_&lt;field_key&gt;</code>
column per custom field (e.g. <code>cf_serial_number</code>).
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0 small">
<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>Quantity</th><th>cf_serial_number</th></tr>
</thead>
<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></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><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>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header py-2 fw-semibold">Import Statuses</div>
<div class="card-body">
<form method="post" action="/settings/import/statuses" enctype="multipart/form-data" class="mb-3">
<div class="mb-3">
<label class="form-label">CSV File</label>
<input type="file" class="form-control" name="file" accept=".csv" required>
</div>
<button type="submit" class="btn btn-primary">Import Statuses</button>
</form>
<p class="mb-1 fw-semibold small">Format</p>
<p class="text-body-secondary small mb-2">
Required: <code>Name</code>. Optional: <code>Color</code> (any Bootstrap color name -
<code>primary</code>, <code>secondary</code>, <code>success</code>, <code>danger</code>,
<code>warning</code>, <code>info</code>, or <code>dark</code>; defaults to <code>secondary</code>).
A row whose name matches an existing status updates its color instead of creating a duplicate.
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0 small">
<thead><tr><th>Name</th><th>Color</th></tr></thead>
<tbody>
<tr><td>In Use</td><td>success</td></tr>
<tr><td>Awaiting Parts</td><td>warning</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
@@ -0,0 +1,71 @@
{{define "content"}}
<div class="container-fluid p-3">
<h4 class="mb-3">Settings</h4>
<div class="row g-3">
<div class="col-md-3 col-lg-2">
{{template "settings_nav" "statuses"}}
</div>
<div class="col-md-9 col-lg-10" style="max-width: 800px">
<div class="card">
<div class="card-header py-2 fw-semibold">Statuses</div>
<div class="table-responsive">
<table class="table mb-0 align-middle">
<thead><tr><th>Order</th><th>Preview</th><th>Name &amp; Color</th><th></th></tr></thead>
<tbody>
{{range $i, $st := .Statuses}}
<tr>
<td class="text-nowrap">
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
<input type="hidden" name="dir" value="up">
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq $i 0}}disabled{{end}}><i class="bi bi-arrow-up"></i></button>
</form>
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
<input type="hidden" name="dir" value="down">
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq (add $i 1) (len $.Statuses)}}disabled{{end}}><i class="bi bi-arrow-down"></i></button>
</form>
</td>
<td><span class="badge text-bg-{{$st.Color}}">{{$st.Name}}</span></td>
<td>
<form method="post" action="/settings/statuses/{{$st.ID}}" class="d-flex gap-2 align-items-center">
<input type="text" class="form-control form-control-sm" name="name" value="{{$st.Name}}" style="max-width:160px" required>
<select class="form-select form-select-sm" name="color" style="max-width:130px">
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}
<option value="{{.}}" {{if eq . $st.Color}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<button type="submit" class="btn btn-sm btn-outline-secondary">Save</button>
</form>
</td>
<td>
<form method="post" action="/settings/statuses/{{$st.ID}}/delete" onsubmit="return confirm('Delete this status?')">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="card-body border-top">
<h6>Add Status</h6>
<form method="post" action="/settings/statuses" class="d-flex gap-2 align-items-end">
<div>
<label class="form-label small mb-1">Name</label>
<input type="text" class="form-control form-control-sm" name="name" required>
</div>
<div>
<label class="form-label small mb-1">Color</label>
<select class="form-select form-select-sm" name="color">
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
</div>
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
+18 -8
View File
@@ -1,15 +1,21 @@
{{define "content"}}
<div class="container-fluid p-3" style="max-width: 600px">
<div class="container-fluid p-3">
<h4 class="mb-3">Settings</h4>
<div class="row g-3">
<div class="col-md-3 col-lg-2">
{{template "settings_nav" "users"}}
</div>
<div class="col-md-9 col-lg-10" style="max-width: 600px">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0">{{if .EditUser}}Edit User{{else}}New User{{end}}</h4>
<a href="/users" class="btn btn-sm btn-outline-secondary ms-auto">Back to Users</a>
<h5 class="mb-0">{{if .EditUser}}Edit User{{else}}New User{{end}}</h5>
<a href="/settings/users" class="btn btn-sm btn-outline-secondary ms-auto">Back to Users</a>
</div>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card mb-3">
<div class="card-body">
<form method="post" action="{{if .EditUser}}/users/{{.EditUser.ID}}{{else}}/users{{end}}">
<form method="post" action="{{if .EditUser}}/settings/users/{{.EditUser.ID}}{{else}}/settings/users{{end}}">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" name="username" value="{{if .EditUser}}{{.EditUser.Username}}{{end}}" required autofocus>
@@ -17,7 +23,8 @@
{{if not .EditUser}}
<div class="mb-3">
<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>
{{end}}
<div class="mb-3">
@@ -25,6 +32,7 @@
<select class="form-select" name="role">
<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="contact" {{if .EditUser}}{{if eq .EditUser.Role "contact"}}selected{{end}}{{end}}>Contact (no login - assignable only)</option>
</select>
</div>
<div class="form-check mb-3">
@@ -34,18 +42,18 @@
<button type="submit" class="btn btn-primary">Save</button>
</form>
{{if .EditUser}}
<form method="post" action="/users/{{.EditUser.ID}}/delete" class="d-inline mt-2" onsubmit="return confirm('Delete this user?')">
<form method="post" action="/settings/users/{{.EditUser.ID}}/delete" class="d-inline mt-2" onsubmit="return confirm('Delete this user?')">
<button type="submit" class="btn btn-outline-danger">Delete User</button>
</form>
{{end}}
</div>
</div>
{{if .EditUser}}
{{if and .EditUser .EditUser.CanLogin}}
<div class="card">
<div class="card-header py-2 fw-semibold">Change Password</div>
<div class="card-body">
<form method="post" action="/users/{{.EditUser.ID}}/password" class="row g-2 align-items-end">
<form method="post" action="/settings/users/{{.EditUser.ID}}/password" class="row g-2 align-items-end">
<div class="col">
<label class="form-label">New Password</label>
<input type="password" class="form-control" name="password" autocomplete="new-password">
@@ -58,4 +66,6 @@
</div>
{{end}}
</div>
</div>
</div>
{{end}}
+17 -5
View File
@@ -1,8 +1,14 @@
{{define "content"}}
<div class="container-fluid p-3">
<h4 class="mb-3">Settings</h4>
<div class="row g-3">
<div class="col-md-3 col-lg-2">
{{template "settings_nav" "users"}}
</div>
<div class="col-md-9 col-lg-10">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0">Users</h4>
<a href="/users/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-person-plus"></i> New User</a>
<h5 class="mb-0">Users</h5>
<a href="/settings/users/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-person-plus"></i> New User</a>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
@@ -10,15 +16,21 @@
<tbody>
{{range .Users}}
<tr>
<td><a href="/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><a href="/settings/users/{{.ID}}/edit">{{.Username}}</a></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>{{fmtDateShort .CreatedAt}}</td>
<td class="text-end"><a href="/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>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
{{end}}
+15 -2
View File
@@ -1,7 +1,20 @@
{{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">&minus;</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>
{{range .}}
{{range .Fields}}
{{$fv := .}}
<div class="mb-2">
<label class="form-label" for="field-{{.Def.ID}}">{{.Def.Name}}{{if .Def.Required}} <span class="text-danger">*</span>{{end}}</label>
+4 -16
View File
@@ -1,13 +1,6 @@
{{define "asset_form"}}
<div class="panel-content p-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-2">
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
<li class="breadcrumb-item active">{{if .Asset}}Edit {{.Asset.Name}}{{else}}New Asset{{end}}</li>
</ol>
</nav>
<form id="assetForm" data-mode="{{if .Asset}}edit{{else}}create{{end}}" {{if .Asset}}data-asset-id="{{.Asset.ID}}"{{end}}>
<form id="assetForm" data-mode="{{if .Asset}}edit{{else}}create{{end}}" {{if .Asset}}data-asset-id="{{.Asset.ID}}"{{end}}
data-title="{{if .Asset}}Edit {{.Asset.Name}}{{else}}New Asset{{end}}">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Asset ID</label>
@@ -56,7 +49,7 @@
<select class="form-select" name="assigned_user_id">
<option value="">Unassigned</option>
{{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}}
</select>
</div>
@@ -67,15 +60,10 @@
</div>
<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 class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">{{if .Asset}}Save Changes{{else}}Create Asset{{end}}</button>
<button type="button" class="btn btn-outline-secondary" data-action="cancel-asset-form" {{if .Asset}}data-id="{{.Asset.ID}}"{{end}}>Cancel</button>
</div>
<div class="alert alert-danger mt-3 d-none" id="assetFormError"></div>
</form>
</div>
{{end}}
+1 -1
View File
@@ -5,7 +5,7 @@
{{$last := sub (len .) 1}}
{{range $i, $b := .}}
{{if eq $i $last}}
<li class="breadcrumb-item active" aria-current="page">{{$b.Name}}</li>
<li class="breadcrumb-item active" aria-current="page" data-context="folder-header" data-id="{{$b.ID}}">{{$b.Name}}</li>
{{else}}
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{$b.ID}}">{{$b.Name}}</a></li>
{{end}}
+104 -9
View File
@@ -22,8 +22,20 @@
</div>
<div class="row g-2">
<div class="col">
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class, optional)</span></label>
<input type="text" class="form-control" name="icon" id="folderFormIcon" placeholder="bi-folder-fill">
<label class="form-label">Icon <span class="text-body-secondary">(optional)</span></label>
<div class="icon-picker" data-icon-picker>
<input type="hidden" name="icon" id="folderFormIcon" value="">
<button type="button" class="btn btn-outline-secondary icon-picker-toggle w-100">
<i class="bi icon-picker-preview"></i>
<span class="icon-picker-label text-body-secondary">Choose icon&hellip;</span>
<i class="bi bi-chevron-down icon-picker-caret"></i>
</button>
<div class="icon-picker-menu">
<input type="search" class="form-control form-control-sm icon-picker-search" placeholder="Search icons&hellip;">
<div class="icon-picker-grid"></div>
<button type="button" class="btn btn-sm btn-link p-0 mt-2 icon-picker-clear">Clear</button>
</div>
</div>
</div>
<div class="col-4">
<label class="form-label">Sort Order</label>
@@ -77,26 +89,109 @@
</div>
</div>
<!-- Import CSV -->
<!-- Add / edit asset -->
<div class="modal fade" id="assetModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="assetModalTitle">New Asset</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="assetModalBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="assetForm" class="btn btn-primary" id="assetModalSubmit">Create Asset</button>
</div>
</div>
</div>
</div>
<!-- Move asset -->
<div class="modal fade" id="moveAssetModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="moveAssetModalTitle">Move Asset</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label">Destination Folder</label>
<select class="form-select" id="moveAssetDestination">
<option value="">Inventory (top level)</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="confirmMoveAssetBtn">Move</button>
</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>
<!-- Bulk status change (multi-select > Edit Status...) -->
<div class="modal fade" id="bulkStatusModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bulkStatusModalTitle">Edit Status</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label">New Status</label>
<select class="form-select" id="bulkStatusSelect">
{{range .Statuses}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</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="confirmBulkStatusBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Import CSV into a specific folder (right-click a folder > Import CSV...) -->
<div class="modal fade" id="importModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="importForm">
<div class="modal-header">
<h5 class="modal-title">Import Assets from CSV</h5>
<h5 class="modal-title" id="importModalTitle">Import CSV</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="folder_id" id="importFolderId">
<div class="mb-3">
<label class="form-label">CSV File</label>
<input type="file" class="form-control" name="file" id="importFile" accept=".csv" required>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="create_missing_folders" id="importCreateFolders" value="1" checked>
<label class="form-check-label" for="importCreateFolders">Create missing folders referenced in the Folder column</label>
</div>
<p class="text-body-secondary small mb-0">
Columns: Asset ID, Name, Item Type, Folder, Status, Assigned User, Notes, plus <code>cf_&lt;field_key&gt;</code> for custom fields.
Every row imports into this folder. Columns: <code>Name</code>, <code>Item Type</code>,
<code>Status</code>. Optional: <code>Asset ID</code>, <code>Assigned User</code>, <code>Notes</code>,
plus <code>cf_&lt;field_key&gt;</code> for custom fields. No Folder column needed.
</p>
<div id="importResult" class="mt-3"></div>
</div>
+14 -38
View File
@@ -1,6 +1,5 @@
{{define "panel_asset"}}
<div class="panel-content p-3" data-asset-id="{{.Asset.ID}}">
<div class="d-flex align-items-start mb-2">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
@@ -10,17 +9,8 @@
<li class="breadcrumb-item active" aria-current="page">{{.Asset.Name}}</li>
</ol>
</nav>
{{if .CanEdit}}
<div class="ms-auto d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" data-action="edit-asset" data-id="{{.Asset.ID}}"><i class="bi bi-pencil"></i> Edit</button>
<button class="btn btn-sm btn-outline-secondary" data-action="move-asset" data-bs-toggle="modal" data-bs-target="#moveAssetModal-{{.Asset.ID}}"><i class="bi bi-arrows-move"></i> Move</button>
<button class="btn btn-sm btn-outline-secondary" data-action="duplicate-asset" data-id="{{.Asset.ID}}"><i class="bi bi-files"></i> Duplicate</button>
<button class="btn btn-sm btn-outline-danger" data-action="delete-asset" data-id="{{.Asset.ID}}" data-name="{{.Asset.Name}}"><i class="bi bi-trash"></i> Delete</button>
</div>
{{end}}
</div>
<div class="d-flex align-items-center gap-2 mb-3">
<div class="d-flex align-items-center gap-2 mb-3 mt-2" data-context="asset-header" data-id="{{.Asset.ID}}" title="Right-click for actions">
<i class="bi {{iconOr .Asset.ItemTypeIcon "bi-box-seam"}} fs-3 text-body-secondary"></i>
<div>
<h4 class="mb-0">{{.Asset.Name}}</h4>
@@ -35,7 +25,19 @@
<div class="card-header py-2 fw-semibold">Details</div>
<table class="table table-sm mb-0">
<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 &mdash; 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">&minus;</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>Updated</th><td>{{fmtDate .Asset.UpdatedAt}}</td></tr>
</tbody>
@@ -75,30 +77,4 @@
</div>
</div>
</div>
{{if .CanEdit}}
<div class="modal fade" id="moveAssetModal-{{.Asset.ID}}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Move "{{.Asset.Name}}"</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label">Destination Folder</label>
<select class="form-select" id="moveAssetDestination-{{.Asset.ID}}">
<option value="">Inventory (top level)</option>
{{range .FolderOptions}}
<option value="{{.ID}}">{{range seq .Depth}}&nbsp;&nbsp;&nbsp;{{end}}{{.Path}}</option>
{{end}}
</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" data-action="confirm-move-asset" data-id="{{.Asset.ID}}" data-select="moveAssetDestination-{{.Asset.ID}}">Move</button>
</div>
</div>
</div>
</div>
{{end}}
{{end}}
+16 -15
View File
@@ -1,15 +1,10 @@
{{define "panel_folder"}}
<div class="panel-content p-3" data-folder-id="{{.Folder.ID}}">
<div class="d-flex align-items-start mb-2">
<div class="panel-content p-3" data-panel-type="folder" data-folder-id="{{.Folder.ID}}">
{{template "breadcrumb" .Breadcrumb}}
<div class="ms-auto d-flex gap-2 admin-only">
<button class="btn btn-sm btn-outline-secondary" data-action="new-subfolder" data-id="{{.Folder.ID}}"><i class="bi bi-folder-plus"></i> Subfolder</button>
<button class="btn btn-sm btn-outline-secondary" data-action="rename-folder" data-id="{{.Folder.ID}}" data-name="{{.Folder.Name}}" data-description="{{.Folder.Description}}" data-icon="{{.Folder.Icon}}" data-sort-order="{{.Folder.SortOrder}}"><i class="bi bi-pencil"></i> Rename</button>
<button class="btn btn-sm btn-outline-danger" data-action="delete-folder" data-id="{{.Folder.ID}}"><i class="bi bi-trash"></i> Delete</button>
</div>
</div>
<h4 class="mb-1"><i class="bi {{iconOr .Folder.Icon "bi-folder-fill"}} text-warning-emphasis me-1"></i>{{.Folder.Name}}</h4>
<h4 class="mb-1" data-context="folder-header" data-id="{{.Folder.ID}}">
<i class="bi {{iconOr .Folder.Icon "bi-folder-fill"}} text-warning-emphasis me-1"></i>{{.Folder.Name}}
</h4>
{{if .Folder.Description}}<p class="text-body-secondary">{{.Folder.Description}}</p>{{end}}
{{if .Subfolders}}
@@ -38,22 +33,28 @@
</select>
<input type="search" class="form-control form-control-sm" name="q" value="{{.Query}}" placeholder="Filter assets&hellip;" style="width:160px">
</form>
<div class="ms-auto d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="/export/assets.csv?folder_id={{.Folder.ID}}&recursive=1"><i class="bi bi-download"></i> Export</a>
<button class="btn btn-sm btn-primary admin-only" data-action="new-asset" data-folder-id="{{.Folder.ID}}"><i class="bi bi-plus-lg"></i> Add Asset</button>
</div>
</div>
{{if .Assets}}
<div class="table-responsive">
<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>
{{range .Assets}}
<tr class="asset-row" data-id="{{.ID}}">
<td>{{.AssetTag}}</td>
<td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td>
<td>{{.ItemTypeName}}</td>
<td>
{{if .ItemTypeIsUnique}}<span class="text-body-secondary">&mdash;</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">&minus;</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>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">&mdash;</span>{{end}}</td>
</tr>
@@ -74,7 +75,7 @@
</nav>
{{end}}
{{else}}
<p class="text-body-secondary">No assets in this folder yet.</p>
<p class="text-body-secondary">No assets in this folder yet. <span class="admin-only">Right-click here to add one.</span></p>
{{end}}
</div>
{{end}}
+2 -106
View File
@@ -1,109 +1,5 @@
{{define "panel_root"}}
<div class="panel-content p-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-2">
<li class="breadcrumb-item active" aria-current="page">Inventory</li>
</ol>
</nav>
<div class="row g-2 mb-3">
<div class="col-6 col-md-2">
<div class="card stat-card">
<div class="card-body py-2">
<div class="stat-value">{{.Stats.Total}}</div>
<div class="stat-label">Total Assets</div>
</div>
</div>
</div>
{{range .Stats.ByStatus}}
<div class="col-6 col-md-2">
<div class="card stat-card">
<div class="card-body py-2">
<div class="stat-value">{{.Count}}</div>
<div class="stat-label"><span class="badge text-bg-{{.Color}}">{{.Name}}</span></div>
</div>
</div>
</div>
{{end}}
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 fw-semibold">Assets by Type</div>
<ul class="list-group list-group-flush">
{{range .Stats.ByType}}
<li class="list-group-item d-flex justify-content-between align-items-center py-1">
<span><i class="bi {{iconOr .Icon "bi-box-seam"}} me-2"></i>{{.Name}}</span>
<span class="badge text-bg-secondary rounded-pill">{{.Count}}</span>
</li>
{{else}}
<li class="list-group-item text-body-secondary py-2">No assets yet.</li>
{{end}}
</ul>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 fw-semibold">Recently Added</div>
<ul class="list-group list-group-flush">
{{range .Stats.RecentlyAdded}}
<li class="list-group-item py-1">
<a href="#" class="asset-link text-decoration-none" data-id="{{.ID}}">
<i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-2"></i>{{.Name}}
<span class="text-body-secondary small">{{.AssetTag}}</span>
</a>
</li>
{{else}}
<li class="list-group-item text-body-secondary py-2">No assets yet.</li>
{{end}}
</ul>
</div>
</div>
</div>
<div class="d-flex align-items-center mb-2">
<h6 class="mb-0">Top-Level Folders</h6>
<div class="ms-auto d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="/export/assets.csv"><i class="bi bi-download"></i> Export CSV</a>
<button class="btn btn-sm btn-outline-secondary admin-only" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
<button class="btn btn-sm btn-primary admin-only" id="btn-new-folder-root"><i class="bi bi-folder-plus"></i> New Folder</button>
</div>
</div>
{{if .Folders}}
<div class="list-group mb-3">
{{range .Folders}}
<a href="#" class="list-group-item list-group-item-action folder-link d-flex align-items-center" data-id="{{.ID}}">
<i class="bi {{iconOr .Icon "bi-folder-fill"}} text-warning-emphasis me-2"></i>
{{.Name}}
<span class="text-body-secondary small ms-2">{{if .Description}}{{.Description}}{{end}}</span>
<span class="ms-auto text-body-secondary small">{{.FolderCount}} folder(s), {{.AssetCount}} asset(s)</span>
</a>
{{end}}
</div>
{{else}}
<p class="text-body-secondary">No folders yet. {{if true}}<span class="admin-only">Create one to start organizing your inventory.</span>{{end}}</p>
{{end}}
{{if .UnfiledAssets}}
<h6 class="mb-2">Unfiled Assets</h6>
<div class="table-responsive">
<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>
<tbody>
{{range .UnfiledAssets}}
<tr class="asset-row" data-id="{{.ID}}">
<td>{{.AssetTag}}</td>
<td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td>
<td>{{.ItemTypeName}}</td>
<td><span class="badge text-bg-{{.StatusColor}}">{{.StatusName}}</span></td>
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">&mdash;</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<div class="panel-content panel-blank p-3 h-100 d-flex align-items-center justify-content-center" data-panel-type="root">
<p class="text-body-secondary mb-0">Select a folder or asset from the tree.</p>
</div>
{{end}}
+19
View File
@@ -0,0 +1,19 @@
{{define "settings_nav"}}
<div class="list-group settings-nav">
<a href="/settings" class="list-group-item list-group-item-action{{if eq . "general"}} active{{end}}">
<i class="bi bi-sliders me-2"></i>General
</a>
<a href="/settings/statuses" class="list-group-item list-group-item-action{{if eq . "statuses"}} active{{end}}">
<i class="bi bi-tag me-2"></i>Statuses
</a>
<a href="/settings/item-types" class="list-group-item list-group-item-action{{if eq . "itemtypes"}} active{{end}}">
<i class="bi bi-tags me-2"></i>Item Types
</a>
<a href="/settings/users" class="list-group-item list-group-item-action{{if eq . "users"}} active{{end}}">
<i class="bi bi-people me-2"></i>Users
</a>
<a href="/settings/import" class="list-group-item list-group-item-action{{if eq . "import"}} active{{end}}">
<i class="bi bi-upload me-2"></i>Import / Export
</a>
</div>
{{end}}
@@ -12,11 +12,6 @@
{{end}}
<i class="bi {{iconOr .Icon "bi-folder-fill"}} tree-icon text-warning-emphasis"></i>
<span class="tree-label">{{.Name}}</span>
<span class="tree-actions admin-only">
<button type="button" class="btn-tree-action" data-action="menu" data-id="{{.ID}}" data-type="folder" title="Actions">
<i class="bi bi-three-dots"></i>
</button>
</span>
</div>
<ul class="tree-children list-unstyled{{if not .Expanded}} d-none{{end}}" data-expanded="{{.Expanded}}" data-loaded="{{.Expanded}}">
{{if .Expanded}}{{template "tree_children" .Children}}{{end}}