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-shm
*.db-wal *.db-wal
.git/ .git/
inventree vibetree
+1 -1
View File
@@ -1,4 +1,4 @@
/inventree /vibetree
/data/ /data/
*.db *.db
*.db-shm *.db-shm
+7 -7
View File
@@ -9,19 +9,19 @@ RUN go mod download
COPY . . COPY . .
ARG VERSION=dev 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 FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \ 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 WORKDIR /app
COPY --from=build /out/inventree /usr/local/bin/inventree COPY --from=build /out/vibetree /usr/local/bin/vibetree
RUN mkdir -p /app/data && chown -R inventree:inventree /app/data RUN mkdir -p /app/data && chown -R vibetree:vibetree /app/data
USER inventree USER vibetree
VOLUME ["/app/data"] VOLUME ["/app/data"]
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["inventree"] ENTRYPOINT ["vibetree"]
CMD ["serve", "-addr", ":8080", "-db", "/app/data/inventree.db"] 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 nested folder tree — like a file manager — for organizing physical and
virtual IT assets (servers, switches, laptops, printers, etc.). virtual IT assets (servers, switches, laptops, printers, etc.).
@@ -14,8 +14,8 @@ virtual IT assets (servers, switches, laptops, printers, etc.).
## Quick start ## Quick start
```sh ```sh
go build -o inventree ./cmd/inventree go build -o vibetree ./cmd/vibetree
./inventree serve ./vibetree serve
``` ```
Then open http://localhost:8080 and log in with: 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 (This default account is created intentionally on first run. Change or
disable it via the Users admin page once you're up and running.) 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, run, with migrations applied and seed data (default statuses, item types,
and the admin account) inserted. and the admin account) inserted.
## CLI ## CLI
```sh ```sh
inventree # same as `inventree serve` vibetree # same as `vibetree serve`
inventree serve # start the web server vibetree serve # start the web server
inventree version # print the version vibetree version # print the version
inventree database migrate # apply pending migrations and exit vibetree database migrate # apply pending migrations and exit
inventree database backup [path] # write a consistent VACUUM INTO snapshot vibetree database backup [path] # write a consistent VACUUM INTO snapshot
``` ```
`serve` flags: `serve` flags:
@@ -47,7 +47,7 @@ inventree database backup [path] # write a consistent VACUUM INTO snapshot
| Flag | Default | Description | | Flag | Default | Description |
|---------------------|------------------------|--------------------------------------------| |---------------------|------------------------|--------------------------------------------|
| `-addr` | `:8080` | Listen address | | `-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) | | `-secure-cookies` | `false` | Mark the session cookie `Secure` (enable when serving over HTTPS) |
`/healthz` is available for load balancer / container health checks. `/healthz` is available for load balancer / container health checks.
@@ -66,8 +66,12 @@ asset).
never fully reloads when you browse the tree. never fully reloads when you browse the tree.
- **Drag and drop** folders and assets onto another folder (or onto - **Drag and drop** folders and assets onto another folder (or onto
"Inventory" itself) to move them. Admin only. "Inventory" itself) to move them. Admin only.
- **Right-click-style actions**: each folder has a "…" action menu (New - **Right-click for everything**: it's meant to feel like a native file
Subfolder, Add Asset, Rename, Delete). 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 - **Deleting a folder** that still contains subfolders or assets requires
confirmation, and offers a safe "move contents elsewhere" option instead confirmation, and offers a safe "move contents elsewhere" option instead
of an accidental cascade. A "permanently delete everything inside" option 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 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. 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 ## CSV import / export
- **Export**: from the Inventory toolbar, or `/export/assets.csv` (optionally - **Export**: from the tree's right-click menu, or `/export/assets.csv`
scoped to a folder, with `?recursive=1` to include subfolders). Includes (optionally scoped to a folder, with `?recursive=1` to include
one `cf_<field_key>` column per distinct custom field in use. subfolders). Includes a `Quantity` column and one `cf_<field_key>` column
- **Import**: CSV with columns `Asset ID` (optional — auto-generated if per distinct custom field in use.
blank), `Name`, `Item Type`, `Folder` (a `/`-separated path, e.g. - **Folder-scoped import**: right-click a folder → **Import CSV…** imports
`Main Office/Network`), `Status`, `Assigned User`, `Notes`, plus every row directly into that folder. No `Folder` column needed — that's
`cf_<field_key>` columns for custom fields. Existing assets are matched the whole point of doing it from a specific folder. Never available at the
and updated by Asset ID; everything else is created. Missing folders in top level.
the path can optionally be created automatically. - **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, A full backup/restore of everything (folders, assets, types, custom fields,
statuses) is just `inventree database backup` — it's a consistent snapshot statuses, users) is just `vibetree database backup` — it's a consistent
of the whole SQLite database. snapshot of the whole SQLite database.
## Project layout ## Project layout
``` ```
cmd/inventree/ CLI entrypoint (serve, version, database migrate/backup) cmd/vibetree/ CLI entrypoint (serve, version, database migrate/backup)
internal/ internal/
auth/ session management, secure cookies auth/ session management, secure cookies
database/ SQLite connection, pragmas, migrations database/ SQLite connection, pragmas, migrations
@@ -140,33 +201,33 @@ the compiled binary has no C library dependencies.
docker compose up --build docker compose up --build
``` ```
This builds the image, starts inventree on port 8080, and persists the This builds the image, starts vibetree on port 8080, and persists the
database in a named volume (`inventree-data`, mounted at `/app/data`). database in a named volume (`vibetree-data`, mounted at `/app/data`).
Or directly: Or directly:
```sh ```sh
docker build -t inventree . docker build -t vibetree .
docker run -p 8080:8080 -v inventree-data:/app/data inventree docker run -p 8080:8080 -v vibetree-data:/app/data vibetree
``` ```
## systemd ## 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 ```sh
sudo useradd --system --home /opt/inventree --shell /usr/sbin/nologin inventree sudo useradd --system --home /opt/vibetree --shell /usr/sbin/nologin vibetree
sudo mkdir -p /opt/inventree/data sudo mkdir -p /opt/vibetree/data
sudo cp inventree /opt/inventree/inventree sudo cp vibetree /opt/vibetree/vibetree
sudo chown -R inventree:inventree /opt/inventree sudo chown -R vibetree:vibetree /opt/vibetree
sudo cp deploy/inventree.service /etc/systemd/system/ sudo cp deploy/vibetree.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now inventree sudo systemctl enable --now vibetree
``` ```
## Scope ## 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 statuses, users, search, and CSV import/export. It does not include
ticketing, purchasing, monitoring, RMM, MDM, or accounting features — if you ticketing, purchasing, monitoring, RMM, MDM, or accounting features — if you
need those, look elsewhere and integrate via CSV. 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). // its supporting subcommands (version, database migrate, database backup).
package main package main
@@ -14,8 +14,8 @@ import (
"syscall" "syscall"
"time" "time"
"inventree/internal/database" "vibetree/internal/database"
"inventree/internal/web" "vibetree/internal/web"
) )
// version is set at build time via -ldflags "-X main.version=...". // version is set at build time via -ldflags "-X main.version=...".
@@ -36,11 +36,11 @@ func run(args []string) error {
case "serve": case "serve":
return runServe(args[1:]) return runServe(args[1:])
case "version": case "version":
fmt.Println("inventree", version) fmt.Println("vibetree", version)
return nil return nil
case "database": case "database":
if len(args) < 2 { if len(args) < 2 {
return fmt.Errorf("usage: inventree database <migrate|backup>") return fmt.Errorf("usage: vibetree database <migrate|backup>")
} }
switch args[1] { switch args[1] {
case "migrate": case "migrate":
@@ -60,22 +60,22 @@ func run(args []string) error {
} }
func printUsage() { func printUsage() {
fmt.Println(`inventree - a small IT asset inventory system fmt.Println(`vibetree - a small IT asset inventory system
Usage: Usage:
inventree [serve] Start the web server (default command) vibetree [serve] Start the web server (default command)
inventree version Print the version vibetree version Print the version
inventree database migrate Apply pending database migrations vibetree database migrate Apply pending database migrations
inventree database backup [path] Write a consistent backup snapshot vibetree database backup [path] Write a consistent backup snapshot
Flags for "serve": Flags for "serve":
-addr string listen address (default ":8080") -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)`) -secure-cookies mark the session cookie Secure (enable when serving over HTTPS)`)
} }
func dbPathFlag(args []string) (dbPath string, rest []string) { func dbPathFlag(args []string) (dbPath string, rest []string) {
dbPath = "data/inventree.db" dbPath = "data/vibetree.db"
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
if args[i] == "-db" && i+1 < len(args) { if args[i] == "-db" && i+1 < len(args) {
dbPath = args[i+1] dbPath = args[i+1]
@@ -137,7 +137,7 @@ func ensureDir(path string) error {
func runServe(args []string) error { func runServe(args []string) error {
var ( var (
addr = ":8080" addr = ":8080"
dbPath = "data/inventree.db" dbPath = "data/vibetree.db"
secureCookies = false secureCookies = false
) )
for i := 0; i < len(args); i++ { 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) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err return err
} }
@@ -1,13 +1,13 @@
[Unit] [Unit]
Description=inventree - IT asset inventory Description=vibetree - IT asset inventory
After=network.target After=network.target
[Service] [Service]
Type=simple Type=simple
User=inventree User=vibetree
Group=inventree Group=vibetree
WorkingDirectory=/opt/inventree WorkingDirectory=/opt/vibetree
ExecStart=/opt/inventree/inventree serve -addr :8080 -db /opt/inventree/data/inventree.db ExecStart=/opt/vibetree/vibetree serve -addr :8080 -db /opt/vibetree/data/vibetree.db
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
@@ -15,7 +15,7 @@ RestartSec=2
NoNewPrivileges=true NoNewPrivileges=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=true
ReadWritePaths=/opt/inventree/data ReadWritePaths=/opt/vibetree/data
PrivateTmp=true PrivateTmp=true
[Install] [Install]
+5 -5
View File
@@ -1,13 +1,13 @@
services: services:
inventree: vibetree:
build: . build: .
image: inventree:latest image: vibetree:latest
container_name: inventree container_name: vibetree
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- inventree-data:/app/data - vibetree-data:/app/data
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s interval: 30s
@@ -16,4 +16,4 @@ services:
start_period: 5s start_period: 5s
volumes: volumes:
inventree-data: vibetree-data:
+3 -1
View File
@@ -1,8 +1,10 @@
module inventree module vibetree
go 1.25.0 go 1.25.0
require ( 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 golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= 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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+86 -93
View File
@@ -11,9 +11,9 @@ import (
"strings" "strings"
"time" "time"
"inventree/internal/folders" "vibetree/internal/folders"
"inventree/internal/models" "vibetree/internal/models"
"inventree/internal/settings" "vibetree/internal/settings"
) )
var ( var (
@@ -106,6 +106,24 @@ func (s *Store) tagExists(ctx context.Context, tag string, excludeID int64) (boo
return count > 0, err 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. // Create inserts a new asset. If a.AssetTag is empty, one is generated.
// fieldValues maps custom_field_defs.id to the submitted value. // 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 { 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 { } else if taken {
return ErrTagTaken 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) tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
@@ -131,9 +154,9 @@ func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int
defer tx.Rollback() defer tx.Rollback()
res, err := tx.ExecContext(ctx, res, err := tx.ExecContext(ctx,
`INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes) `INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes) a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.Quantity)
if err != nil { if err != nil {
return err return err
} }
@@ -173,6 +196,11 @@ func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int
} else if taken { } else if taken {
return ErrTagTaken 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) tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
@@ -181,9 +209,9 @@ func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int
defer tx.Rollback() defer tx.Rollback()
_, err = tx.ExecContext(ctx, _, 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 = ?`, 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 { if err != nil {
return err return err
} }
@@ -209,6 +237,51 @@ func (s *Store) Move(ctx context.Context, id int64, folderID *int64) error {
return err 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 // Duplicate copies an asset's fields and custom field values into a new
// asset with a freshly generated asset tag. // asset with a freshly generated asset tag.
func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error) { 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, StatusID: orig.StatusID,
AssignedUserID: orig.AssignedUserID, AssignedUserID: orig.AssignedUserID,
Notes: orig.Notes, Notes: orig.Notes,
Quantity: orig.Quantity,
} }
values := map[int64]string{} values := map[int64]string{}
for _, fv := range orig.CustomFields { for _, fv := range orig.CustomFields {
@@ -242,8 +316,8 @@ func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error)
} }
const assetSelect = ` 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, 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, st.name, st.color, COALESCE(u.username, '') it.name, it.icon, it.is_unique, st.name, st.color, COALESCE(u.username, '')
FROM assets a FROM assets a
JOIN item_types it ON it.id = a.item_type_id JOIN item_types it ON it.id = a.item_type_id
JOIN statuses st ON st.id = a.status_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 a models.Asset
var folderID, assignedUserID sql.NullInt64 var folderID, assignedUserID sql.NullInt64
var created, updated string var created, updated string
if err := row.Scan(&a.ID, &a.AssetTag, &a.Name, &a.ItemTypeID, &folderID, &a.StatusID, &assignedUserID, &a.Notes, &created, &updated, 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.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil { &a.ItemTypeName, &a.ItemTypeIcon, &a.ItemTypeIsUnique, &a.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil {
return nil, err return nil, err
} }
if folderID.Valid { if folderID.Valid {
@@ -466,84 +540,3 @@ func (s *Store) descendantFolderIDs(ctx context.Context, rootID int64) ([]int64,
walk(rootID) walk(rootID)
return out, nil 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" "strings"
"testing" "testing"
"inventree/internal/assets" "vibetree/internal/assets"
"inventree/internal/database" "vibetree/internal/database"
"inventree/internal/folders" "vibetree/internal/folders"
"inventree/internal/models" "vibetree/internal/models"
"inventree/internal/settings" "vibetree/internal/settings"
) )
// Seed data from migrations/0002_seed.sql gives us stable IDs to rely on: // Seed data from migrations/0002_seed.sql gives us stable IDs to rely on:
+73 -21
View File
@@ -6,9 +6,10 @@ import (
"fmt" "fmt"
"io" "io"
"sort" "sort"
"strconv"
"strings" "strings"
"inventree/internal/models" "vibetree/internal/models"
) )
const ( const (
@@ -19,12 +20,20 @@ const (
colStatus = "status" colStatus = "status"
colAssignedUser = "assigned user" colAssignedUser = "assigned user"
colNotes = "notes" colNotes = "notes"
colQuantity = "quantity"
cfColPrefix = "cf_" cfColPrefix = "cf_"
) )
// ImportOptions controls CSV import behavior. // ImportOptions controls CSV import behavior.
type ImportOptions struct { type ImportOptions struct {
CreateMissingFolders bool 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 { type ImportResult struct {
@@ -157,8 +166,9 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
} }
var folderID *int64 var folderID *int64
folderPath := get(row, colFolder) if opts.TargetFolderID != nil {
if folderPath != "" { folderID = opts.TargetFolderID
} else if folderPath := get(row, colFolder); folderPath != "" {
id, err := s.resolveFolderPath(ctx, lk, folderPath, opts.CreateMissingFolders) id, err := s.resolveFolderPath(ctx, lk, folderPath, opts.CreateMissingFolders)
if err != nil { if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: folder %q: %v", rowNum, folderPath, err)) 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{ asset := &models.Asset{
AssetTag: get(row, colAssetID), AssetTag: get(row, colAssetID),
Name: name, Name: name,
@@ -201,6 +218,7 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
StatusID: statusID, StatusID: statusID,
AssignedUserID: assignedUserID, AssignedUserID: assignedUserID,
Notes: get(row, colNotes), Notes: get(row, colNotes),
Quantity: quantity,
} }
if asset.AssetTag != "" { if asset.AssetTag != "" {
@@ -272,13 +290,8 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
if err != nil { if err != nil {
return err return err
} }
cw := csv.NewWriter(w) cw := csv.NewWriter(w)
header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Created At", "Updated At"} if err := writeAssetHeader(cw, fieldDefs); err != nil {
for _, k := range fieldDefs {
header = append(header, cfColPrefix+k)
}
if err := cw.Write(header); err != nil {
return err return err
} }
@@ -294,18 +307,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
if err != nil { if err != nil {
return err return err
} }
row := []string{ if err := writeAssetRow(cw, a, fieldDefs); err != nil {
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 {
return err return err
} }
} }
@@ -318,6 +320,56 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
return cw.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 { func folderPathString(path []models.BreadcrumbItem) string {
names := make([]string, len(path)) names := make([]string, len(path))
for i, p := range 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 ( import (
"context" "context"
"inventree/internal/models" "vibetree/internal/models"
) )
// Search performs a global, case-insensitive substring search across asset // Search performs a global, case-insensitive substring search across asset
+3 -3
View File
@@ -10,12 +10,12 @@ import (
"net/http" "net/http"
"time" "time"
"inventree/internal/models" "vibetree/internal/models"
"inventree/internal/users" "vibetree/internal/users"
) )
const ( const (
CookieName = "inventree_session" CookieName = "vibetree_session"
SessionTTL = 7 * 24 * time.Hour SessionTTL = 7 * 24 * time.Hour
contextUserK = ctxKey("current_user") contextUserK = ctxKey("current_user")
) )
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"inventree/internal/auth" "vibetree/internal/auth"
"inventree/internal/database" "vibetree/internal/database"
"inventree/internal/users" "vibetree/internal/users"
) )
func testDB(t *testing.T) *sql.DB { func testDB(t *testing.T) *sql.DB {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"inventree/internal/database" "vibetree/internal/database"
) )
func TestOpenSetsPragmasAndMigrates(t *testing.T) { func TestOpenSetsPragmasAndMigrates(t *testing.T) {
+1 -1
View File
@@ -1,4 +1,4 @@
-- Initial schema for inventree -- Initial schema for vibetree
CREATE TABLE users ( CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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_prefix', 'IT-'),
('asset_id_padding', '6'), ('asset_id_padding', '6'),
('asset_id_seq', '0'), ('asset_id_seq', '0'),
('site_name', 'inventree'); ('site_name', 'vibetree');
INSERT INTO item_types (name, description, icon, enabled, sort_order) VALUES INSERT INTO item_types (name, description, icon, enabled, sort_order) VALUES
('Laptop', 'Portable computer', 'bi-laptop', 1, 1), ('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" "fmt"
"time" "time"
"inventree/internal/models" "vibetree/internal/models"
) )
var ( var (
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"inventree/internal/database" "vibetree/internal/database"
"inventree/internal/folders" "vibetree/internal/folders"
"inventree/internal/models" "vibetree/internal/models"
) )
func testDB(t *testing.T) *sql.DB { func testDB(t *testing.T) *sql.DB {
+18 -9
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"inventree/internal/models" "vibetree/internal/models"
) )
var ( var (
@@ -58,7 +58,7 @@ func validFieldType(t models.FieldType) bool {
// List returns all item types, optionally including disabled ones, each with // List returns all item types, optionally including disabled ones, each with
// its custom field definitions loaded. // its custom field definitions loaded.
func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemType, error) { 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 { if !includeDisabled {
q += ` WHERE enabled = 1` q += ` WHERE enabled = 1`
} }
@@ -72,7 +72,7 @@ func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemTy
for rows.Next() { for rows.Next() {
var it models.ItemType var it models.ItemType
var created, updated string 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 return nil, err
} }
it.CreatedAt = parseTime(created) 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) { 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 it models.ItemType
var created, updated string 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -146,8 +146,8 @@ func (s *Store) Create(ctx context.Context, it *models.ItemType) error {
it.Icon = "bi-box-seam" it.Icon = "bi-box-seam"
} }
res, err := s.db.ExecContext(ctx, res, err := s.db.ExecContext(ctx,
`INSERT INTO item_types (name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?)`, `INSERT INTO item_types (name, description, icon, enabled, sort_order, is_unique) VALUES (?, ?, ?, ?, ?, ?)`,
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder) it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.IsUnique)
if err != nil { if err != nil {
return err return err
} }
@@ -164,8 +164,17 @@ func (s *Store) Update(ctx context.Context, it *models.ItemType) error {
return ErrNameRequired return ErrNameRequired
} }
_, err := s.db.ExecContext(ctx, _, 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 = ?`, `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.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 return err
} }
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"inventree/internal/database" "vibetree/internal/database"
"inventree/internal/itemtypes" "vibetree/internal/itemtypes"
"inventree/internal/models" "vibetree/internal/models"
) )
func testDB(t *testing.T) *sql.DB { func testDB(t *testing.T) *sql.DB {
+22 -3
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 package models
import "time" import "time"
@@ -8,6 +8,11 @@ type Role string
const ( const (
RoleAdmin Role = "admin" RoleAdmin Role = "admin"
RoleUser Role = "user" 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 { type User struct {
@@ -22,6 +27,10 @@ type User struct {
func (u User) IsAdmin() bool { return u.Role == RoleAdmin } 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 { type Folder struct {
ID int64 ID int64
Name string Name string
@@ -96,6 +105,12 @@ type ItemType struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt 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 Fields []CustomFieldDef
} }
@@ -129,12 +144,16 @@ type Asset struct {
StatusID int64 StatusID int64
AssignedUserID *int64 AssignedUserID *int64
Notes string Notes string
CreatedAt time.Time // Quantity is meaningful only when the asset's item type is not
UpdatedAt time.Time // IsUnique; unique-type assets are always kept at 1.
Quantity int
CreatedAt time.Time
UpdatedAt time.Time
// Populated by joined list/detail queries. // Populated by joined list/detail queries.
ItemTypeName string ItemTypeName string
ItemTypeIcon string ItemTypeIcon string
ItemTypeIsUnique bool
StatusName string StatusName string
StatusColor string StatusColor string
AssignedUsername 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" "errors"
"time" "time"
"inventree/internal/models" "vibetree/internal/models"
) )
var ( var (
+11 -4
View File
@@ -9,17 +9,21 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"inventree/internal/models" "vibetree/internal/models"
) )
var ( var (
ErrNotFound = errors.New("user not found") ErrNotFound = errors.New("user not found")
ErrUsernameRequired = errors.New("username is required") ErrUsernameRequired = errors.New("username is required")
ErrUsernameTaken = errors.New("username is already taken") 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") 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 } type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: 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 == "" { if u.Username == "" {
return ErrUsernameRequired return ErrUsernameRequired
} }
if u.Role != models.RoleAdmin && u.Role != models.RoleUser { if !validRole(u.Role) {
return ErrInvalidRole return ErrInvalidRole
} }
if _, err := s.GetByUsername(ctx, u.Username); err == nil { 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 == "" { if u.Username == "" {
return ErrUsernameRequired return ErrUsernameRequired
} }
if u.Role != models.RoleAdmin && u.Role != models.RoleUser { if !validRole(u.Role) {
return ErrInvalidRole return ErrInvalidRole
} }
if existing, err := s.GetByUsername(ctx, u.Username); err == nil && existing.ID != u.ID { 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 { if !u.Enabled {
return nil, errors.New("account is disabled") 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 { if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials") return nil, errors.New("invalid credentials")
} }
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"inventree/internal/database" "vibetree/internal/database"
"inventree/internal/models" "vibetree/internal/models"
"inventree/internal/users" "vibetree/internal/users"
) )
func testDB(t *testing.T) *sql.DB { func testDB(t *testing.T) *sql.DB {
+9 -9
View File
@@ -1,5 +1,5 @@
// Package web implements the HTTP server: routing, session middleware, // 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 package web
import ( import (
@@ -9,13 +9,13 @@ import (
"net/http" "net/http"
"time" "time"
"inventree/internal/assets" "vibetree/internal/assets"
"inventree/internal/auth" "vibetree/internal/auth"
"inventree/internal/folders" "vibetree/internal/folders"
"inventree/internal/itemtypes" "vibetree/internal/itemtypes"
"inventree/internal/settings" "vibetree/internal/settings"
"inventree/internal/statuses" "vibetree/internal/statuses"
"inventree/internal/users" "vibetree/internal/users"
) )
// App holds every dependency HTTP handlers need: data stores, templates, and // 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, SecureCookies: secureCookies,
} }
tmpl, err := loadTemplates() tmpl, err := loadTemplates(app)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -5,9 +5,9 @@ import "context"
// FolderOption is a flattened, indented folder choice for <select> dropdowns // FolderOption is a flattened, indented folder choice for <select> dropdowns
// (asset create/edit, move-to pickers). // (asset create/edit, move-to pickers).
type FolderOption struct { type FolderOption struct {
ID int64 ID int64 `json:"id"`
Path string Path string `json:"path"`
Depth int Depth int `json:"depth"`
} }
// folderOptions returns every folder as a flat, depth-first ordered list // folderOptions returns every folder as a flat, depth-first ordered list
+95 -3
View File
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"inventree/internal/models" "vibetree/internal/models"
) )
type assetRequest struct { type assetRequest struct {
@@ -15,6 +15,7 @@ type assetRequest struct {
StatusID int64 `json:"status_id"` StatusID int64 `json:"status_id"`
AssignedUserID *int64 `json:"assigned_user_id"` AssignedUserID *int64 `json:"assigned_user_id"`
Notes string `json:"notes"` Notes string `json:"notes"`
Quantity int `json:"quantity"`
Fields map[string]string `json:"fields"` Fields map[string]string `json:"fields"`
} }
@@ -38,7 +39,7 @@ func (a *App) handleAssetCreate(w http.ResponseWriter, r *http.Request) {
} }
asset := &models.Asset{ asset := &models.Asset{
AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: req.ItemTypeID, FolderID: req.FolderID, 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 { if err := a.Assets.Create(r.Context(), asset, req.fieldValues()); err != nil {
jsonErr(w, http.StatusBadRequest, err) jsonErr(w, http.StatusBadRequest, err)
@@ -65,7 +66,7 @@ func (a *App) handleAssetUpdate(w http.ResponseWriter, r *http.Request) {
} }
asset := &models.Asset{ asset := &models.Asset{
ID: id, AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: existing.ItemTypeID, FolderID: req.FolderID, 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 { if err := a.Assets.Update(r.Context(), asset, req.fieldValues()); err != nil {
jsonErr(w, http.StatusBadRequest, err) 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}) 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 ( import (
"net/http" "net/http"
"inventree/internal/auth" "vibetree/internal/auth"
) )
type loginPageData struct { type loginPageData struct {
+46 -7
View File
@@ -5,11 +5,46 @@ import (
"net/http" "net/http"
"time" "time"
"inventree/internal/assets" "vibetree/internal/assets"
) )
func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) { func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() 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{ filter := assets.ListFilter{
ItemTypeID: queryInt64(r, "item_type_id"), ItemTypeID: queryInt64(r, "item_type_id"),
StatusID: queryInt64(r, "status_id"), StatusID: queryInt64(r, "status_id"),
@@ -21,15 +56,16 @@ func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
filter.FolderSet = true filter.FolderSet = true
filter.IncludeSub = r.URL.Query().Get("recursive") == "1" 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 { if err := a.Assets.Export(ctx, w, filter); err != nil {
a.Log.Error("csv export failed", "error", err) 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) { func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
if err := r.ParseMultipartForm(32 << 20); err != nil { 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() 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) itemTypes, err := a.ItemTypes.List(ctx, true)
if err != nil { if err != nil {
@@ -61,7 +100,7 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
jsonErr(w, http.StatusBadRequest, err) jsonErr(w, http.StatusBadRequest, err)
return return
+13 -2
View File
@@ -4,10 +4,21 @@ import (
"errors" "errors"
"net/http" "net/http"
"inventree/internal/folders" "vibetree/internal/folders"
"inventree/internal/models" "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) { func (a *App) handleFolderGet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
+48 -65
View File
@@ -1,20 +1,24 @@
package web package web
import ( import (
"context"
"net/http" "net/http"
"inventree/internal/assets" "vibetree/internal/assets"
"inventree/internal/models" "vibetree/internal/models"
) )
// InventoryPageData is the top-level data passed to templates/pages/inventory.html. // InventoryPageData is the top-level data passed to templates/pages/inventory.html.
type InventoryPageData struct { type InventoryPageData struct {
User *models.User User *models.User
Tree []models.TreeNode Tree []models.TreeNode
Root *RootPanelData
ItemTypes []models.ItemType ItemTypes []models.ItemType
Statuses []models.Status 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) { 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()) writeErr(w, http.StatusInternalServerError, err.Error())
return return
} }
root, err := a.rootPanelData(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
itemTypes, err := a.ItemTypes.List(ctx, false) itemTypes, err := a.ItemTypes.List(ctx, false)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error()) 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{ 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) w.WriteHeader(http.StatusNoContent)
} }
// RootPanelData backs the main panel when nothing is selected: the virtual // handlePanelRoot backs the main panel when nothing is selected. It is
// "Inventory" root, showing top-level folders, unfiled assets, and the // intentionally blank (no dashboard/summary) - the panel only shows
// small dashboard. // something once a folder or asset is actually clicked; browsing happens
type RootPanelData struct { // in the tree. The fragment still carries a data-panel-type="root" hook so
Folders []models.Folder // right-click still offers "New Folder" / "Add Asset" / import / export.
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
}
func (a *App) handlePanelRoot(w http.ResponseWriter, r *http.Request) { func (a *App) handlePanelRoot(w http.ResponseWriter, r *http.Request) {
data, err := a.rootPanelData(r.Context()) a.RenderFragment(w, http.StatusOK, "panel_root", nil)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "panel_root", data)
} }
// FolderPanelData backs the main panel when a folder is selected. // FolderPanelData backs the main panel when a folder is selected.
@@ -221,9 +195,7 @@ func (a *App) handlePanelFolder(w http.ResponseWriter, r *http.Request) {
// AssetPanelData backs the main panel when an asset is selected (view mode). // AssetPanelData backs the main panel when an asset is selected (view mode).
type AssetPanelData struct { type AssetPanelData struct {
Asset *models.Asset Asset *models.Asset
CanEdit bool
FolderOptions []FolderOption
} }
func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) { func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
@@ -238,24 +210,21 @@ func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, err.Error()) writeErr(w, http.StatusNotFound, err.Error())
return return
} }
opts, err := a.folderOptions(ctx) a.RenderFragment(w, http.StatusOK, "panel_asset", AssetPanelData{Asset: asset})
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})
} }
// AssetFormData backs both the "new asset" and "edit asset" forms. // AssetFormData backs both the "new asset" and "edit asset" forms.
type AssetFormData struct { type AssetFormData struct {
Asset *models.Asset // nil when creating Asset *models.Asset // nil when creating
FolderID *int64 FolderID *int64
ItemTypes []models.ItemType ItemTypes []models.ItemType
Statuses []models.Status Statuses []models.Status
Users []models.User Users []models.User
FolderOptions []FolderOption FolderOptions []FolderOption
SelectedIT int64 SelectedIT int64
Fields []models.AssetFieldValue SelectedITUnique bool
Quantity int
Fields []models.AssetFieldValue
} }
func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) { func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
@@ -287,15 +256,17 @@ func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
} }
var selectedIT int64 var selectedIT int64
var selectedITUnique bool
var fields []models.AssetFieldValue var fields []models.AssetFieldValue
if len(itemTypes) > 0 { if len(itemTypes) > 0 {
selectedIT = itemTypes[0].ID selectedIT = itemTypes[0].ID
selectedITUnique = itemTypes[0].IsUnique
fields = fieldValuesForCreate(itemTypes[0].Fields) fields = fieldValuesForCreate(itemTypes[0].Fields)
} }
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{ a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
FolderID: folderID, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts, 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{ a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
Asset: asset, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts, 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 // AssetDynamicFieldsData backs the "asset_fields_only" partial: the
// a chosen item type, used when the item type dropdown changes on the // quantity input (only relevant for non-unique item types) plus the
// "new asset" form. // 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) { func (a *App) handleAssetFieldsFragment(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
itemTypeID := queryInt64(r, "item_type_id") 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()) writeErr(w, http.StatusNotFound, err.Error())
return 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" "strconv"
"strings" "strings"
"inventree/internal/models" "vibetree/internal/models"
) )
type itemTypesListData struct { type itemTypesListData struct {
@@ -41,12 +41,13 @@ func (a *App) handleItemTypeCreate(w http.ResponseWriter, r *http.Request) {
it := &models.ItemType{ it := &models.ItemType{
Name: r.FormValue("name"), Description: r.FormValue("description"), Name: r.FormValue("name"), Description: r.FormValue("description"),
Icon: r.FormValue("icon"), Enabled: r.FormValue("enabled") == "on", SortOrder: atoiDefault(r.FormValue("sort_order"), 0), 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 { 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()}) a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
return 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) { 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.Icon = r.FormValue("icon")
it.Enabled = r.FormValue("enabled") == "on" it.Enabled = r.FormValue("enabled") == "on"
it.SortOrder = atoiDefault(r.FormValue("sort_order"), it.SortOrder) 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 { 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()}) a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
return 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) { 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 return
} }
if err := a.ItemTypes.Delete(r.Context(), id); err != nil { 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 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) { 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 { if err := a.ItemTypes.CreateField(r.Context(), f); err != nil {
a.Log.Warn("create field failed", "error", err) 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) { 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 { if err := a.ItemTypes.UpdateField(r.Context(), f); err != nil {
a.Log.Warn("update field failed", "error", err) 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) { 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 { if err := a.ItemTypes.DeleteField(r.Context(), fieldID); err != nil {
a.Log.Warn("delete field failed", "error", err) 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 { 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 ( import (
"net/http" "net/http"
"inventree/internal/models" "vibetree/internal/models"
) )
type SearchPanelData struct { type SearchPanelData struct {
+34 -31
View File
@@ -4,48 +4,37 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"inventree/internal/models" "vibetree/internal/models"
) )
type settingsPageData struct { type settingsPageData struct {
User *models.User User *models.User
Statuses []models.Status
SiteName string SiteName string
AssetPrefix string AssetPrefix string
AssetPadding string AssetPadding string
Error 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) { 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 { if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error()) writeErr(w, http.StatusInternalServerError, err.Error())
return 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) { 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) 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) { func (a *App) handleStatusCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error()) 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 { if err := a.Statuses.Create(r.Context(), st); err != nil {
a.Log.Warn("create status failed", "error", err) 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) { 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 { if err := a.Statuses.Update(r.Context(), st); err != nil {
a.Log.Warn("update status failed", "error", err) 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) { 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 { if err := a.Statuses.Delete(r.Context(), id); err != nil {
a.Log.Warn("delete status failed", "error", err) 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) { 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)
}
+65 -15
View File
@@ -1,12 +1,38 @@
package web package web
import ( import (
"crypto/rand"
"encoding/hex"
"net/http" "net/http"
"strconv" "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 { type usersListData struct {
User *models.User User *models.User
Users []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}) 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 { type userFormData struct {
User *models.User User *models.User
EditUser *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()) writeErr(w, http.StatusBadRequest, err.Error())
return return
} }
role := models.RoleUser role := parseRole(r.FormValue("role"))
if r.FormValue("role") == "admin" {
role = models.RoleAdmin
}
u := &models.User{Username: r.FormValue("username"), Role: role, Enabled: r.FormValue("enabled") == "on"} u := &models.User{Username: r.FormValue("username"), Role: role, Enabled: r.FormValue("enabled") == "on"}
password := r.FormValue("password") password := r.FormValue("password")
if password == "" { if password == "" {
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: "Password is required."}) if role != models.RoleContact {
return 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 { 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()}) a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
return 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) { 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 return
} }
u.Username = r.FormValue("username") u.Username = r.FormValue("username")
u.Role = models.RoleUser u.Role = parseRole(r.FormValue("role"))
if r.FormValue("role") == "admin" {
u.Role = models.RoleAdmin
}
u.Enabled = r.FormValue("enabled") == "on" u.Enabled = r.FormValue("enabled") == "on"
if err := a.Users.Update(r.Context(), u); err != nil { 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()}) a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
return 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) { 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) 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) { 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 { if err := a.Users.Delete(r.Context(), id); err != nil {
a.Log.Warn("delete user failed", "error", err) 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 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 { func queryInt64(r *http.Request, name string) int64 {
v := r.URL.Query().Get(name) v := r.URL.Query().Get(name)
if v == "" { if v == "" {
+2 -2
View File
@@ -3,8 +3,8 @@ package web
import ( import (
"net/http" "net/http"
"inventree/internal/auth" "vibetree/internal/auth"
"inventree/internal/models" "vibetree/internal/models"
) )
// loadSession attaches the current user (if any) to the request context // 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 package web
import ( import (
"context"
"fmt" "fmt"
"html/template" "html/template"
"io/fs" "io/fs"
@@ -9,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
content "inventree/web" content "vibetree/web"
) )
// templateFuncs are available to every template (pages and fragments). // 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 // each page can define a block named "content" without colliding with the
// others (html/template templates sharing one *template.Template must have // others (html/template templates sharing one *template.Template must have
// unique names, but isolated clones may reuse the same name freely). // unique names, but isolated clones may reuse the same name freely).
func loadTemplates() (*templateSet, error) { func loadTemplates(a *App) (*templateSet, error) {
shared := template.New("root").Funcs(templateFuncs) // 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") shared, err := shared.ParseFS(content.TemplatesFS, "templates/layout.html")
if err != nil { if err != nil {
@@ -124,7 +138,7 @@ func loadTemplates() (*templateSet, error) {
pages[name] = clone 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") login, err = login.ParseFS(content.TemplatesFS, "templates/login.html")
if err != nil { if err != nil {
return nil, fmt.Errorf("parse login: %w", err) return nil, fmt.Errorf("parse login: %w", err)
+39 -22
View File
@@ -3,10 +3,10 @@ package web
import ( import (
"net/http" "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 // authentication, the AJAX-driven inventory tree, and the traditional
// full-page admin sections (item types, users, settings). // full-page admin sections (item types, users, settings).
func (a *App) Router() http.Handler { 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)) mux.HandleFunc("GET /app/search", a.requireLogin(a.handleSearch))
// Folder mutations (JSON in/out) // 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("GET /app/folders/{id}", a.requireAdmin(a.handleFolderGet))
mux.HandleFunc("POST /app/folders", a.requireAdmin(a.handleFolderCreate)) mux.HandleFunc("POST /app/folders", a.requireAdmin(a.handleFolderCreate))
mux.HandleFunc("PATCH /app/folders/{id}", a.requireAdmin(a.handleFolderUpdate)) 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)) mux.HandleFunc("DELETE /app/folders/{id}", a.requireAdmin(a.handleFolderDelete))
// Asset mutations (JSON in/out) // 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("POST /app/assets", a.requireAdmin(a.handleAssetCreate))
mux.HandleFunc("PATCH /app/assets/{id}", a.requireAdmin(a.handleAssetUpdate)) mux.HandleFunc("PATCH /app/assets/{id}", a.requireAdmin(a.handleAssetUpdate))
mux.HandleFunc("DELETE /app/assets/{id}", a.requireAdmin(a.handleAssetDelete)) 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}/duplicate", a.requireAdmin(a.handleAssetDuplicate))
mux.HandleFunc("POST /app/assets/{id}/move", a.requireAdmin(a.handleAssetMove)) 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 // CSV import/export
mux.HandleFunc("GET /export/assets.csv", a.requireLogin(a.handleExportCSV)) mux.HandleFunc("GET /export/assets.csv", a.requireLogin(a.handleExportCSV))
mux.HandleFunc("POST /import/assets.csv", a.requireAdmin(a.handleImportCSV)) mux.HandleFunc("POST /import/assets.csv", a.requireAdmin(a.handleImportCSV))
// Item types (admin, traditional pages) // QR asset tags: scan a printed label to jump to that asset, or print
mux.HandleFunc("GET /item-types", a.requireAdmin(a.handleItemTypesList)) // a sheet of labels for a folder/asset.
mux.HandleFunc("GET /item-types/new", a.requireAdmin(a.handleItemTypeNewForm)) mux.HandleFunc("GET /scan/{tag}", a.requireLogin(a.handleScan))
mux.HandleFunc("POST /item-types", a.requireAdmin(a.handleItemTypeCreate)) mux.HandleFunc("GET /labels", a.requireLogin(a.handleLabelsPage))
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))
// Users (admin, traditional pages) // Settings (admin, traditional pages) - Item Types, Users, and general
mux.HandleFunc("GET /users", a.requireAdmin(a.handleUsersList)) // settings all live under one unified /settings section with shared
mux.HandleFunc("GET /users/new", a.requireAdmin(a.handleUserNewForm)) // sub-navigation, rather than as separate top-level nav items.
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)
mux.HandleFunc("GET /settings", a.requireAdmin(a.handleSettingsPage)) mux.HandleFunc("GET /settings", a.requireAdmin(a.handleSettingsPage))
mux.HandleFunc("POST /settings/general", a.requireAdmin(a.handleSettingsGeneral)) 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", a.requireAdmin(a.handleStatusCreate))
mux.HandleFunc("POST /settings/statuses/{id}", a.requireAdmin(a.handleStatusUpdate)) 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}/delete", a.requireAdmin(a.handleStatusDelete))
mux.HandleFunc("POST /settings/statuses/{id}/reorder", a.requireAdmin(a.handleStatusReorder)) 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) return a.loadSession(mux)
} }
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"inventree/internal/assets" "vibetree/internal/assets"
"inventree/internal/models" "vibetree/internal/models"
) )
// treeAssetLimit bounds how many assets are rendered directly inside a // treeAssetLimit bounds how many assets are rendered directly inside a
+4 -4
View File
@@ -11,9 +11,9 @@ import (
"strings" "strings"
"testing" "testing"
"inventree/internal/database" "vibetree/internal/database"
"inventree/internal/models" "vibetree/internal/models"
"inventree/internal/web" "vibetree/internal/web"
) )
func testApp(t *testing.T) *web.App { 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) t.Fatalf("expected redirect on login, got %d", res.StatusCode)
} }
for _, c := range res.Cookies() { for _, c := range res.Cookies() {
if c.Name == "inventree_session" { if c.Name == "vibetree_session" {
return c 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. // (Bootstrap CSS/JS, icons, vanilla JS) into the compiled binary.
package content package content
+108 -25
View File
@@ -77,6 +77,15 @@ ul.tree-children {
font-weight: 500; 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 { .tree-row.drop-target {
outline: 2px dashed #0d6efd; outline: 2px dashed #0d6efd;
outline-offset: -2px; outline-offset: -2px;
@@ -129,44 +138,118 @@ ul.tree-children {
flex-shrink: 0; 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 { .tree-node.dragging {
opacity: 0.4; 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; text-align: center;
margin-right: 0.15rem;
} }
.stat-value { [data-context="asset-header"],
font-size: 1.4rem; [data-context="folder-header"] {
font-weight: 600; cursor: default;
line-height: 1.2;
}
.stat-label {
font-size: 0.75rem;
color: #6c757d;
} }
.panel-content { .panel-content {
max-width: 1100px; 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 { .asset-row, .folder-link, .asset-link {
cursor: pointer; cursor: pointer;
} }
+662 -105
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. // 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 () { (function () {
'use strict'; 'use strict';
@@ -7,7 +14,6 @@
var mainPanel = document.getElementById('main-panel'); var mainPanel = document.getElementById('main-panel');
var isAdmin = document.body.classList.contains('role-admin'); var isAdmin = document.body.classList.contains('role-admin');
var draggedNode = null; var draggedNode = null;
var formReturnContext = { type: 'root', id: '' };
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Generic helpers // Generic helpers
@@ -158,7 +164,7 @@
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Tree context menu ("...") // Generic right-click context menu
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
var ctxMenu = null; var ctxMenu = null;
@@ -166,14 +172,8 @@
function ensureCtxMenu() { function ensureCtxMenu() {
if (ctxMenu) return ctxMenu; if (ctxMenu) return ctxMenu;
ctxMenu = document.createElement('div'); ctxMenu = document.createElement('div');
ctxMenu.className = 'dropdown-menu tree-ctx-menu'; ctxMenu.className = 'dropdown-menu ctx-menu';
ctxMenu.style.position = 'fixed';
ctxMenu.style.display = 'none'; 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); document.body.appendChild(ctxMenu);
return ctxMenu; return ctxMenu;
} }
@@ -182,30 +182,360 @@
if (ctxMenu) ctxMenu.style.display = 'none'; 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 menu = ensureCtxMenu();
var rect = btn.getBoundingClientRect(); menu.innerHTML = items.map(function (item) {
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px'; if (item.divider) return '<div class="dropdown-divider"></div>';
menu.style.top = rect.bottom + 'px'; 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.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) { document.addEventListener('click', function (e) {
if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target) && !e.target.closest('.btn-tree-action')) { if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target)) closeCtxMenu();
closeCtxMenu();
}
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);
}
}); });
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); } });
}
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 // Folder create / rename modal
@@ -220,7 +550,7 @@
document.getElementById('folderFormParentId').value = (opts && opts.parentId) || ''; document.getElementById('folderFormParentId').value = (opts && opts.parentId) || '';
document.getElementById('folderFormName').value = ''; document.getElementById('folderFormName').value = '';
document.getElementById('folderFormDescription').value = ''; document.getElementById('folderFormDescription').value = '';
document.getElementById('folderFormIcon').value = ''; setIconPickerValue(document.getElementById('folderFormIcon'), '');
document.getElementById('folderFormSortOrder').value = '0'; document.getElementById('folderFormSortOrder').value = '0';
document.getElementById('folderModalTitle').textContent = 'New Folder'; document.getElementById('folderModalTitle').textContent = 'New Folder';
document.getElementById('folderFormError').classList.add('d-none'); document.getElementById('folderFormError').classList.add('d-none');
@@ -235,7 +565,7 @@
document.getElementById('folderFormParentId').value = ''; document.getElementById('folderFormParentId').value = '';
document.getElementById('folderFormName').value = f.Name; document.getElementById('folderFormName').value = f.Name;
document.getElementById('folderFormDescription').value = f.Description; 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('folderFormSortOrder').value = f.SortOrder;
document.getElementById('folderModalTitle').textContent = 'Rename Folder'; document.getElementById('folderModalTitle').textContent = 'Rename Folder';
document.getElementById('folderFormError').classList.add('d-none'); 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) { async function loadAssetForm(opts) {
formReturnContext = { type: mainPanel.dataset.contextType || 'root', id: mainPanel.dataset.contextId || '' };
var params = new URLSearchParams(); var params = new URLSearchParams();
if (opts && opts.folderId) params.set('folder_id', opts.folderId); if (opts && opts.folderId) params.set('folder_id', opts.folderId);
var qs = params.toString(); var qs = params.toString();
var html = await fetchText('/app/panel/assets/new' + (qs ? '?' + qs : '')); var html = await fetchText('/app/panel/assets/new' + (qs ? '?' + qs : ''));
mainPanel.innerHTML = html; showAssetModal(html);
mainPanel.dataset.contextType = 'asset-form';
mainPanel.dataset.contextId = '';
} }
async function loadAssetEditForm(id) { async function loadAssetEditForm(id) {
var html = await fetchText('/app/panel/assets/' + id + '/edit'); var html = await fetchText('/app/panel/assets/' + id + '/edit');
mainPanel.innerHTML = html; showAssetModal(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);
}
} }
async function submitAssetForm(form) { async function submitAssetForm(form) {
@@ -380,6 +896,8 @@
payload.status_id = Number(value); payload.status_id = Number(value);
} else if (key === 'assigned_user_id') { } else if (key === 'assigned_user_id') {
payload.assigned_user_id = value ? Number(value) : null; payload.assigned_user_id = value ? Number(value) : null;
} else if (key === 'quantity') {
payload.quantity = Number(value) || 0;
} else { } else {
payload[key] = value; payload[key] = value;
} }
@@ -389,6 +907,7 @@
var method = mode === 'edit' ? 'PATCH' : 'POST'; var method = mode === 'edit' ? 'PATCH' : 'POST';
try { try {
var res = await apiFetch(url, { method: method, json: payload }); var res = await apiFetch(url, { method: method, json: payload });
if (assetModal) assetModal.hide();
await refreshWholeTree(); await refreshWholeTree();
await navigateAndSelect('asset', res.id); await navigateAndSelect('asset', res.id);
} catch (err) { } catch (err) {
@@ -407,8 +926,8 @@
} }
} }
async function deleteAsset(id, name) { async function deleteAsset(id) {
if (!confirm('Delete asset "' + name + '"? This cannot be undone.')) return; if (!confirm('Delete this asset? This cannot be undone.')) return;
try { try {
var res = await apiFetch('/app/assets/' + id, { method: 'DELETE' }); var res = await apiFetch('/app/assets/' + id, { method: 'DELETE' });
await refreshWholeTree(); await refreshWholeTree();
@@ -419,20 +938,20 @@
} }
} }
async function confirmMoveAsset(id, selectId) { // adjustAssetQuantity handles the +/- steppers shown next to non-unique
var select = document.getElementById(selectId); // assets (in the asset detail panel and folder asset tables), updating
var val = select.value; // 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 { try {
await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } }); var res = await apiFetch('/app/assets/' + id + '/quantity', { method: 'POST', json: { delta: delta } });
var modalEl = select.closest('.modal'); if (valueEl) valueEl.textContent = res.quantity;
if (modalEl && window.bootstrap) {
var inst = bootstrap.Modal.getInstance(modalEl);
if (inst) inst.hide();
}
await refreshWholeTree();
await navigateAndSelect('asset', id);
} catch (err) { } catch (err) {
alert('Move failed: ' + err.message); alert('Could not update quantity: ' + err.message);
} }
} }
@@ -493,6 +1012,18 @@
// CSV import // 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'); var importFormEl = document.getElementById('importForm');
if (importFormEl) { if (importFormEl) {
importFormEl.addEventListener('submit', async function (e) { importFormEl.addEventListener('submit', async function (e) {
@@ -520,23 +1051,38 @@
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Tree pane: click delegation // Tree pane: click + drag/drop delegation
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
if (treePane) { if (treePane) {
treePane.addEventListener('click', function (e) { treePane.addEventListener('click', function (e) {
if (e.target.closest('#btn-expand-all')) { expandAllHandler(); return; } if (e.target.closest('#btn-expand-all')) { expandAllHandler(); return; }
if (e.target.closest('#btn-collapse-all')) { collapseAllHandler(); 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'); var toggle = e.target.closest('.tree-toggle');
if (toggle) { e.stopPropagation(); toggleFolder(toggle); return; } 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'); var row = e.target.closest('.tree-row');
if (row && row.dataset.type && !row.classList.contains('tree-row-more')) { 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); selectNode(row);
} }
}); });
@@ -606,6 +1152,9 @@
if (mainPanel) { if (mainPanel) {
mainPanel.addEventListener('click', function (e) { 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'); var folderLink = e.target.closest('.folder-link');
if (folderLink) { e.preventDefault(); navigateAndSelect('folder', folderLink.dataset.id); return; } if (folderLink) { e.preventDefault(); navigateAndSelect('folder', folderLink.dataset.id); return; }
@@ -617,42 +1166,9 @@
var pageLink = e.target.closest('.panel-page-link'); var pageLink = e.target.closest('.panel-page-link');
if (pageLink) { e.preventDefault(); loadFolderPanelWithFilters(pageLink.dataset.folderId, { page: pageLink.dataset.page }); return; } 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) { mainPanel.addEventListener('change', 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;
}
var filterForm = e.target.closest('.panel-filter-form'); var filterForm = e.target.closest('.panel-filter-form');
if (filterForm) { if (filterForm) {
loadFolderPanelWithFilters(filterForm.dataset.folderId, { page: 1 }); loadFolderPanelWithFilters(filterForm.dataset.folderId, { page: 1 });
@@ -672,18 +1188,38 @@
if (e.target.matches && e.target.matches('.panel-filter-form')) { if (e.target.matches && e.target.matches('.panel-filter-form')) {
e.preventDefault(); e.preventDefault();
loadFolderPanelWithFilters(e.target.dataset.folderId, { page: 1 }); 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') { if (e.target.id === 'assetForm') {
e.preventDefault(); e.preventDefault();
submitAssetForm(e.target); submitAssetForm(e.target);
} }
}); });
assetModalEl.addEventListener('click', function (e) {
// Assets/folders are also draggable as drop-into-root targets from the var stepBtn = e.target.closest('[data-action="quantity-step"]');
// panel view when browsing (drag onto the tree's root row still works if (!stepBtn) return;
// via the tree pane handler above); dragstart on panel rows is not var input = document.getElementById('assetFormQuantity');
// implemented since the tree is the primary drag surface. 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> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css"> <link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
</head> </head>
<body class="role-{{.User.Role}}"> <body class="role-{{.User.Role}}">
<nav class="navbar navbar-expand-lg app-navbar border-bottom px-3"> <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"> <span class="navbar-brand fw-semibold d-flex align-items-center gap-2 mb-0">
<i class="bi bi-diagram-3-fill"></i> inventree <i class="bi bi-diagram-3-fill"></i> {{siteName}}
</a> </span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navMain"> <div class="collapse navbar-collapse" id="navMain">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="/inventory">Inventory</a></li> <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>
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
@@ -32,6 +27,10 @@
{{if .User.IsAdmin}}<span class="badge text-bg-secondary ms-1">admin</span>{{end}} {{if .User.IsAdmin}}<span class="badge text-bg-secondary ms-1">admin</span>{{end}}
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <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> <li>
<form action="/logout" method="post" class="px-3 py-1"> <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> <button type="submit" class="btn btn-sm btn-outline-secondary w-100">Sign out</button>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css"> <link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
<link rel="stylesheet" href="/static/css/app.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="col-11 col-sm-8 col-md-5 col-lg-4">
<div class="text-center mb-4"> <div class="text-center mb-4">
<div class="display-6"><i class="bi bi-diagram-3-fill"></i></div> <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 class="text-body-secondary small">IT asset inventory</div>
</div> </div>
<div class="card shadow-sm"> <div class="card shadow-sm">
+6 -12
View File
@@ -1,20 +1,14 @@
{{define "content"}} {{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"> <aside id="tree-pane" class="tree-pane border-end d-flex flex-column">
<div class="p-2 border-bottom"> <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> <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;"> <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>
<div class="d-flex gap-1 flex-wrap"> <div class="form-text">Right-click the tree to add, rename, or delete.</div>
<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> </div>
<div class="tree-scroll flex-fill overflow-auto"> <div class="tree-scroll flex-fill overflow-auto">
<ul class="tree list-unstyled mb-0" id="tree-root"> <ul class="tree list-unstyled mb-0" id="tree-root">
@@ -35,7 +29,7 @@
</aside> </aside>
<section id="main-panel" class="main-panel flex-fill overflow-auto" data-context-type="root" data-context-id=""> <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> </section>
</div> </div>
+42 -10
View File
@@ -1,23 +1,41 @@
{{define "content"}} {{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"> <div class="d-flex align-items-center mb-3">
<h4 class="mb-0">{{if .ItemType}}Edit Item Type{{else}}New Item Type{{end}}</h4> <h5 class="mb-0">{{if .ItemType}}Edit Item Type{{else}}New Item Type{{end}}</h5>
<a href="/item-types" class="btn btn-sm btn-outline-secondary ms-auto">Back to Item Types</a> <a href="/settings/item-types" class="btn btn-sm btn-outline-secondary ms-auto">Back to Item Types</a>
</div> </div>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}} {{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body"> <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="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-control" name="name" value="{{if .ItemType}}{{.ItemType.Name}}{{end}}" required> <input type="text" class="form-control" name="name" value="{{if .ItemType}}{{.ItemType.Name}}{{end}}" required>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class)</span></label> <label class="form-label">Icon</label>
<input type="text" class="form-control" name="icon" value="{{if .ItemType}}{{.ItemType.Icon}}{{end}}" placeholder="bi-box-seam"> <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>
<div class="col-12"> <div class="col-12">
<label class="form-label">Description</label> <label class="form-label">Description</label>
@@ -33,10 +51,22 @@
<label class="form-check-label" for="enabled">Enabled</label> <label class="form-check-label" for="enabled">Enabled</label>
</div> </div>
</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> </div>
<button type="submit" class="btn btn-primary mt-3">Save</button> <button type="submit" class="btn btn-primary mt-3">Save</button>
{{if .ItemType}} {{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}} {{end}}
</form> </form>
</div> </div>
@@ -65,7 +95,7 @@
data-options="{{range $i, $o := .Options}}{{if $i}}&#10;{{end}}{{$o}}{{end}}" data-sort-order="{{.SortOrder}}"> data-options="{{range $i, $o := .Options}}{{if $i}}&#10;{{end}}{{$o}}{{end}}" data-sort-order="{{.SortOrder}}">
Edit Edit
</button> </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> <button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form> </form>
</td> </td>
@@ -79,7 +109,7 @@
</div> </div>
<div class="card-body border-top"> <div class="card-body border-top">
<h6 id="fieldFormTitle">Add Field</h6> <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"> <div class="col-md-3">
<label class="form-label small mb-1">Name</label> <label class="form-label small mb-1">Name</label>
<input type="text" class="form-control form-control-sm" name="name" id="fieldFormName" required> <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> <button type="button" class="btn btn-sm btn-link d-none" id="fieldFormCancel">Cancel edit</button>
</div> </div>
</div> </div>
<script>window.itemTypeFieldsBaseAction = "/item-types/{{.ItemType.ID}}/fields";</script> <script>window.itemTypeFieldsBaseAction = "/settings/item-types/{{.ItemType.ID}}/fields";</script>
{{else}} {{else}}
<p class="text-body-secondary">Save the item type first to add custom fields.</p> <p class="text-body-secondary">Save the item type first to add custom fields.</p>
{{end}} {{end}}
</div>
</div>
</div> </div>
{{end}} {{end}}
+30 -22
View File
@@ -1,27 +1,35 @@
{{define "content"}} {{define "content"}}
<div class="container-fluid p-3"> <div class="container-fluid p-3">
<div class="d-flex align-items-center mb-3"> <h4 class="mb-3">Settings</h4>
<h4 class="mb-0">Item Types</h4> <div class="row g-3">
<a href="/item-types/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-plus-lg"></i> New Item Type</a> <div class="col-md-3 col-lg-2">
</div> {{template "settings_nav" "itemtypes"}}
<div class="table-responsive"> </div>
<table class="table table-hover align-middle"> <div class="col-md-9 col-lg-10">
<thead><tr><th>Icon</th><th>Name</th><th>Description</th><th>Custom Fields</th><th>Status</th><th></th></tr></thead> <div class="d-flex align-items-center mb-3">
<tbody> <h5 class="mb-0">Item Types</h5>
{{range .ItemTypes}} <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>
<tr> </div>
<td><i class="bi {{iconOr .Icon "bi-box-seam"}} fs-5"></i></td> <div class="table-responsive">
<td><a href="/item-types/{{.ID}}/edit">{{.Name}}</a></td> <table class="table table-hover align-middle">
<td class="text-body-secondary">{{.Description}}</td> <thead><tr><th>Icon</th><th>Name</th><th>Description</th><th>Custom Fields</th><th>Status</th><th></th></tr></thead>
<td>{{len .Fields}}</td> <tbody>
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td> {{range .ItemTypes}}
<td class="text-end"><a href="/item-types/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td> <tr>
</tr> <td><i class="bi {{iconOr .Icon "bi-box-seam"}} fs-5"></i></td>
{{else}} <td><a href="/settings/item-types/{{.ID}}/edit">{{.Name}}</a></td>
<tr><td colspan="6" class="text-body-secondary">No item types defined yet.</td></tr> <td class="text-body-secondary">{{.Description}}</td>
{{end}} <td>{{len .Fields}}</td>
</tbody> <td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
</table> <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>
{{end}}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
{{end}} {{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}}
+27 -77
View File
@@ -1,85 +1,35 @@
{{define "content"}} {{define "content"}}
<div class="container-fluid p-3" style="max-width: 800px"> <div class="container-fluid p-3">
<h4 class="mb-3">Settings</h4> <h4 class="mb-3">Settings</h4>
<div class="row g-3">
<div class="card mb-4"> <div class="col-md-3 col-lg-2">
<div class="card-header py-2 fw-semibold">General</div> {{template "settings_nav" "general"}}
<div class="card-body">
<form method="post" action="/settings/general" class="row g-3">
<div class="col-md-6">
<label class="form-label">Site Name</label>
<input type="text" class="form-control" name="site_name" value="{{.SiteName}}">
</div>
<div class="col-md-3">
<label class="form-label">Asset ID Prefix</label>
<input type="text" class="form-control" name="asset_id_prefix" value="{{.AssetPrefix}}">
</div>
<div class="col-md-3">
<label class="form-label">Asset ID Padding</label>
<input type="number" class="form-control" name="asset_id_padding" value="{{.AssetPadding}}" min="1" max="12">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div> </div>
</div> <div class="col-md-9 col-lg-10" style="max-width: 800px">
<div class="card"> <div class="card">
<div class="card-header py-2 fw-semibold">Statuses</div> <div class="card-header py-2 fw-semibold">General</div>
<div class="table-responsive"> <div class="card-body">
<table class="table mb-0 align-middle"> <form method="post" action="/settings/general" class="row g-3">
<thead><tr><th>Order</th><th>Preview</th><th>Name &amp; Color</th><th></th></tr></thead> <div class="col-md-6">
<tbody> <label class="form-label">Site Name</label>
{{range $i, $st := .Statuses}} <input type="text" class="form-control" name="site_name" value="{{.SiteName}}">
<tr> </div>
<td class="text-nowrap"> <div class="col-md-3">
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline"> <label class="form-label">Asset ID Prefix</label>
<input type="hidden" name="dir" value="up"> <input type="text" class="form-control" name="asset_id_prefix" value="{{.AssetPrefix}}">
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq $i 0}}disabled{{end}}><i class="bi bi-arrow-up"></i></button> </div>
</form> <div class="col-md-3">
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline"> <label class="form-label">Asset ID Padding</label>
<input type="hidden" name="dir" value="down"> <input type="number" class="form-control" name="asset_id_padding" value="{{.AssetPadding}}" min="1" max="12">
<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> </div>
</form> <div class="col-12">
</td> <button type="submit" class="btn btn-primary">Save</button>
<td><span class="badge text-bg-{{$st.Color}}">{{$st.Name}}</span></td> </div>
<td> </form>
<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>
<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> </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}}
+61 -51
View File
@@ -1,61 +1,71 @@
{{define "content"}} {{define "content"}}
<div class="container-fluid p-3" style="max-width: 600px"> <div class="container-fluid p-3">
<div class="d-flex align-items-center mb-3"> <h4 class="mb-3">Settings</h4>
<h4 class="mb-0">{{if .EditUser}}Edit User{{else}}New User{{end}}</h4> <div class="row g-3">
<a href="/users" class="btn btn-sm btn-outline-secondary ms-auto">Back to Users</a> <div class="col-md-3 col-lg-2">
</div> {{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">
<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}} {{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <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"> <div class="mb-3">
<label class="form-label">Username</label> <label class="form-label">Username</label>
<input type="text" class="form-control" name="username" value="{{if .EditUser}}{{.EditUser.Username}}{{end}}" required autofocus> <input type="text" class="form-control" name="username" value="{{if .EditUser}}{{.EditUser.Username}}{{end}}" required autofocus>
</div>
{{if not .EditUser}}
<div class="mb-3">
<label class="form-label">Password</label>
<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">
<label class="form-label">Role</label>
<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">
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if .EditUser}}{{if .EditUser.Enabled}}checked{{end}}{{else}}checked{{end}}>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
{{if .EditUser}}
<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 not .EditUser}} </div>
<div class="mb-3">
<label class="form-label">Password</label> {{if and .EditUser .EditUser.CanLogin}}
<input type="password" class="form-control" name="password" required autocomplete="new-password"> <div class="card">
<div class="card-header py-2 fw-semibold">Change Password</div>
<div class="card-body">
<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">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">Set Password</button>
</div>
</form>
</div> </div>
{{end}} </div>
<div class="mb-3">
<label class="form-label">Role</label>
<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>
</select>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if .EditUser}}{{if .EditUser.Enabled}}checked{{end}}{{else}}checked{{end}}>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
<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?')">
<button type="submit" class="btn btn-outline-danger">Delete User</button>
</form>
{{end}} {{end}}
</div> </div>
</div> </div>
{{if .EditUser}}
<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">
<div class="col">
<label class="form-label">New Password</label>
<input type="password" class="form-control" name="password" autocomplete="new-password">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">Set Password</button>
</div>
</form>
</div>
</div>
{{end}}
</div> </div>
{{end}} {{end}}
+31 -19
View File
@@ -1,24 +1,36 @@
{{define "content"}} {{define "content"}}
<div class="container-fluid p-3"> <div class="container-fluid p-3">
<div class="d-flex align-items-center mb-3"> <h4 class="mb-3">Settings</h4>
<h4 class="mb-0">Users</h4> <div class="row g-3">
<a href="/users/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-person-plus"></i> New User</a> <div class="col-md-3 col-lg-2">
</div> {{template "settings_nav" "users"}}
<div class="table-responsive"> </div>
<table class="table table-hover align-middle"> <div class="col-md-9 col-lg-10">
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead> <div class="d-flex align-items-center mb-3">
<tbody> <h5 class="mb-0">Users</h5>
{{range .Users}} <a href="/settings/users/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-person-plus"></i> New User</a>
<tr> </div>
<td><a href="/users/{{.ID}}/edit">{{.Username}}</a></td> <div class="table-responsive">
<td>{{if .IsAdmin}}<span class="badge text-bg-primary">Admin</span>{{else}}<span class="badge text-bg-secondary">User</span>{{end}}</td> <table class="table table-hover align-middle">
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td> <thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
<td>{{fmtDateShort .CreatedAt}}</td> <tbody>
<td class="text-end"><a href="/users/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td> {{range .Users}}
</tr> <tr>
{{end}} <td><a href="/settings/users/{{.ID}}/edit">{{.Username}}</a></td>
</tbody> <td>
</table> {{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="/settings/users/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
{{end}} {{end}}
+15 -2
View File
@@ -1,7 +1,20 @@
{{define "asset_fields_only"}} {{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> <h6 class="mt-3 mb-2">Type-Specific Fields</h6>
{{range .}} {{range .Fields}}
{{$fv := .}} {{$fv := .}}
<div class="mb-2"> <div class="mb-2">
<label class="form-label" for="field-{{.Def.ID}}">{{.Def.Name}}{{if .Def.Required}} <span class="text-danger">*</span>{{end}}</label> <label class="form-label" for="field-{{.Def.ID}}">{{.Def.Name}}{{if .Def.Required}} <span class="text-danger">*</span>{{end}}</label>
+62 -74
View File
@@ -1,81 +1,69 @@
{{define "asset_form"}} {{define "asset_form"}}
<div class="panel-content p-3"> <form id="assetForm" data-mode="{{if .Asset}}edit{{else}}create{{end}}" {{if .Asset}}data-asset-id="{{.Asset.ID}}"{{end}}
<nav aria-label="breadcrumb"> data-title="{{if .Asset}}Edit {{.Asset.Name}}{{else}}New Asset{{end}}">
<ol class="breadcrumb mb-2"> <div class="row g-3">
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li> <div class="col-md-6">
<li class="breadcrumb-item active">{{if .Asset}}Edit {{.Asset.Name}}{{else}}New Asset{{end}}</li> <label class="form-label">Asset ID</label>
</ol> <input type="text" class="form-control" name="asset_id" value="{{if .Asset}}{{.Asset.AssetTag}}{{end}}" placeholder="Auto-generated if left blank">
</nav> </div>
<div class="col-md-6">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" value="{{if .Asset}}{{.Asset.Name}}{{end}}" required autofocus>
</div>
<form id="assetForm" data-mode="{{if .Asset}}edit{{else}}create{{end}}" {{if .Asset}}data-asset-id="{{.Asset.ID}}"{{end}}> <div class="col-md-6">
<div class="row g-3"> <label class="form-label">Item Type <span class="text-danger">*</span></label>
<div class="col-md-6"> {{if .Asset}}
<label class="form-label">Asset ID</label> <input type="text" class="form-control" value="{{.Asset.ItemTypeName}}" disabled>
<input type="text" class="form-control" name="asset_id" value="{{if .Asset}}{{.Asset.AssetTag}}{{end}}" placeholder="Auto-generated if left blank"> <div class="form-text">Item type cannot be changed after creation.</div>
</div> {{else}}
<div class="col-md-6"> <select class="form-select" name="item_type_id" id="assetFormItemType" required>
<label class="form-label">Name <span class="text-danger">*</span></label> {{range .ItemTypes}}<option value="{{.ID}}" {{if eq .ID $.SelectedIT}}selected{{end}}>{{.Name}}</option>{{end}}
<input type="text" class="form-control" name="name" value="{{if .Asset}}{{.Asset.Name}}{{end}}" required autofocus> </select>
</div> {{end}}
</div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Item Type <span class="text-danger">*</span></label> <label class="form-label">Folder</label>
{{if .Asset}} {{$selectedFolder := deref .FolderID}}
<input type="text" class="form-control" value="{{.Asset.ItemTypeName}}" disabled> {{if .Asset}}{{$selectedFolder = deref .Asset.FolderID}}{{end}}
<div class="form-text">Item type cannot be changed after creation.</div> <select class="form-select" name="folder_id">
{{else}} <option value="">Inventory (top level)</option>
<select class="form-select" name="item_type_id" id="assetFormItemType" required> {{range .FolderOptions}}
{{range .ItemTypes}}<option value="{{.ID}}" {{if eq .ID $.SelectedIT}}selected{{end}}>{{.Name}}</option>{{end}} <option value="{{.ID}}" {{if eq $selectedFolder .ID}}selected{{end}}>
</select> {{range seq .Depth}}&nbsp;&nbsp;&nbsp;{{end}}{{.Path}}
</option>
{{end}} {{end}}
</div> </select>
<div class="col-md-6">
<label class="form-label">Folder</label>
{{$selectedFolder := deref .FolderID}}
{{if .Asset}}{{$selectedFolder = deref .Asset.FolderID}}{{end}}
<select class="form-select" name="folder_id">
<option value="">Inventory (top level)</option>
{{range .FolderOptions}}
<option value="{{.ID}}" {{if eq $selectedFolder .ID}}selected{{end}}>
{{range seq .Depth}}&nbsp;&nbsp;&nbsp;{{end}}{{.Path}}
</option>
{{end}}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" name="status_id" required>
{{range .Statuses}}<option value="{{.ID}}" {{if $.Asset}}{{if eq .ID $.Asset.StatusID}}selected{{end}}{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Assigned User</label>
{{$selectedUser := 0}}
{{if .Asset}}{{$selectedUser = deref .Asset.AssignedUserID}}{{end}}
<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>
{{end}}
</select>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="3">{{if .Asset}}{{.Asset.Notes}}{{end}}</textarea>
</div>
<div class="col-12" id="asset-fields-container">
{{template "asset_fields_only" .Fields}}
</div>
</div> </div>
<div class="d-flex gap-2 mt-4"> <div class="col-md-6">
<button type="submit" class="btn btn-primary">{{if .Asset}}Save Changes{{else}}Create Asset{{end}}</button> <label class="form-label">Status <span class="text-danger">*</span></label>
<button type="button" class="btn btn-outline-secondary" data-action="cancel-asset-form" {{if .Asset}}data-id="{{.Asset.ID}}"{{end}}>Cancel</button> <select class="form-select" name="status_id" required>
{{range .Statuses}}<option value="{{.ID}}" {{if $.Asset}}{{if eq .ID $.Asset.StatusID}}selected{{end}}{{end}}>{{.Name}}</option>{{end}}
</select>
</div> </div>
<div class="alert alert-danger mt-3 d-none" id="assetFormError"></div> <div class="col-md-6">
</form> <label class="form-label">Assigned User</label>
</div> {{$selectedUser := 0}}
{{if .Asset}}{{$selectedUser = deref .Asset.AssignedUserID}}{{end}}
<select class="form-select" name="assigned_user_id">
<option value="">Unassigned</option>
{{range .Users}}
<option value="{{.ID}}" {{if eq $selectedUser .ID}}selected{{end}}>{{.Username}}{{if eq .Role "contact"}} (Contact){{end}}</option>
{{end}}
</select>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="3">{{if .Asset}}{{.Asset.Notes}}{{end}}</textarea>
</div>
<div class="col-12" id="asset-fields-container">
{{template "asset_fields_only" (dict "IsUnique" .SelectedITUnique "Quantity" .Quantity "Fields" .Fields)}}
</div>
</div>
<div class="alert alert-danger mt-3 d-none" id="assetFormError"></div>
</form>
{{end}} {{end}}
+1 -1
View File
@@ -5,7 +5,7 @@
{{$last := sub (len .) 1}} {{$last := sub (len .) 1}}
{{range $i, $b := .}} {{range $i, $b := .}}
{{if eq $i $last}} {{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}} {{else}}
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{$b.ID}}">{{$b.Name}}</a></li> <li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{$b.ID}}">{{$b.Name}}</a></li>
{{end}} {{end}}
+104 -9
View File
@@ -22,8 +22,20 @@
</div> </div>
<div class="row g-2"> <div class="row g-2">
<div class="col"> <div class="col">
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class, optional)</span></label> <label class="form-label">Icon <span class="text-body-secondary">(optional)</span></label>
<input type="text" class="form-control" name="icon" id="folderFormIcon" placeholder="bi-folder-fill"> <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>
<div class="col-4"> <div class="col-4">
<label class="form-label">Sort Order</label> <label class="form-label">Sort Order</label>
@@ -77,26 +89,109 @@
</div> </div>
</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 fade" id="importModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<form id="importForm"> <form id="importForm">
<div class="modal-header"> <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> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="folder_id" id="importFolderId">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">CSV File</label> <label class="form-label">CSV File</label>
<input type="file" class="form-control" name="file" id="importFile" accept=".csv" required> <input type="file" class="form-control" name="file" id="importFile" accept=".csv" required>
</div> </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"> <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> </p>
<div id="importResult" class="mt-3"></div> <div id="importResult" class="mt-3"></div>
</div> </div>
+23 -47
View File
@@ -1,26 +1,16 @@
{{define "panel_asset"}} {{define "panel_asset"}}
<div class="panel-content p-3" data-asset-id="{{.Asset.ID}}"> <div class="panel-content p-3" data-asset-id="{{.Asset.ID}}">
<div class="d-flex align-items-start mb-2"> <nav aria-label="breadcrumb">
<nav aria-label="breadcrumb"> <ol class="breadcrumb mb-0">
<ol class="breadcrumb mb-0"> <li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li> {{range .Asset.FolderPath}}
{{range .Asset.FolderPath}} <li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{.ID}}">{{.Name}}</a></li>
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{.ID}}">{{.Name}}</a></li> {{end}}
{{end}} <li class="breadcrumb-item active" aria-current="page">{{.Asset.Name}}</li>
<li class="breadcrumb-item active" aria-current="page">{{.Asset.Name}}</li> </ol>
</ol> </nav>
</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> <i class="bi {{iconOr .Asset.ItemTypeIcon "bi-box-seam"}} fs-3 text-body-secondary"></i>
<div> <div>
<h4 class="mb-0">{{.Asset.Name}}</h4> <h4 class="mb-0">{{.Asset.Name}}</h4>
@@ -35,7 +25,19 @@
<div class="card-header py-2 fw-semibold">Details</div> <div class="card-header py-2 fw-semibold">Details</div>
<table class="table table-sm mb-0"> <table class="table table-sm mb-0">
<tbody> <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>Created</th><td>{{fmtDate .Asset.CreatedAt}}</td></tr>
<tr><th>Updated</th><td>{{fmtDate .Asset.UpdatedAt}}</td></tr> <tr><th>Updated</th><td>{{fmtDate .Asset.UpdatedAt}}</td></tr>
</tbody> </tbody>
@@ -75,30 +77,4 @@
</div> </div>
</div> </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}} {{end}}
+17 -16
View File
@@ -1,15 +1,10 @@
{{define "panel_folder"}} {{define "panel_folder"}}
<div class="panel-content p-3" data-folder-id="{{.Folder.ID}}"> <div class="panel-content p-3" data-panel-type="folder" data-folder-id="{{.Folder.ID}}">
<div class="d-flex align-items-start mb-2"> {{template "breadcrumb" .Breadcrumb}}
{{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 .Folder.Description}}<p class="text-body-secondary">{{.Folder.Description}}</p>{{end}}
{{if .Subfolders}} {{if .Subfolders}}
@@ -38,22 +33,28 @@
</select> </select>
<input type="search" class="form-control form-control-sm" name="q" value="{{.Query}}" placeholder="Filter assets&hellip;" style="width:160px"> <input type="search" class="form-control form-control-sm" name="q" value="{{.Query}}" placeholder="Filter assets&hellip;" style="width:160px">
</form> </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> </div>
{{if .Assets}} {{if .Assets}}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover asset-table"> <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> <tbody>
{{range .Assets}} {{range .Assets}}
<tr class="asset-row" data-id="{{.ID}}"> <tr class="asset-row" data-id="{{.ID}}">
<td>{{.AssetTag}}</td> <td>{{.AssetTag}}</td>
<td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td> <td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td>
<td>{{.ItemTypeName}}</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><span class="badge text-bg-{{.StatusColor}}">{{.StatusName}}</span></td>
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">&mdash;</span>{{end}}</td> <td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">&mdash;</span>{{end}}</td>
</tr> </tr>
@@ -74,7 +75,7 @@
</nav> </nav>
{{end}} {{end}}
{{else}} {{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}} {{end}}
</div> </div>
{{end}} {{end}}
+2 -106
View File
@@ -1,109 +1,5 @@
{{define "panel_root"}} {{define "panel_root"}}
<div class="panel-content p-3"> <div class="panel-content panel-blank p-3 h-100 d-flex align-items-center justify-content-center" data-panel-type="root">
<nav aria-label="breadcrumb"> <p class="text-body-secondary mb-0">Select a folder or asset from the tree.</p>
<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> </div>
{{end}} {{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}} {{end}}
<i class="bi {{iconOr .Icon "bi-folder-fill"}} tree-icon text-warning-emphasis"></i> <i class="bi {{iconOr .Icon "bi-folder-fill"}} tree-icon text-warning-emphasis"></i>
<span class="tree-label">{{.Name}}</span> <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> </div>
<ul class="tree-children list-unstyled{{if not .Expanded}} d-none{{end}}" data-expanded="{{.Expanded}}" data-loaded="{{.Expanded}}"> <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}} {{if .Expanded}}{{template "tree_children" .Children}}{{end}}