Compare commits
10
Commits
218687a1ee
...
f3a959334f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a959334f | ||
|
|
14e2f3bb77 | ||
|
|
4e4313723e | ||
|
|
e86ffe765c | ||
|
|
fb85c9edbb | ||
|
|
42e7b2a21c | ||
|
|
8d9106542c | ||
|
|
4d7ebed68e | ||
|
|
62bdcb2428 | ||
|
|
4ceb1cbc03 |
+1
-1
@@ -3,4 +3,4 @@ data/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
.git/
|
.git/
|
||||||
inventree
|
vibetree
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/inventree
|
/vibetree
|
||||||
/data/
|
/data/
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|||||||
+7
-7
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"inventree/internal/models"
|
"vibetree/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"inventree/internal/models"
|
"vibetree/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
+11
-4
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"inventree/internal/auth"
|
"vibetree/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type loginPageData struct {
|
type loginPageData struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"inventree/internal/models"
|
"vibetree/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SearchPanelData struct {
|
type SearchPanelData struct {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 += ' ';
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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…">
|
<input type="search" id="tree-search" class="form-control" placeholder="Search inventory…">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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…</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…">
|
||||||
|
<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 — 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}} {{end}}{{$o}}{{end}}" data-sort-order="{{.SortOrder}}">
|
data-options="{{range $i, $o := .Options}}{{if $i}} {{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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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…</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_<field_key></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 & 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}}
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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">−</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>
|
||||||
|
|||||||
@@ -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}} {{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}} {{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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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…</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…">
|
||||||
|
<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_<field_key></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_<field_key></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>
|
||||||
|
|||||||
@@ -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 — 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">−</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}} {{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}}
|
||||||
|
|||||||
@@ -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…" style="width:160px">
|
<input type="search" class="form-control form-control-sm" name="q" value="{{.Query}}" placeholder="Filter assets…" 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">—</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">−</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">—</span>{{end}}</td>
|
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">—</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}}
|
||||||
|
|||||||
@@ -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">—</span>{{end}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user