Add custom fields + warranty plugin, rework settings into two-pane shell

Feature work from the 2026-07 session:

Settings IA
- Replace the flat 27-card settings hub with a persistent two-pane shell
  (SettingsLayout.vue): grouped, searchable left rail + content pane.
- Nest all settings/* routes under the shell via router post-processing;
  shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers,
  Equipment, Network) so per-type settings stop scattering.

Custom fields (core)
- customfields + customfieldvalues tables (migration 7d14), CRUD API at
  /api/customfields, per-asset value get/save.
- Settings management page + reusable CustomFieldsSection (detail) and
  CustomFieldsInputs (form) wired into all four asset types.

Warranty (new plugin)
- plugins/warranty: warranties + warrantyassets (migration 7d15), derived
  coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs).
- API CRUD + per-asset panel + report buckets; WarrantyPanel on all four
  detail pages; Warranties management page; Warranty report + Reports card.
- Seed warranty.* permissions.

Printer drivers
- printerdrivers table (migration 7d13) linked to printer models; drivers now
  surface on the matching printer's detail page.

Other
- PCDetail rebalanced (Network + Status + Warranty + custom fields on the right).
- Rename PCs list "Features" column to "Remote Access"; fix badge hover underline.
- Drop equipment islocationonly field.
- Centralize asset-type label/route maps into utils/assetTypes.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 15:37:21 -04:00
parent 419f26107d
commit 78a0ee8d83
154 changed files with 9479 additions and 1098 deletions

View File

@@ -22,9 +22,11 @@ JWT_SECRET_KEY=change-this-to-another-secure-random-string
# ---- Database (required) ----
# Format: mysql+pymysql://<user>:<password>@<host>:<port>/<database>
# Format: mysql+pymysql://<user>:<password>@<host>:<port>/<database>?charset=utf8mb4
# In docker-compose, host is `db` (the service name).
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask
# The ?charset=utf8mb4 keeps the connection on utf8mb4; create the database as
# utf8mb4 too (see docs/DEPLOY.md). Both must be utf8mb4 to match the schema.
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8mb4
# ---- CORS (required, no wildcards in production) ----
@@ -67,3 +69,13 @@ ZABBIX_TOKEN=
# EMPLOYEE_DB_USER=
# EMPLOYEE_DB_PASSWORD=
# EMPLOYEE_DB_NAME=wjf_employees
# ---- cmmc_usb database (USB check-in/out) ----
# Separate read-write MySQL DB used by the USB plugin to track device
# check-in/out, lockers, and the check-in/out log. Leave unset if the feature
# is not used; there is no safe default for the password, so an unset password
# fails loud.
# CMMC_USB_DB_HOST=
# CMMC_USB_DB_USER=
# CMMC_USB_DB_PASSWORD=
# CMMC_USB_DB_NAME=cmmc_usb

67
deploy/windows/web.config Normal file
View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
IIS site config for shopdb-flask via HttpPlatformHandler.
IIS launches waitress (a Windows-friendly WSGI server; gunicorn does NOT run
on Windows) and forwards requests to it on a private loopback port that IIS
assigns via %HTTP_PLATFORM_PORT%. One process serves both /api and the built
Vue SPA (frontend/dist), so no separate static site is needed.
Prerequisites on the box:
- HttpPlatformHandler IIS module installed
(https://www.iis.net/downloads/microsoft/httpplatformhandler)
- URL Rewrite module installed (only for the optional X-Forwarded-For rule)
- Python 3.12 + a venv at APP_ROOT\venv with requirements.txt + waitress
- Secrets live in APP_ROOT\.env (wsgi.py load_dotenv() reads it). Keep them
OUT of this file. Lock .env ACLs to the app pool identity + admins.
Replace APP_ROOT (C:\shopdb-flask below) with the real deploy path. The IIS
site's physical path MUST be APP_ROOT (where wsgi.py lives).
-->
<configuration>
<system.webServer>
<handlers>
<add name="httpplatformhandler" path="*" verb="*"
modules="httpPlatformHandler" resourceType="Unspecified" />
</handlers>
<httpPlatform
processPath="C:\shopdb-flask\venv\Scripts\waitress-serve.exe"
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 wsgi:app"
stdoutLogEnabled="true"
stdoutLogFile="C:\shopdb-flask\logs\httpplatform"
startupTimeLimit="120"
startupRetryCount="3">
<environmentVariables>
<!-- FLASK_ENV MUST be production here or wsgi.py defaults to the dev
config (SQL echo, debug, wrong DB URL). Real secrets go in .env. -->
<environmentVariable name="FLASK_ENV" value="production" />
<environmentVariable name="PYTHONPATH" value="C:\shopdb-flask" />
</environmentVariables>
</httpPlatform>
<!--
OPTIONAL: forward the real client IP so audit logs and the kiosk
visitor-location feature (IP -> business unit) see the caller, not the
loopback that HttpPlatformHandler connects from. Needs URL Rewrite.
Delete this whole <rewrite> block if URL Rewrite is not installed;
audit logs will then record 127.0.0.1 for a test instance.
-->
<rewrite>
<allowedServerVariables>
<add name="HTTP_X_FORWARDED_FOR" />
</allowedServerVariables>
<rules>
<rule name="Set X-Forwarded-For" stopProcessing="false">
<match url=".*" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
</serverVariables>
<action type="None" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>

View File

@@ -18,6 +18,9 @@
services:
db:
image: mysql:8.0
# utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the
# image default. Keeps every site's schema on the same charset/collation.
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
@@ -42,7 +45,7 @@ services:
condition: service_healthy
environment:
FLASK_ENV: production
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}

180
docs/DEPLOY-WINDOWS-IIS.md Normal file
View File

@@ -0,0 +1,180 @@
# Deploy shopdb-flask to Windows IIS (MySQL 5.6)
Runbook for standing up a single-site instance on the production Windows Server
that already runs the classic ASP shopdb, using IIS + HttpPlatformHandler +
waitress, against the existing MySQL 5.6. This is the test-instance path; keep
developing on the Linux dev box and redeploy as needed.
The Docker path in `DEPLOY.md` does NOT apply on Windows (gunicorn is Linux
only, and there is no MySQL container here). This file replaces it for IIS.
Notation: `APP_ROOT` = the deploy folder, e.g. `C:\shopdb-flask`. The IIS site
physical path must be `APP_ROOT` (where `wsgi.py` lives).
## 0. Prerequisites on the box
- Python 3.12 (same minor as dev). `py -3.12 --version` to confirm.
- IIS with the **HttpPlatformHandler** module:
https://www.iis.net/downloads/microsoft/httpplatformhandler
- **URL Rewrite** module (only for the optional real-client-IP rule).
- Network access to the MySQL 5.6 server.
- If the box is air-gapped, you cannot `pip install` live. On the dev box run
`pip download -r requirements.txt waitress -d wheels\` (on a matching
Windows/Python target, or use `--platform` wheels), copy `wheels\` over, and
install with `pip install --no-index --find-links wheels\ ...`.
## 1. Copy the code
Copy the repo to `APP_ROOT`, INCLUDING `frontend/dist` (the built SPA the API
serves). Build it on dev first if stale:
```bash
# on the dev box
cd frontend && npm run build # produces frontend/dist
```
Ship `frontend/dist` with the code (Node is not needed on the prod box).
## 2. Python venv + dependencies
```powershell
cd C:\shopdb-flask
py -3.12 -m venv venv
venv\Scripts\python -m pip install --upgrade pip
venv\Scripts\pip install -r requirements.txt
venv\Scripts\pip install waitress
```
The DB driver is `pymysql` (pure Python) so no C compiler / MySQL client libs
are needed. `waitress` is the WSGI server (installed separately, same as the
Docker image installs gunicorn separately).
## 3. Prepare MySQL 5.6 (the utf8mb4 gotcha)
MySQL 5.6 defaults cannot index utf8mb4 VARCHAR(255) columns (767-byte prefix
limit) and often defaults the server charset to latin1. The schema is utf8mb4,
so the server needs Barracuda + large-prefix, made durable in `my.ini` under
`[mysqld]`, then restart the MySQL service:
```ini
[mysqld]
innodb_file_per_table = 1
innodb_file_format = Barracuda
innodb_large_prefix = 1
```
Then create the database as utf8mb4 and a least-privilege app user:
```sql
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'shopdb'@'%' IDENTIFIED BY 'CHANGE_ME';
GRANT ALL PRIVILEGES ON shopdb_flask.* TO 'shopdb'@'%';
FLUSH PRIVILEGES;
```
Without the `[mysqld]` flags, `flask db upgrade` fails with error 1071
("Specified key was too long"). The migration chain emits `ROW_FORMAT=DYNAMIC`
per table (see `migrations/env.py`), which fits the 3072-byte prefix those
flags unlock.
## 4. Configure secrets and connection (.env)
Create `APP_ROOT\.env` (loaded by `wsgi.py` via `load_dotenv()`). Keep secrets
here, not in `web.config`. Lock the file's ACLs to the IIS app-pool identity +
administrators.
```
FLASK_ENV=production
SECRET_KEY=<64+ random chars>
JWT_SECRET_KEY=<another 64+ random chars>
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@<mysql-host>:3306/shopdb_flask?charset=utf8mb4
CORS_ORIGINS=https://<the site's own hostname>
```
`ProductionConfig.validate()` refuses to boot if any of `SECRET_KEY`,
`JWT_SECRET_KEY`, `DATABASE_URL`, `CORS_ORIGINS` is missing or left at a dev
default. `CORS_ORIGINS` is the browser origin users hit (the IIS binding).
Generate a key: `venv\Scripts\python -c "import secrets;print(secrets.token_urlsafe(64))"`.
## 5. Initialize schema, data, plugins, admin
Run from `APP_ROOT` with the venv active and `.env` present:
```powershell
$env:FLASK_APP="shopdb"
venv\Scripts\flask db upgrade
venv\Scripts\flask seed reference-data
# Enable the plugins this site tracks (registry lives in the gitignored
# instance/plugins.json, so a fresh box starts with none enabled):
venv\Scripts\flask plugin list
venv\Scripts\flask plugin install computers
venv\Scripts\flask plugin install equipment
venv\Scripts\flask plugin install network
venv\Scripts\flask plugin install notifications
venv\Scripts\flask plugin install printers
venv\Scripts\flask plugin install usb
venv\Scripts\flask plugin install knowledgebase
venv\Scripts\flask plugin install slides
venv\Scripts\flask plugin install employees
# First admin (password is generated and printed once):
venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example.com
```
(Alternatively copy the dev box's `instance/plugins.json` to `APP_ROOT\instance\`
to reproduce the exact enabled set, then just run `flask plugin upgrade-all`.)
## 6. Create the IIS site + web.config
1. In IIS Manager, add a new **Site** (separate from the classic ASP site):
- Physical path: `APP_ROOT`
- Binding: a free port or a dedicated hostname (e.g. `https` 443 with the
facility cert, or `http` on a test port like 8081 to start).
- App pool: No Managed Code, and an identity that can read `APP_ROOT`.
2. Copy `deploy\windows\web.config` to `APP_ROOT\web.config` and edit the paths
(`C:\shopdb-flask` -> your `APP_ROOT`). It launches
`waitress-serve --port=%HTTP_PLATFORM_PORT% wsgi:app` and sets
`FLASK_ENV=production` + `PYTHONPATH`.
3. Create `APP_ROOT\logs` for the HttpPlatform stdout log.
4. Recycle the app pool / restart the site.
TLS terminates at the IIS binding. The optional URL Rewrite rule in the
web.config sets `X-Forwarded-For` to the real client IP (HttpPlatformHandler
otherwise forwards from loopback, so audit logs and the kiosk visitor-location
feature would see 127.0.0.1). Drop that block if URL Rewrite is not installed.
## 7. Smoke test
```powershell
# SPA loads:
curl.exe -k https://<host>/ # returns index.html
# API rejects an empty login with a validation error (health signal):
curl.exe -k -X POST https://<host>/api/auth/login -H "Content-Type: application/json" -d "{}"
# expect JSON containing VALIDATION_ERROR
```
Then log in through the browser as the admin from step 5 and confirm the
dashboard renders.
## 8. Redeploying as dev advances
Because this is a test instance you keep iterating on:
1. Pull/copy new code to `APP_ROOT` (rebuild `frontend/dist` on dev if the UI
changed).
2. `venv\Scripts\pip install -r requirements.txt` (if deps changed).
3. `venv\Scripts\flask db upgrade` (if new migrations).
4. Recycle the app pool.
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Site 502 / process won't start | Check `APP_ROOT\logs\httpplatform*`. Usually a bad `processPath`, missing waitress, or `wsgi:app` not importable (set `PYTHONPATH`). |
| Boots but SQL echoes / debug on | `FLASK_ENV` not `production` (web.config env var or `.env`). |
| `flask db upgrade` error 1071 | MySQL 5.6 `[mysqld]` flags in step 3 not applied / server not restarted. |
| ConfigError on boot | A required var (SECRET_KEY / JWT_SECRET_KEY / DATABASE_URL / CORS_ORIGINS) missing or left at a dev default in `.env`. |
| Login works, CORS errors in browser | `CORS_ORIGINS` does not match the exact origin (scheme + host + port) the browser used. |
| Audit logs show 127.0.0.1 | Expected without the URL Rewrite X-Forwarded-For rule (step 6). |

View File

@@ -59,6 +59,14 @@ docker compose exec api flask db upgrade
This applies the baseline migration (creates all tables) and any subsequent migrations. Re-running is idempotent.
**Charset:** the schema is utf8mb4 (`utf8mb4_unicode_ci`). The docker-compose `db` service sets `--character-set-server=utf8mb4`, so the auto-created `shopdb_flask` database is utf8mb4. If you point at an external MySQL instead of the bundled container, create the database as utf8mb4 first, or it inherits the server default (often latin1) and the schema silently drifts:
```sql
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
`DATABASE_URL` must keep `?charset=utf8mb4` so the connection matches. On MySQL older than 5.7 also enable `innodb_large_prefix=ON` + `innodb_file_format=Barracuda`, or the utf8mb4 indexes exceed the 767-byte prefix limit (error 1071). MySQL 5.7+ and 8.0 need no extra config.
## Step 4: Seed reference data
```bash

View File

@@ -21,7 +21,12 @@ The codebase today is single-tenant per deployment. There is no `siteid` column,
Each site:
- Owns its database (own credentials, own backup policy, own retention)
- Owns its database (own credentials, own backup policy, own retention). The
database charset is part of the contract: it must be **utf8mb4**
(`utf8mb4_unicode_ci`). The migration chain creates every table utf8mb4, and
the connection pins `?charset=utf8mb4`. A site that creates the database with
a different default charset (older MySQL defaults to latin1) gets a schema
that silently diverges from every other site. See `docs/DEPLOY.md`.
- Picks its own enabled plugins
- Configures its own JWT secret, CORS allowlist, Zabbix integration, Active Directory binding
- Deploys at its own cadence

View File

@@ -13,6 +13,7 @@
"@fullcalendar/vue3": "^6.1.20",
"axios": "^1.6.0",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.1.0",
@@ -58,6 +59,14 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
@@ -904,6 +913,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"optional": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1065,6 +1091,15 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1087,6 +1122,25 @@
"node": ">=6"
}
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -1128,6 +1182,26 @@
"node": ">= 0.8"
}
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1158,6 +1232,15 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1283,6 +1366,16 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1301,6 +1394,11 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -1471,6 +1569,24 @@
"node": ">= 0.4"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -1486,6 +1602,22 @@
"integrity": "sha512-CuHU9hC6dPsHF5oVFMo8NW76uQVjH4L22CsP4hW+dNnGywJHC/B0ThA1CTDVLnxKLrrpYdicBLnd2xsgTfRnvg==",
"license": "MIT"
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -1606,6 +1738,21 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
]
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -1615,6 +1762,12 @@
"node": ">=8"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1726,6 +1879,21 @@
"node": ">=10.13.0"
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"optional": true
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1741,6 +1909,15 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
@@ -1801,6 +1978,15 @@
"node": ">=0.10.0"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -1827,6 +2013,24 @@
"node": ">=8"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -1844,6 +2048,15 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",

View File

@@ -14,6 +14,7 @@
"@fullcalendar/vue3": "^6.1.20",
"axios": "^1.6.0",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.1.0",

View File

@@ -94,6 +94,9 @@ export const equipmentApi = {
},
update(id, data) {
return api.put(`/equipment/types/${id}`, data)
},
remove(id) {
return api.delete(`/equipment/types/${id}`)
}
}
}
@@ -137,6 +140,24 @@ export const computersApi = {
},
update(id, data) {
return api.put(`/computers/types/${id}`, data)
},
remove(id) {
return api.delete(`/computers/types/${id}`)
}
},
// Remote-access protocol catalog
protocols: {
list(params = {}) {
return api.get('/computers/protocols', { params })
},
create(data) {
return api.post('/computers/protocols', data)
},
update(id, data) {
return api.put(`/computers/protocols/${id}`, data)
},
remove(id) {
return api.delete(`/computers/protocols/${id}`)
}
}
}
@@ -148,6 +169,12 @@ export const relationshipTypesApi = {
},
create(data) {
return api.post('/assets/relationshiptypes', data)
},
update(id, data) {
return api.put(`/assets/relationshiptypes/${id}`, data)
},
remove(id) {
return api.delete(`/assets/relationshiptypes/${id}`)
}
}
@@ -204,8 +231,17 @@ export const locationsApi = {
return api.delete(`/locations/${id}`)
},
types: {
list() {
return api.get('/locations/types')
list(params = {}) {
return api.get('/locations/types', { params })
},
create(data) {
return api.post('/locations/types', data)
},
update(id, data) {
return api.put(`/locations/types/${id}`, data)
},
remove(id) {
return api.delete(`/locations/types/${id}`)
}
}
}
@@ -236,6 +272,12 @@ export const printersApi = {
},
create(data) {
return api.post('/printers/types', data)
},
update(id, data) {
return api.put(`/printers/types/${id}`, data)
},
remove(id) {
return api.delete(`/printers/types/${id}`)
}
},
updateCommunication(id, data) {
@@ -260,8 +302,8 @@ export const printersApi = {
return api.get('/printers/dashboard/summary')
},
drivers: {
list() {
return api.get('/printers/drivers')
list(params = {}) {
return api.get('/printers/drivers', { params })
},
create(data) {
return api.post('/printers/drivers', data)
@@ -521,6 +563,9 @@ export const assetsApi = {
},
get(id) {
return api.get(`/assets/types/${id}`)
},
update(id, data) {
return api.put(`/assets/types/${id}`, data)
}
},
statuses: {
@@ -575,8 +620,14 @@ export const notificationsApi = {
list() {
return api.get('/notifications/types')
},
get(id) {
return api.get(`/notifications/types/${id}`)
},
create(data) {
return api.post('/notifications/types', data)
},
update(id, data) {
return api.put(`/notifications/types/${id}`, data)
}
}
}
@@ -718,6 +769,24 @@ export const settingsApi = {
}
}
// Slide manager (lobby display + shopfloor screensaver)
export const slidesApi = {
list(surface) {
return api.get(`/slides/${surface}`)
},
upload(surface, formData) {
return api.post(`/slides/${surface}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
reorder(surface, order) {
return api.post(`/slides/${surface}/order`, { order })
},
remove(surface, files) {
return api.post(`/slides/${surface}/delete`, { files })
}
}
// Audit Logs API
export const auditLogsApi = {
list(params = {}) {
@@ -811,6 +880,9 @@ export const networkApi = {
},
update(id, data) {
return api.put(`/network/types/${id}`, data)
},
remove(id) {
return api.delete(`/network/types/${id}`)
}
},
// VLANs
@@ -850,3 +922,55 @@ export const networkApi = {
}
}
}
// Custom fields: site-defined attributes per asset type.
export const customFieldsApi = {
// Definitions
list(params = {}) {
return api.get('/customfields', { params })
},
create(data) {
return api.post('/customfields', data)
},
update(fieldid, data) {
return api.put(`/customfields/${fieldid}`, data)
},
remove(fieldid) {
return api.delete(`/customfields/${fieldid}`)
},
// Per-asset values (defs merged with the asset's stored values)
forAsset(assetid) {
return api.get(`/customfields/asset/${assetid}`)
},
saveForAsset(assetid, values) {
return api.put(`/customfields/asset/${assetid}`, { values })
}
}
// Warranty plugin: asset warranty tracking (manual + provider lookups).
export const warrantyApi = {
list(params = {}) {
return api.get('/warranty', { params })
},
get(id) {
return api.get(`/warranty/${id}`)
},
forAsset(assetid) {
return api.get(`/warranty/asset/${assetid}`)
},
create(data) {
return api.post('/warranty', data)
},
update(id, data) {
return api.put(`/warranty/${id}`, data)
},
remove(id) {
return api.delete(`/warranty/${id}`)
},
refresh(id) {
return api.post(`/warranty/${id}/refresh`)
},
report() {
return api.get('/warranty/report')
}
}

View File

@@ -1585,3 +1585,15 @@ td.actions {
}
/* Light mode is now default, dark mode via prefers-color-scheme */
/* "Show inactive" toggle in a type-settings page header */
.show-inactive {
margin-left: auto;
margin-right: 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--text-light);
cursor: pointer;
}

View File

@@ -33,7 +33,7 @@
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
@@ -65,7 +65,7 @@
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
@@ -175,6 +175,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
import { colorStyle } from '@/utils/colorStyle'
import { useAuthStore } from '../stores/auth'
const props = defineProps({
@@ -199,6 +200,11 @@ const lookupFailed = ref(false)
const outgoing = ref([])
const incoming = ref([])
const relationshipTypes = ref([])
function colorForType(name) {
const t = relationshipTypes.value.find(rt => rt.relationshiptype === name)
return t?.color || null
}
const showAddModal = ref(false)
// New relationship form

View File

@@ -0,0 +1,101 @@
<template>
<div class="color-swatch-picker">
<div class="swatches">
<button
v-for="c in palette"
:key="c"
type="button"
class="swatch"
:class="{ active: modelValue === c }"
:style="{ backgroundColor: c }"
:title="c"
@click="$emit('update:modelValue', c)"
></button>
</div>
<div class="custom">
<input
type="color"
:value="isHex(modelValue) ? modelValue : '#000000'"
@input="$emit('update:modelValue', $event.target.value)"
/>
<input
type="text"
:value="modelValue"
placeholder="#RRGGBB"
maxlength="20"
@input="$emit('update:modelValue', $event.target.value)"
/>
<span class="preview" :style="colorStyle(modelValue)">Aa</span>
</div>
</div>
</template>
<script setup>
import { PALETTE, colorStyle } from '@/utils/colorStyle'
defineProps({ modelValue: { type: String, default: '' } })
defineEmits(['update:modelValue'])
const palette = PALETTE
function isHex(c) {
return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)
}
</script>
<style scoped>
.color-swatch-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.swatch {
width: 26px;
height: 26px;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
}
.swatch.active {
border-color: var(--bg);
box-shadow: 0 0 0 2px var(--text);
}
.custom {
display: flex;
align-items: center;
gap: 8px;
}
.custom input[type="text"] {
width: 120px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-family: monospace;
}
.custom input[type="color"] {
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: none;
cursor: pointer;
}
.preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 6px;
font-weight: 700;
border: 1px solid var(--border);
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div v-if="fields.length" class="custom-fields">
<div class="form-group" v-for="f in fields" :key="f.fieldid">
<label>{{ f.label }}</label>
<select v-if="f.datatype === 'select'" v-model="local[f.fieldid]" class="form-control">
<option value="">-- none --</option>
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<label v-else-if="f.datatype === 'boolean'" class="checkbox-label">
<input type="checkbox"
:checked="local[f.fieldid] === 'true'"
@change="local[f.fieldid] = $event.target.checked ? 'true' : 'false'" />
Yes
</label>
<input v-else-if="f.datatype === 'date'" type="date" v-model="local[f.fieldid]" class="form-control" />
<input v-else-if="f.datatype === 'number'" type="number" v-model="local[f.fieldid]" class="form-control" />
<input v-else type="text" v-model="local[f.fieldid]" class="form-control" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { customFieldsApi } from '../api'
const props = defineProps({
// Asset type whose fields to render (needed even for a brand-new asset).
assettypeid: { type: [Number, String], default: null },
// Existing asset id, if editing - used to preload stored values.
assetid: { type: [Number, String], default: null },
})
const fields = ref([])
const local = reactive({})
async function load() {
fields.value = []
Object.keys(local).forEach(k => delete local[k])
if (!props.assettypeid) return
try {
// Definitions for this asset type (form-visible, active).
const defsResponse = await customFieldsApi.list({ assettypeid: props.assettypeid })
const defs = (defsResponse.data.data || []).filter(f => f.showonform)
fields.value = defs
// Seed blanks, then overlay stored values if editing.
for (const f of defs) local[f.fieldid] = f.datatype === 'boolean' ? 'false' : ''
if (props.assetid) {
const valResponse = await customFieldsApi.forAsset(props.assetid)
for (const f of (valResponse.data.data || [])) {
if (f.value != null) local[f.fieldid] = String(f.value)
}
}
} catch (err) {
console.error('Error loading custom field defs:', err)
}
}
// Persist current values against an asset id (parent calls after asset save).
async function save(assetid) {
if (!assetid || !fields.value.length) return
const values = {}
for (const f of fields.value) values[f.fieldid] = local[f.fieldid]
await customFieldsApi.saveForAsset(assetid, values)
}
onMounted(load)
watch(() => [props.assettypeid, props.assetid], load)
defineExpose({ save, hasFields: () => fields.value.length > 0 })
</script>
<style scoped>
.custom-fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<!-- Only render the card when there is at least one field with a value to show -->
<div class="section-card" v-if="visibleFields.length">
<h3 class="section-title">{{ title }}</h3>
<div class="info-list">
<div class="info-row" v-for="f in visibleFields" :key="f.fieldid">
<span class="info-label">{{ f.label }}</span>
<span class="info-value">{{ displayValue(f) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { customFieldsApi } from '../api'
const props = defineProps({
assetid: { type: [Number, String], default: null },
title: { type: String, default: 'Additional Details' },
})
const fields = ref([])
const visibleFields = computed(() =>
fields.value.filter(f => f.showondetail && f.value != null && String(f.value).trim() !== ''))
function displayValue(f) {
if (f.datatype === 'boolean') {
return ['true', '1', 'yes'].includes(String(f.value).toLowerCase()) ? 'Yes' : 'No'
}
return f.value
}
async function load() {
if (!props.assetid) { fields.value = []; return }
try {
const response = await customFieldsApi.forAsset(props.assetid)
fields.value = response.data.data || []
} catch (err) {
console.error('Error loading custom fields:', err)
fields.value = []
}
}
onMounted(load)
watch(() => props.assetid, load)
</script>

View File

@@ -7,6 +7,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
const props = defineProps({
left: { type: Number, default: null },
@@ -19,14 +20,17 @@ const mapContainer = ref(null)
let map = null
let marker = null
// Map dimensions
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Map dimensions - facility blueprint size, loaded from settings.
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.height
function initMap() {
if (!mapContainer.value || props.left === null || props.top === null) return
MAP_WIDTH = mapConfig.width
MAP_HEIGHT = mapConfig.height
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
map = L.map(mapContainer.value, {
crs: L.CRS.Simple,
minZoom: -3,
@@ -35,11 +39,7 @@ function initMap() {
zoomControl: true
})
const blueprintUrl = currentTheme.value === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
L.imageOverlay(blueprintUrl, bounds).addTo(map)
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
// Convert database coordinates to Leaflet (y is inverted)
const leafletY = MAP_HEIGHT - props.top
@@ -71,7 +71,8 @@ function initMap() {
map.setMaxBounds(bounds)
}
onMounted(() => {
onMounted(async () => {
await loadMapConfig()
initMap()
})

View File

@@ -43,6 +43,11 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
// Fetch this facility's blueprint + dimensions once; computeds below react
// when it loads.
loadMapConfig()
const props = defineProps({
left: { type: Number, default: null },
@@ -58,29 +63,22 @@ const isOverTooltip = ref(false)
const zoom = ref(1)
const imageLoaded = ref(false)
// Map dimensions
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const hasPosition = computed(() => {
return props.left !== null && props.top !== null
})
const blueprintUrl = computed(() => {
// Force re-evaluation when theme changes by including theme in the computed
const theme = currentTheme.value
return theme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
// Reading currentTheme keeps this reactive to theme changes.
return blueprintUrlFor(currentTheme.value)
})
// Calculate marker position as percentage
// Calculate marker position as percentage of the facility blueprint size
const markerX = computed(() => {
return (props.left / MAP_WIDTH) * 100
return (props.left / mapConfig.width) * 100
})
const markerY = computed(() => {
return (props.top / MAP_HEIGHT) * 100
return (props.top / mapConfig.height) * 100
})
// Marker style with counter-scale to maintain constant size

View File

@@ -86,6 +86,8 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
const props = defineProps({
machines: { type: Array, default: () => [] },
@@ -119,10 +121,10 @@ const filters = ref({
search: ''
})
// Map dimensions (matching old system)
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Map dimensions - facility blueprint size, loaded from settings before
// initMap runs (mutable so the loaded values replace the fallback defaults).
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.height
// Asset type colors (for unified map mode) - normalized lookup
const assetTypeColorsMap = {
@@ -140,21 +142,10 @@ function getAssetTypeColor(assettype) {
return assetTypeColorsMap[normalized] || '#BDBDBD'
}
// Asset type labels for display (case-insensitive lookup)
const assetTypeLabelsMap = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network device': 'Network Devices',
'network_device': 'Network Devices'
}
// Asset-type display labels come from the shared util (single source of truth).
const assetTypeLabels = new Proxy({}, {
get(target, prop) {
if (typeof prop === 'string') {
return assetTypeLabelsMap[prop.toLowerCase()] || prop
}
return prop
return typeof prop === 'string' ? assetTypeLabel(prop) : prop
}
})
@@ -232,7 +223,8 @@ const visibleAssetTypes = computed(() => {
// Get subtype ID from asset based on asset type
function getSubtypeId(asset) {
if (!asset.typedata) return null
const typeLower = asset.assettype?.toLowerCase() || ''
// Normalize network_device -> network device so the subtype id resolves.
const typeLower = (asset.assettype || '').toLowerCase().replace(/_/g, ' ')
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
@@ -271,11 +263,8 @@ function initMap() {
renderer: canvasRenderer
})
const blueprintUrl = props.theme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
imageOverlay = L.imageOverlay(blueprintUrl, bounds)
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme), bounds)
imageOverlay.addTo(map)
// Set initial view - zoom out to show full floor plan
@@ -390,7 +379,8 @@ function renderMarkers() {
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
} else {
color = getAssetTypeColor(item.assettype)
// Prefer the stored AssetType.color; fall back to the built-in map.
color = item.assettypecolor || getAssetTypeColor(item.assettype)
typeName = item.assettype || ''
}
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
@@ -517,33 +507,9 @@ function renderMarkers() {
applyFilters()
}
// Get detail route for unified asset format
// Get detail route for unified asset format (shared util = single source).
function getAssetDetailRoute(asset) {
const assetType = (asset.assettype || '').toLowerCase()
const routeMap = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'network device': '/network'
}
const basePath = routeMap[assetType] || '/machines'
// Get the plugin-specific ID from typedata
let id = asset.assetid // fallback
if (asset.typedata) {
if (assetType === 'equipment' && asset.typedata.equipmentid) {
id = asset.typedata.equipmentid
} else if (assetType === 'computer' && asset.typedata.computerid) {
id = asset.typedata.computerid
} else if (assetType === 'printer' && asset.typedata.printerid) {
id = asset.typedata.printerid
} else if ((assetType === 'network_device' || assetType === 'network device') && asset.typedata.networkdeviceid) {
id = asset.typedata.networkdeviceid
}
}
return `${basePath}/${id}`
return assetDetailRoute(asset)
}
function applyFilters() {
@@ -581,14 +547,16 @@ watch(() => props.machines, (newVal, oldVal) => {
watch(() => props.theme, (newTheme) => {
if (imageOverlay && map) {
const blueprintUrl = newTheme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
imageOverlay.setUrl(blueprintUrl)
imageOverlay.setUrl(blueprintUrlFor(newTheme))
}
})
onMounted(() => {
onMounted(async () => {
// Load this facility's blueprint + dimensions before building the map so
// bounds and coordinate math use the right size. Falls back to defaults.
await loadMapConfig()
MAP_WIDTH = mapConfig.width
MAP_HEIGHT = mapConfig.height
initMap()
})

View File

@@ -0,0 +1,75 @@
<template>
<div class="section-card" v-if="warranties.length || showEmpty">
<h3 class="section-title">Warranty</h3>
<div v-if="warranties.length" class="warranty-list">
<div v-for="w in warranties" :key="w.warrantyid" class="warranty-item">
<div class="warranty-top">
<span class="warranty-vendor">{{ w.vendor }}</span>
<span class="status-badge" :style="colorStyle(w.statuscolor)">{{ statusLabel(w.status) }}</span>
</div>
<div class="warranty-meta">
<span v-if="w.servicelevel">{{ w.servicelevel }}</span>
<span v-if="w.enddate">Ends {{ formatDate(w.enddate) }}</span>
<span v-if="w.servicetag" class="mono">Tag {{ w.servicetag }}</span>
</div>
</div>
<router-link to="/warranties" class="warranty-manage">Manage warranties</router-link>
</div>
<div v-else class="warranty-empty">
<span class="muted">No warranty on record.</span>
<router-link to="/warranties" class="warranty-manage">Add one</router-link>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi } from '../api'
const props = defineProps({
assetid: { type: [Number, String], default: null },
// When true, render the card even with no warranties (shows an "Add one" link).
showEmpty: { type: Boolean, default: false },
})
const warranties = ref([])
function statusLabel(status) {
return { active: 'Active', expiring: 'Expiring Soon', expired: 'Expired', unknown: 'Unknown' }[status] || status
}
function formatDate(d) {
if (!d) return '-'
return new Date(d + 'T00:00:00').toLocaleDateString()
}
async function load() {
if (!props.assetid) { warranties.value = []; return }
try {
const response = await warrantyApi.forAsset(props.assetid)
warranties.value = response.data.data || []
} catch (err) {
console.error('Error loading warranties:', err)
warranties.value = []
}
}
onMounted(load)
watch(() => props.assetid, load)
</script>
<style scoped>
.warranty-list { display: flex; flex-direction: column; gap: 0.75rem; }
.warranty-item { padding: 0.6rem 0.75rem; background: var(--bg); border-radius: 6px; }
.warranty-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.warranty-vendor { font-weight: 600; color: var(--text); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.warranty-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
.warranty-empty { display: flex; align-items: center; gap: 0.6rem; }
.warranty-manage { font-size: 0.82rem; }
.mono { font-family: monospace; }
.muted { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,68 @@
// Facility floor-map blueprint config, read from the settings table so each
// site instance (ADR-004) renders its own floor plan instead of a hardcoded
// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height.
// Missing keys fall back to the West Jefferson sitemap so a fresh or offline
// install still renders.
import { reactive } from 'vue'
import { settingsApi } from '../api'
// Fallback defaults - the original hardcoded West Jefferson values.
const DEFAULTS = {
blueprintLight: '/static/images/sitemap2025-light.png',
blueprintDark: '/static/images/sitemap2025-dark.png',
width: 3300,
height: 2550
}
// Shared reactive config. Import as `state` to read width/height/blueprint.
export const state = reactive({ ...DEFAULTS, loaded: false })
let inflight = null
function applySetting(key, value) {
if (value === null || value === undefined || value === '') return
if (key === 'map_blueprint_light') state.blueprintLight = value
else if (key === 'map_blueprint_dark') state.blueprintDark = value
else if (key === 'map_width') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.width = n
} else if (key === 'map_height') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.height = n
}
}
function fetchConfig() {
inflight = settingsApi.list({ category: 'map' })
.then(({ data }) => {
;(data.data || []).forEach(s => applySetting(s.key, s.value))
state.loaded = true
})
.catch(() => { state.loaded = true })
.finally(() => { inflight = null })
return inflight
}
// Fetch the map config once (shared across all map components). Returns a
// promise that resolves when state is populated, so a caller can await it
// before initializing a Leaflet map that needs the dimensions.
export function loadMapConfig() {
if (state.loaded) return Promise.resolve()
if (inflight) return inflight
return fetchConfig()
}
// Re-read config from the server after a map setting changes.
export function reloadMapConfig() {
return fetchConfig()
}
// Blueprint image URL for the given theme ('light' | 'dark').
export function blueprintUrlFor(theme) {
return theme === 'light' ? state.blueprintLight : state.blueprintDark
}
export function useMapConfig() {
loadMapConfig()
return { state, blueprintUrlFor }
}

View File

@@ -1,10 +1,39 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import AppLayout from '../views/AppLayout.vue'
import SettingsLayout from '../views/settings/SettingsLayout.vue'
// Auto-discover all route modules from routes/ directory
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
const appChildren = Object.values(routeModules).flatMap(m => m.default)
const rawChildren = Object.values(routeModules).flatMap(m => m.default)
// Gather the settings pages (spread across plugin route files) and nest them
// under a single two-pane shell so the grouped rail stays put while the right
// pane swaps. Slides is a sidebar page that happens to live at /settings/slides;
// keep it full-width (not inside the settings rail).
const SETTINGS_STANDALONE = new Set(['settings/slides'])
const settingsChildren = []
const otherChildren = []
for (const route of rawChildren) {
const path = route.path
if (path === 'settings') {
// Old index becomes the shell's default child (keeps name 'settings').
settingsChildren.unshift({ ...route, path: '' })
} else if (typeof path === 'string' && path.startsWith('settings/') && !SETTINGS_STANDALONE.has(path)) {
settingsChildren.push({ ...route, path: path.replace(/^settings\//, '') })
} else {
otherChildren.push(route)
}
}
const appChildren = [
...otherChildren,
{
path: 'settings',
component: SettingsLayout,
meta: { requiresAuth: true, requiresAdmin: true },
children: settingsChildren,
},
]
const routes = [
{

View File

@@ -36,5 +36,11 @@ export default [
name: 'operatingsystems',
component: () => import('../../views/settings/OperatingSystemsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/accessprotocols',
name: 'access-protocols',
component: () => import('../../views/settings/AccessProtocolsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -92,6 +92,60 @@ export default [
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/site',
name: 'site-settings',
component: () => import('../../views/settings/SiteSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/slides',
name: 'slide-manager',
component: () => import('../../views/settings/SlideManager.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/equipmenttypes',
name: 'equipment-types',
component: () => import('../../views/settings/EquipmentTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/networktypes',
name: 'network-types',
component: () => import('../../views/settings/NetworkTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/printertypes',
name: 'printer-types',
component: () => import('../../views/settings/PrinterTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/relationshiptypes',
name: 'relationship-types',
component: () => import('../../views/settings/RelationshipTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/locationtypes',
name: 'location-types',
component: () => import('../../views/settings/LocationTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/assettypes',
name: 'asset-types',
component: () => import('../../views/settings/AssetTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/customfields',
name: 'custom-fields',
component: () => import('../../views/settings/CustomFieldsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/system',
name: 'system-settings',

View File

@@ -13,6 +13,12 @@ export default [
component: () => import('../../views/notifications/NotificationForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'settings/notificationtypes',
name: 'notification-types',
component: () => import('../../views/notifications/NotificationTypesList.vue'),
meta: { requiresAuth: true }
},
{
path: 'notifications/:id',
name: 'notification-detail',

View File

@@ -30,5 +30,11 @@ export default [
name: 'model-supplies',
component: () => import('../../views/settings/ModelSuppliesList.vue'),
meta: { requiresAuth: true }
},
{
path: 'settings/printerdrivers',
name: 'printer-drivers',
component: () => import('../../views/settings/PrinterDriversList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -0,0 +1,16 @@
/**
* Warranty plugin routes
*/
export default [
{
path: 'warranties',
name: 'warranties',
component: () => import('../../views/warranty/WarrantiesList.vue'),
meta: { requiresAuth: true }
},
{
path: 'reports/warranty',
name: 'warranty-report',
component: () => import('../../views/reports/WarrantyReport.vue')
}
]

View File

@@ -0,0 +1,60 @@
// Single source of truth for asset-type display labels + detail routing.
// The map and shopfloor views used to each hardcode these identical maps.
// Keys are lowercase; both 'network_device' and 'network device' are accepted
// because the API sends the underscore form and some map data the spaced form.
const ASSET_TYPE_LABELS = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network_device': 'Network Devices',
'network device': 'Network Devices',
}
const ASSET_TYPE_ROUTES = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'network device': '/network',
}
// Plugin-specific id field inside asset.typedata for each asset type.
const ASSET_TYPE_ID_KEYS = {
'equipment': 'equipmentid',
'computer': 'computerid',
'printer': 'printerid',
'network_device': 'networkdeviceid',
'network device': 'networkdeviceid',
}
function titleCase(text) {
return String(text || '')
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase())
}
// Plural, human-friendly label for an asset type. Falls back to title-cased
// input so an unknown/new type still reads sensibly.
export function assetTypeLabel(type) {
if (!type) return type
return ASSET_TYPE_LABELS[String(type).toLowerCase()] || titleCase(type)
}
// Base list route for an asset type (e.g. 'computer' -> '/pcs').
export function assetTypeRoute(type) {
return ASSET_TYPE_ROUTES[String(type || '').toLowerCase()] || '/machines'
}
// Full detail route for a unified-format asset, preferring the plugin-specific
// id in typedata and falling back to the asset id.
export function assetDetailRoute(asset) {
const type = (asset.assettype || '').toLowerCase()
const base = assetTypeRoute(type)
const idKey = ASSET_TYPE_ID_KEYS[type]
let id = asset.assetid
if (asset.typedata && idKey && asset.typedata[idKey]) {
id = asset.typedata[idKey]
}
return `${base}/${id}`
}

View File

@@ -0,0 +1,39 @@
// Data-driven badge colors. A record stores a hex color; the UI renders it with
// an auto-picked readable text color, so any color stays legible. Pickers should
// offer PALETTE (curated, distinct, accessible) with custom hex as a fallback.
// Curated categorical palette - distinct + accessible. ~12 is the practical
// ceiling for at-a-glance distinguishability, which is plenty per category.
export const PALETTE = [
'#f5365c', // red
'#fb6340', // orange
'#ff8800', // amber
'#ffc107', // gold
'#2dce89', // green
'#04b962', // emerald
'#11cdef', // cyan
'#14abef', // blue
'#0d6efd', // royal blue
'#7934f3', // purple
'#e83e8c', // pink
'#6c757d', // gray
]
// Black or white text for a given background, by perceived brightness.
export function readableText(bg) {
if (!bg || typeof bg !== 'string') return '#ffffff'
let hex = bg.trim().replace('#', '')
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('')
if (hex.length !== 6) return '#ffffff'
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return brightness > 0.6 ? '#1a1a1a' : '#ffffff'
}
// Style object for a badge/pill from a stored color (with a neutral fallback).
export function colorStyle(color, fallback = '#6c757d') {
const backgroundColor = color || fallback
return { backgroundColor, color: readableText(backgroundColor) }
}

View File

@@ -0,0 +1,51 @@
// Marker color logic for the shop-floor map, shared so the PDF export renders
// the exact same colors the map shows on screen. Mirrors the maps defined in
// ShopFloorMap.vue - keep the two in sync if the palette changes.
export const assetTypeColorsMap = {
equipment: '#F44336', // Red
computer: '#2196F3', // Blue
printer: '#4CAF50', // Green
'network device': '#FF9800', // Orange
network_device: '#FF9800' // Orange (alternate key)
}
const DEFAULT_COLOR = '#BDBDBD'
// Canonical form for comparing asset-type strings. The API sends the machine
// type as 'network_device' but subtype keys and labels use 'network device',
// so normalize underscores to spaces before any comparison. Without this,
// network-device subtypes silently fail to match (equipment/computer/printer
// are single words and were unaffected, which is why only network broke).
export function normalizeAssetType(assettype) {
return (assettype || '').toLowerCase().replace(/_/g, ' ')
}
// Color for an asset by its top-level asset type (used when no type filter is
// active, so every type is distinguished by color).
export function getAssetTypeColor(assettype) {
if (!assettype) return DEFAULT_COLOR
return assetTypeColorsMap[assettype.toLowerCase()] || DEFAULT_COLOR
}
// The subtype id for an asset, read from the plugin-specific typedata block.
// Returns null when the asset has no subtype.
export function getSubtypeId(asset) {
if (!asset || !asset.typedata) return null
const typeLower = normalizeAssetType(asset.assettype)
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid
return null
}
// Resolve the marker color for an asset the same way ShopFloorMap does: when a
// type is selected, color by subtype (grey fallback); otherwise by asset type.
export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) {
if (selectedType) {
const id = getSubtypeId(asset)
return (id != null && subtypeColors[id]) || DEFAULT_COLOR
}
return getAssetTypeColor(asset.assettype)
}

View File

@@ -0,0 +1,135 @@
// Export the shop-floor map (current filtered assets on the facility blueprint)
// to a PDF, entirely client-side. The blueprint image is drawn full-size and
// each visible marker is placed at its scaled coordinate, so the PDF is a crisp
// vector-over-raster page rather than a screen capture.
import { jsPDF } from 'jspdf'
import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors'
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('Failed to load blueprint image: ' + url))
img.src = url
})
}
function hexToRgb(hex) {
const h = (hex || '#BDBDBD').replace('#', '')
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)]
}
// Build the legend entries (color + label) for the assets present, matching the
// active coloring mode.
function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) {
const seen = new Map()
for (const a of assets) {
let key, label, color
if (selectedType) {
const id = getSubtypeId(a)
key = id != null ? String(id) : 'none'
label = (id != null && subtypeNames[id]) || 'Unspecified'
color = (id != null && subtypeColors[id]) || '#BDBDBD'
} else {
key = (a.assettype || 'unknown').toLowerCase()
label = a.assettype || 'Unknown'
color = getAssetTypeColor(a.assettype)
}
if (!seen.has(key)) seen.set(key, { label, color, count: 0 })
seen.get(key).count += 1
}
return [...seen.values()].sort((a, b) => b.count - a.count)
}
export async function exportMapPdf(opts) {
const {
assets = [],
blueprintUrl,
mapWidth = 3300,
mapHeight = 2550,
selectedType = '',
subtypeColors = {},
subtypeNames = {},
filters = [],
facility = '',
title = 'Shop Floor Map'
} = opts
const img = await loadImage(blueprintUrl)
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4', compress: true })
const pageW = doc.internal.pageSize.getWidth()
const pageH = doc.internal.pageSize.getHeight()
const margin = 28
// ---- Header ----
doc.setTextColor('#111111')
doc.setFont('helvetica', 'bold')
doc.setFontSize(16)
doc.text(title, margin, margin + 6)
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor('#555555')
const stamp = new Date().toLocaleString()
const subParts = [facility, stamp, `${assets.length} asset${assets.length === 1 ? '' : 's'}`].filter(Boolean)
doc.text(subParts.join(' | '), margin, margin + 22)
const filterText = filters.length ? 'Filters: ' + filters.join(' ') : 'Filters: none (all assets)'
doc.text(filterText, margin, margin + 35)
// ---- Legend (wraps under the header, pushes the map down) ----
const legend = buildLegend(assets, { selectedType, subtypeColors, subtypeNames })
const swatch = 8
const legendY0 = margin + 50
const lineH = 15
let lx = margin
let ly = legendY0
doc.setFontSize(8.5)
for (const entry of legend) {
const label = `${entry.label} (${entry.count})`
const w = swatch + 4 + doc.getTextWidth(label) + 16
if (lx + w > pageW - margin) { lx = margin; ly += lineH }
const [r, g, b] = hexToRgb(entry.color)
doc.setFillColor(r, g, b)
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4)
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F')
doc.setTextColor('#333333')
doc.text(label, lx + swatch + 4, ly)
lx += w
}
const legendBottom = legend.length ? ly + 6 : legendY0
// ---- Blueprint image, fit into the area below the legend ----
const aspect = mapWidth / mapHeight
const contentTop = legendBottom + 8
const availW = pageW - margin * 2
const availH = pageH - contentTop - margin
let imgW = availW
let imgH = availW / aspect
if (imgH > availH) { imgH = availH; imgW = availH * aspect }
const imgX = margin + (availW - imgW) / 2
const imgY = contentTop + (availH - imgH) / 2
doc.addImage(img, 'PNG', imgX, imgY, imgW, imgH, undefined, 'FAST')
doc.setDrawColor('#cccccc'); doc.setLineWidth(0.5)
doc.rect(imgX, imgY, imgW, imgH)
// ---- Markers (database Y is top-down, same origin as the drawn image) ----
const radius = 3.2
for (const a of assets) {
if (a.mapx == null || a.mapy == null) continue
const x = imgX + (a.mapx / mapWidth) * imgW
const y = imgY + (a.mapy / mapHeight) * imgH
if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue
const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors }))
doc.setFillColor(r, g, b)
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6)
doc.circle(x, y, radius, 'FD')
}
const dateSlug = new Date().toISOString().slice(0, 10)
doc.save(`shopfloor-map-${dateSlug}.pdf`)
}

View File

@@ -0,0 +1,37 @@
// Shared read-through for public site settings (site_base_url, facility_name).
// The settings GET is public (jwt optional), so the kiosk dashboard and the
// print views can read these without auth. Fetched once and cached per page.
import { settingsApi } from '@/api'
let settingsCache = null
async function loadSettings() {
if (settingsCache) return settingsCache
try {
const response = await settingsApi.list()
const items = response.data?.data || response.data || []
settingsCache = {}
for (const s of items) settingsCache[s.key] = s.value
} catch (err) {
console.error('Error loading site settings:', err)
settingsCache = {}
}
return settingsCache
}
export async function getSetting(key, fallback = '') {
const settings = await loadSettings()
const value = settings[key]
return (value === undefined || value === null || value === '') ? fallback : value
}
// Public base URL for QR codes / absolute links. Falls back to the current
// browsing origin when a site has not set one.
export async function getSiteBaseUrl() {
return getSetting('site_base_url', window.location.origin)
}
// Facility name shown on the shopfloor dashboard.
export async function getFacilityName() {
return getSetting('facility_name', 'West Jefferson')
}

View File

@@ -77,7 +77,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck
} from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
@@ -103,6 +103,8 @@ const iconMap = {
'app-window': AppWindow,
'book-open': BookOpen,
'bar-chart-3': BarChart3,
'image': Image,
'shield': ShieldCheck,
}
// Default navigation (used as fallback if API fails)

View File

@@ -29,13 +29,13 @@
<!-- Event details modal -->
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
<div class="modal">
<!-- Recognition event with employee highlight -->
<div v-if="selectedEvent.extendedProps?.typecolor === 'recognition'" class="recognition-header">
<!-- Employee-photo event (recognition/recertification) with highlight -->
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
<div class="recognition-badge">
<span class="recognition-icon"><Trophy :size="24" /></span>
</div>
<div class="recognition-info">
<div class="recognition-label">Recognition</div>
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
<span class="employee-icon"><User :size="16" /></span>
@@ -48,7 +48,7 @@
<h2 v-else>{{ selectedEvent.title }}</h2>
<div class="event-details">
<p v-if="selectedEvent.extendedProps?.typename && selectedEvent.extendedProps?.typecolor !== 'recognition'">
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
</p>
<p>
@@ -57,7 +57,7 @@
<p v-if="selectedEvent.end">
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
</p>
<p v-if="selectedEvent.extendedProps?.message && selectedEvent.extendedProps?.typecolor !== 'recognition'" class="message-block">
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
<strong>Details:</strong>
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
</p>

View File

@@ -47,6 +47,15 @@
@input="debouncedSearch"
/>
<button
class="btn btn-secondary export-btn"
@click="exportPdf"
:disabled="exporting || !filteredAssets.length"
:title="filteredAssets.length ? 'Export the filtered map to PDF' : 'No assets to export'"
>
{{ exporting ? 'Exporting...' : 'Export PDF' }}
</button>
<span class="result-count">{{ filteredAssets.length }} assets</span>
</div>
@@ -73,6 +82,9 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
import { loadMapConfig, state as mapConfig } from '../composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
const router = useRouter()
const authStore = useAuthStore()
@@ -89,18 +101,20 @@ const selectedSubtype = ref('')
const selectedBusinessUnit = ref('')
const selectedStatus = ref('')
const searchQuery = ref('')
const exporting = ref(false)
let searchTimeout = null
// Case-insensitive lookup helper for subtypes
// Lookup helper for subtypes. Normalizes case AND underscores-vs-spaces, since
// the asset-type value is 'network_device' but the subtypes key is
// 'Network Device' - without normalizing, network subtypes never match.
function getSubtypesForType(typeName) {
if (!typeName || !subtypes.value) return []
// Try exact match first
if (subtypes.value[typeName]) return subtypes.value[typeName]
// Try case-insensitive match
const lowerType = typeName.toLowerCase()
const norm = typeName.toLowerCase().replace(/_/g, ' ')
for (const [key, value] of Object.entries(subtypes.value)) {
if (key.toLowerCase() === lowerType) return value
if (key.toLowerCase().replace(/_/g, ' ') === norm) return value
}
return []
}
@@ -119,7 +133,7 @@ const subtypeLabel = computed(() => {
'network device': 'All Device Types',
'printer': 'All Printer Types'
}
return labels[selectedType.value.toLowerCase()] || 'All Subtypes'
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
})
// Generate distinct colors for subtypes
@@ -130,12 +144,13 @@ const subtypeColorPalette = [
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
]
// Map subtype IDs to colors
// Map subtype IDs to colors: prefer each subtype's stored color; fall back to
// the auto palette (by index) for subtypes that have not been given one.
const subtypeColorMap = computed(() => {
const colorMap = {}
const allSubtypes = currentSubtypes.value
allSubtypes.forEach((st, index) => {
colorMap[st.id] = subtypeColorPalette[index % subtypeColorPalette.length]
colorMap[st.id] = st.color || subtypeColorPalette[index % subtypeColorPalette.length]
})
return colorMap
})
@@ -158,10 +173,10 @@ const filteredAssets = computed(() => {
result = result.filter(a => a.assettype && a.assettype.toLowerCase() === selectedLower)
}
// Filter by subtype (case-insensitive type check)
// Filter by subtype (normalize network_device -> network device)
if (selectedSubtype.value) {
const subtypeId = parseInt(selectedSubtype.value)
const typeLower = selectedType.value?.toLowerCase() || ''
const typeLower = (selectedType.value || '').toLowerCase().replace(/_/g, ' ')
result = result.filter(a => {
if (!a.typedata) return false
// Check different ID fields based on asset type
@@ -202,7 +217,51 @@ const filteredAssets = computed(() => {
return result
})
// Human-readable labels for the filters currently applied, for the PDF header.
function activeFilterLabels() {
const labels = []
if (selectedType.value) labels.push(`Type: ${formatTypeName(selectedType.value)}`)
if (selectedSubtype.value) {
const st = currentSubtypes.value.find(s => String(s.id) === String(selectedSubtype.value))
if (st) labels.push(`Subtype: ${st.name}`)
}
if (selectedBusinessUnit.value) {
const bu = businessunits.value.find(b => String(b.businessunitid) === String(selectedBusinessUnit.value))
if (bu) labels.push(`Business Unit: ${bu.businessunit}`)
}
if (selectedStatus.value) {
const s = statuses.value.find(x => String(x.statusid) === String(selectedStatus.value))
if (s) labels.push(`Status: ${s.status}`)
}
if (searchQuery.value) labels.push(`Search: "${searchQuery.value}"`)
return labels
}
async function exportPdf() {
if (!filteredAssets.value.length) return
exporting.value = true
try {
await loadMapConfig()
await exportMapPdf({
assets: filteredAssets.value,
blueprintUrl: mapConfig.blueprintLight,
mapWidth: mapConfig.width,
mapHeight: mapConfig.height,
selectedType: selectedType.value,
subtypeColors: subtypeColorMap.value,
subtypeNames: subtypeNameMap.value,
filters: activeFilterLabels()
})
} catch (e) {
console.error('Map PDF export failed:', e)
alert('Failed to export map PDF. See console for details.')
} finally {
exporting.value = false
}
}
onMounted(async () => {
loadMapConfig()
try {
const response = await assetsApi.getMap()
const data = response.data.data || {}
@@ -220,15 +279,7 @@ onMounted(async () => {
})
function formatTypeName(assettype) {
if (!assettype) return assettype
const names = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network device': 'Network Devices',
'network_device': 'Network Devices'
}
return names[assettype.toLowerCase()] || assettype
return assetTypeLabel(assettype)
}
function getTypeCount(assettype) {
@@ -255,33 +306,7 @@ function debouncedSearch() {
}
function handleMarkerClick(asset) {
// Route based on asset type (lowercase keys to match API data)
const assetType = (asset.assettype || '').toLowerCase()
const routeMap = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'network device': '/network'
}
const basePath = routeMap[assetType] || '/machines'
// Get the plugin-specific ID from typedata
let id = asset.assetid // fallback
if (asset.typedata) {
if (assetType === 'equipment' && asset.typedata.equipmentid) {
id = asset.typedata.equipmentid
} else if (assetType === 'computer' && asset.typedata.computerid) {
id = asset.typedata.computerid
} else if (assetType === 'printer' && asset.typedata.printerid) {
id = asset.typedata.printerid
} else if ((assetType === 'network_device' || assetType === 'network device') && asset.typedata.networkdeviceid) {
id = asset.typedata.networkdeviceid
}
}
router.push(`${basePath}/${id}`)
router.push(assetDetailRoute(asset))
}
</script>
@@ -331,10 +356,30 @@ function handleMarkerClick(asset) {
min-width: 180px;
}
.export-btn {
margin-left: auto;
padding: 0.5rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
}
.export-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.export-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-count {
color: var(--text-light);
font-size: 0.875rem;
margin-left: auto;
}
.map-page :deep(.shopfloor-map) {

View File

@@ -6,7 +6,7 @@
</div>
<div class="header-center">
<div class="location-title">West Jefferson</div>
<div class="location-title">{{ facilityName }}</div>
<h1>Shopfloor Dashboard</h1>
</div>
@@ -22,6 +22,18 @@
</header>
<main class="dashboard-content">
<!-- Banner - single prominent full-width message -->
<section v-if="banners.length" class="banner-section">
<div
v-for="n in banners"
:key="n.notificationid"
class="banner-strip"
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
>
{{ n.notification }}
</div>
</section>
<!-- Recognition Carousel -->
<section v-if="recognitions.length" class="recognition-section">
<div class="section-title recognition">Employee Recognition</div>
@@ -49,7 +61,6 @@
</div>
<div class="recognition-content">
<div class="recognition-header">
<span class="recognition-star">&#9733;</span>
<div class="recognition-name">{{ rec.employeename }}</div>
</div>
<div class="recognition-message">{{ rec.notification }}</div>
@@ -58,6 +69,44 @@
</div>
</section>
<!-- Recertification grid - everyone due shown at once, so nobody has to
wait for a carousel to rotate to their name -->
<section v-if="recertifications.length" class="recert-section">
<div class="section-title recert-title">
<span>Recertification Required ({{ recertifications.length }})</span>
<span v-if="recertRangeLabel" class="recert-range">{{ recertRangeLabel }}</span>
</div>
<div
v-for="msg in recertDescriptions"
:key="msg"
class="recert-description"
>
{{ msg }}
</div>
<div class="recert-row">
<div
v-for="rec in recertPage"
:key="`recert-${rec.notificationid}-${rec.employeesso}`"
class="recert-tile"
>
<img
v-if="rec.employeepicture"
:src="`/static/employees/${rec.employeepicture}`"
:alt="rec.employeename"
class="recert-photo"
@error="handlePhotoError"
/>
<img
v-else
src="/ge-aerospace-logo.svg"
alt="GE Aerospace"
class="recert-photo ge-logo-fallback"
/>
<div class="recert-name">{{ rec.employeename || rec.employeesso }}</div>
</div>
</div>
</section>
<!-- Current Notifications -->
<section v-if="currentNotifications.length" class="notifications-section">
<div class="section-title" :class="getSectionClass(currentNotifications)">
@@ -114,7 +163,7 @@
</section>
<!-- No notifications -->
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !recognitions.length" class="no-events">
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !recognitions.length && !recertifications.length && !banners.length" class="no-events">
No active notifications
</div>
@@ -130,27 +179,75 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api'
import { getFacilityName } from '@/utils/siteSettings'
const loading = ref(true)
const facilityName = ref('West Jefferson')
const businessUnit = ref('')
const businessUnits = ref([])
const notifications = ref({ current: [], upcoming: [] })
const currentRecognition = ref(0)
// Layout-config fingerprint from the feed; when it changes the kiosk reloads.
const loadedConfigVersion = ref(null)
let refreshInterval = null
let recognitionInterval = null
let recertPageInterval = null
// The board groups cards by each type's configured display style, so any custom
// type set to carousel/grid/banner renders that way - not just the built-ins.
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
// Separate recognition notifications from others
const recognitions = computed(() =>
notifications.value.current.filter(n => n.typecolor === 'recognition')
notifications.value.current.filter(n => n.displaystyle === 'carousel')
)
const recertifications = computed(() =>
notifications.value.current.filter(n => n.displaystyle === 'grid')
)
const banners = computed(() =>
notifications.value.current.filter(n => n.displaystyle === 'banner')
)
// Distinct training descriptions shown once above the grid (the per-person
// tiles only carry photo + name).
const recertDescriptions = computed(() => {
const seen = new Set()
const out = []
for (const r of recertifications.value) {
const msg = (r.notification || '').trim()
if (msg && !seen.has(msg)) { seen.add(msg); out.push(msg) }
}
return out
})
// Recertification shows as a single rotating row: one page of tiles at a time
// so it stays compact on any screen, cycling through everyone due.
const RECERT_PAGE_SIZE = 8
const currentRecertPage = ref(0)
const recertPageCount = computed(() =>
Math.max(1, Math.ceil(recertifications.value.length / RECERT_PAGE_SIZE))
)
const recertPage = computed(() => {
const page = currentRecertPage.value % recertPageCount.value
const start = page * RECERT_PAGE_SIZE
return recertifications.value.slice(start, start + RECERT_PAGE_SIZE)
})
const recertRangeLabel = computed(() => {
if (recertPageCount.value <= 1) return ''
const page = currentRecertPage.value % recertPageCount.value
const start = page * RECERT_PAGE_SIZE
const end = Math.min(start + RECERT_PAGE_SIZE, recertifications.value.length)
return `${start + 1}-${end} of ${recertifications.value.length}`
})
const currentNotifications = computed(() =>
notifications.value.current.filter(n => n.typecolor !== 'recognition')
notifications.value.current.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
)
const upcomingNotifications = computed(() =>
notifications.value.upcoming.filter(n => n.typecolor !== 'recognition')
notifications.value.upcoming.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
)
// Clock
@@ -168,6 +265,8 @@ onMounted(async () => {
updateClock()
setInterval(updateClock, 1000)
getFacilityName().then(name => { facilityName.value = name })
// Load business units
try {
const response = await businessUnitsApi.list()
@@ -200,11 +299,19 @@ onMounted(async () => {
currentRecognition.value = (currentRecognition.value + 1) % recognitions.value.length
}
}, 8000)
// Cycle the recertification row through pages of employees every 7 seconds.
recertPageInterval = setInterval(() => {
if (recertPageCount.value > 1) {
currentRecertPage.value = (currentRecertPage.value + 1) % recertPageCount.value
}
}, 7000)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
if (recognitionInterval) clearInterval(recognitionInterval)
if (recertPageInterval) clearInterval(recertPageInterval)
})
async function loadData() {
@@ -214,7 +321,21 @@ async function loadData() {
params.businessunit = businessUnit.value
}
const response = await notificationsApi.getShopfloor(params)
notifications.value = response.data.data || { current: [], upcoming: [] }
const data = response.data.data || { current: [], upcoming: [] }
// Reload the kiosk when the board's layout config changes, so type/style
// edits (and deploys, via SHOPFLOOR_BUILD) reach already-open pages without
// anyone touching the machine.
const version = data.configversion
if (version) {
if (loadedConfigVersion.value && loadedConfigVersion.value !== version) {
window.location.reload()
return
}
loadedConfigVersion.value = version
}
notifications.value = { current: data.current || [], upcoming: data.upcoming || [] }
} catch (err) {
console.error('Error loading shopfloor data:', err)
} finally {
@@ -223,15 +344,17 @@ async function loadData() {
}
function getTypeColor(typecolor) {
const colors = {
// Types store a hex color, used directly. Only legacy Bootstrap color names
// still need aliasing; anything else (a hex) passes straight through.
const aliases = {
success: '#04b962',
warning: '#ff8800',
danger: '#f5365c',
info: '#14abef',
primary: '#7934f3',
recognition: '#0d6efd'
secondary: '#94614f'
}
return colors[typecolor] || typecolor || '#14abef'
return aliases[typecolor] || typecolor || '#14abef'
}
function getSectionClass(notifications) {
@@ -362,7 +485,69 @@ function handlePhotoError(e) {
}
.section-title.recognition {
background: #ffc107;
color: #3a2e00;
}
/* Recertification grid - blue, compact tiles so 20-30 people are all visible
at once (no carousel to wait through) */
.recert-section {
margin-bottom: 25px;
}
.section-title.recert-title {
background: #0d6efd;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
}
.recert-range {
font-size: 16px;
font-weight: 600;
letter-spacing: 1px;
opacity: 0.85;
}
.recert-description {
font-size: 22px;
font-weight: 600;
color: #cbd5e1;
margin: -4px 0 14px;
line-height: 1.35;
}
/* Single row that cycles through pages of employees */
.recert-row {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 14px;
}
.recert-tile {
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
border: 2px solid #0d6efd;
border-radius: 10px;
padding: 14px 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.recert-photo {
width: 90px;
height: 90px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #0d6efd;
background: #1a1a2e;
}
.recert-photo.ge-logo-fallback {
object-fit: contain;
padding: 12px;
background: #fff;
}
.recert-name {
font-size: 20px;
font-weight: 700;
line-height: 1.15;
}
.section-title.danger {
@@ -374,6 +559,22 @@ function handlePhotoError(e) {
}
/* Recognition carousel */
.banner-section {
margin-bottom: 25px;
}
.banner-strip {
padding: 22px 30px;
border-radius: 10px;
margin-bottom: 12px;
color: #fff;
font-size: 2rem;
font-weight: 700;
text-align: center;
text-wrap: balance;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.recognition-section {
margin-bottom: 25px;
}
@@ -388,8 +589,8 @@ function handlePhotoError(e) {
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
border: 3px solid #0d6efd;
background: linear-gradient(135deg, #4a3a0a 0%, #2a2200 100%);
border: 3px solid #ffc107;
border-radius: 12px;
padding: 20px 25px;
display: flex;
@@ -411,7 +612,7 @@ function handlePhotoError(e) {
height: 140px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #0d6efd;
border: 4px solid #ffc107;
background: #1a1a2e;
}
@@ -438,6 +639,7 @@ function handlePhotoError(e) {
animation: starPulse 2s ease-in-out infinite;
}
@keyframes starPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }

View File

@@ -66,12 +66,13 @@ onUnmounted(() => {
async function fetchSlides() {
try {
const response = await api.get('/slides')
// Flat feed shape: { success, surface, basepath, interval, slides:[...] }
const response = await api.get('/slides/feed', { params: { surface: 'lobby' } })
const data = response.data
if (data.success && data.data?.slides?.length > 0) {
slides.value = data.data.slides
basePath.value = data.data.basepath || '/static/slides/'
if (data.success && data.slides?.length > 0) {
slides.value = data.slides
basePath.value = data.basepath || '/api/slides/img/lobby/'
error.value = ''
// Restart slideshow if slides changed
@@ -79,7 +80,8 @@ async function fetchSlides() {
startSlideshow()
}
} else {
error.value = data.message || 'No slides found'
slides.value = []
error.value = 'No slides configured'
}
} catch (err) {
console.error('Error fetching slides:', err)

View File

@@ -75,7 +75,7 @@
<!-- Application Notes -->
<div class="section-card" v-if="app.applicationnotes">
<h3 class="section-title">Application Notes</h3>
<div class="notes-text" v-html="app.applicationnotes"></div>
<div class="notes-text">{{ app.applicationnotes }}</div>
</div>
<!-- Versions -->
@@ -326,13 +326,9 @@ function handleImageError(e) {
font-size: 1.125rem;
}
/* Notes styling */
.notes-text :deep(a) {
color: var(--primary);
text-decoration: none;
}
.notes-text :deep(a:hover) {
text-decoration: underline;
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
.notes-text {
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -129,14 +129,6 @@
</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Location Only</span>
<span class="info-value">
<span class="feature-tag" :class="{ active: equipment.equipment?.islocationonly }">
{{ equipment.equipment?.islocationonly ? 'Yes' : 'No' }}
</span>
</span>
</div>
</div>
</div>
@@ -211,6 +203,12 @@
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="equipment.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="equipment.assetid" />
<!-- Notes -->
<div class="section-card" v-if="equipment.notes">
<h3 class="section-title">Notes</h3>
@@ -237,6 +235,8 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()

View File

@@ -271,15 +271,7 @@
<input type="checkbox" v-model="form.requiresmanualconfig" />
Requires Manual Config
</label>
<small class="form-help">Multi-PC machine needs manual configuration</small>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.islocationonly" />
Location Only
</label>
<small class="form-help">Virtual location marker (not actual equipment)</small>
<small class="form-help">Machine a tech must configure by hand (e.g. driven by multiple PCs)</small>
</div>
</div>
@@ -336,6 +328,9 @@
></textarea>
</div>
<!-- Site-defined custom fields for equipment -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="EQUIPMENT_ASSETTYPEID" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
@@ -355,6 +350,7 @@ import { useRoute, useRouter } from 'vue-router'
import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
@@ -365,6 +361,11 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for equipment (see /api/assets/types).
const EQUIPMENT_ASSETTYPEID = 1
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
@@ -481,6 +482,7 @@ onMounted(async () => {
if (isEdit.value) {
const response = await equipmentApi.get(route.params.id)
const data = response.data.data
currentAssetId.value = data.assetid || null
currentEquipment.value = data
form.value = {
@@ -592,6 +594,15 @@ async function saveEquipment() {
// Handle relationship (controlling PC)
await saveRelationship(assetId)
// Persist custom-field values against the asset id.
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push(`/machines/${savedEquipment.equipment?.equipmentid || route.params.id}`)
} catch (err) {
console.error('Error saving equipment:', err)

View File

@@ -42,7 +42,7 @@
<td>{{ item.equipment?.equipmenttypename || '-' }}</td>
<td>{{ item.equipment?.vendorname || '-' }}</td>
<td>
<span class="badge" :class="getStatusClass(item.statusname)">
<span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }}
</span>
</td>
@@ -81,6 +81,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { equipmentApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
@@ -134,15 +135,6 @@ function changePerPage(newPerPage) {
page.value = 1
loadEquipment()
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair') return 'badge-warning'
if (s === 'retired') return 'badge-danger'
return 'badge-info'
}
</script>
<style scoped>

View File

@@ -18,7 +18,7 @@
</router-link>
</div>
<div class="hero-meta">
<span class="badge" :class="getStatusClass(device.statusname)">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
<span v-if="device.networkdevice?.networkdevicetypename" class="meta-item">
@@ -87,6 +87,12 @@
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="device.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="device.assetid" />
<!-- Notes -->
<div class="section-card" v-if="device.notes">
<h3 class="section-title">Notes</h3>
@@ -130,7 +136,7 @@
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value">
<span class="badge" :class="getStatusClass(device.statusname)">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
</span>
@@ -184,11 +190,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { Network, Router, Shield, Wifi, Camera, Server, Server as Rack, Globe } from 'lucide-vue-next'
import { useAuthStore } from '../../stores/auth'
import { networkApi } from '../../api'
import AssetRelationships from '../../components/AssetRelationships.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()

View File

@@ -226,6 +226,11 @@
</div>
</fieldset>
<!-- Site-defined custom fields for network devices -->
<fieldset>
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="NETWORK_ASSETTYPEID" :assetid="currentAssetId" />
</fieldset>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
@@ -250,6 +255,7 @@ import {
assetsApi,
businessunitsApi
} from '../../api'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()
@@ -260,6 +266,11 @@ const router = useRouter()
const deviceId = route.params.id
const isEdit = computed(() => !!deviceId)
// Seeded asset-type id for network devices (see /api/assets/types).
const NETWORK_ASSETTYPEID = 3
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const form = ref({
assetnumber: '',
name: '',
@@ -353,6 +364,7 @@ async function loadDevice() {
try {
const response = await networkApi.get(deviceId)
const data = response.data.data
currentAssetId.value = data.assetid || null
// Populate form with existing data
form.value.assetnumber = data.assetnumber || ''
@@ -411,14 +423,26 @@ async function submitForm() {
notes: form.value.notes || null
}
let assetId = currentAssetId.value
let redirectId = deviceId
if (isEdit.value) {
await networkApi.update(deviceId, payload)
router.push(`/network/${deviceId}`)
const response = await networkApi.update(deviceId, payload)
assetId = assetId || response.data?.data?.assetid
} else {
const response = await networkApi.create(payload)
const newId = response.data.data?.networkdevice?.networkdeviceid
router.push(newId ? `/network/${newId}` : '/network')
assetId = response.data.data?.assetid
redirectId = response.data.data?.networkdevice?.networkdeviceid
}
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push(redirectId ? `/network/${redirectId}` : '/network')
} catch (err) {
console.error('Error saving device:', err)
error.value = err.response?.data?.message || 'Failed to save device'

View File

@@ -79,7 +79,7 @@
<span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span>
</td>
<td>
<span class="badge" :class="getStatusClass(device.statusname)">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
</td>
@@ -117,6 +117,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'

View File

@@ -31,7 +31,7 @@
</div>
<div class="form-group">
<label for="notification">{{ isRecognition ? 'Recognition Message' : 'Notification' }} *</label>
<label for="notification">{{ messageLabel }} *</label>
<textarea
id="notification"
v-model="form.notification"
@@ -39,12 +39,12 @@
rows="4"
required
:disabled="isDetail"
:placeholder="isRecognition ? 'Enter the recognition message...' : 'Enter the notification message...'"
:placeholder="messagePlaceholder"
></textarea>
</div>
<!-- Employee Search - Only for Recognition -->
<div v-if="isRecognition" class="form-group">
<!-- Employee Search - for Recognition and Recertification -->
<div v-if="isEmployeeType" class="form-group">
<label>Employee(s) *</label>
<div class="employee-search-container">
<input
@@ -68,7 +68,7 @@
</div>
</div>
</div>
<small class="form-hint">Search for employees or press Enter to add a custom name</small>
<small class="form-hint">Search and pick, or paste multiple SSOs / names separated by commas and press Enter</small>
<!-- Selected Employees -->
<div v-if="selectedEmployees.length" class="selected-employees">
@@ -83,7 +83,7 @@
</div>
</div>
<div v-if="!isRecognition" class="form-group">
<div v-if="!isEmployeeType" class="form-group">
<label for="businessunitid">Business Unit</label>
<select
id="businessunitid"
@@ -103,7 +103,7 @@
<small class="form-hint">Leave blank to apply to all</small>
</div>
<div v-if="!isRecognition" class="form-group">
<div v-if="!isEmployeeType" class="form-group">
<label for="appid">Related Application</label>
<select
id="appid"
@@ -123,7 +123,7 @@
<small class="form-hint">Link to a specific application (e.g., for software updates)</small>
</div>
<div v-if="!isRecognition" class="form-group">
<div v-if="!isEmployeeType" class="form-group">
<label for="ticketnumber">Ticket Number</label>
<input
id="ticketnumber"
@@ -151,7 +151,7 @@
</div>
<!-- Time fields - Hidden for Recognition (auto-set) -->
<div v-if="!isRecognition" class="form-row">
<div v-if="!isEmployeeType" class="form-row">
<div class="form-group">
<label for="starttime">Start Time *</label>
<div class="input-group">
@@ -275,11 +275,28 @@ const form = ref({
employeesso: ''
})
// Check if selected type is Recognition
const isRecognition = computed(() => {
function selectedTypeName() {
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
return selectedType?.typename?.toLowerCase() === 'recognition'
})
return selectedType?.typename?.toLowerCase() || ''
}
// Recognition and recertification are the employee-photo types: both show the
// employee picker, hide the time/BU/app fields, and get a server-computed
// display window.
const isRecognition = computed(() => selectedTypeName() === 'recognition')
const isRecertification = computed(() => selectedTypeName() === 'recertification')
const isEmployeeType = computed(() => isRecognition.value || isRecertification.value)
const messageLabel = computed(() =>
isRecertification.value ? 'Recertification Message'
: isRecognition.value ? 'Recognition Message'
: 'Notification'
)
const messagePlaceholder = computed(() =>
isRecertification.value ? 'Enter the recertification message...'
: isRecognition.value ? 'Enter the recognition message...'
: 'Enter the notification message...'
)
onMounted(async () => {
try {
@@ -313,16 +330,18 @@ onMounted(async () => {
employeesso: n.employeesso || ''
}
// Parse existing employee data
// Parse existing employee data: SSOs are comma-joined and names are
// ", "-joined, so pair them up by index (one chip per person). Falling
// back to the SSO when a name is missing.
if (n.employeesso) {
const ssos = n.employeesso.split(',')
for (const sso of ssos) {
if (sso.trim()) {
// Try to look up the employee name
const name = n.employeename || sso.trim()
selectedEmployees.value.push({ sso: sso.trim(), name })
}
}
const names = (n.employeename || '').split(',')
ssos.forEach((rawSso, i) => {
const sso = rawSso.trim()
if (!sso) return
const name = (names[i] || '').trim() || sso
selectedEmployees.value.push({ sso, name })
})
}
} else {
// Set default start date to now
@@ -353,13 +372,12 @@ function setNow(field) {
}
function onTypeChange() {
// If switching to Recognition, auto-set times
if (isRecognition.value) {
const now = new Date()
form.value.starttime = formatDateForInput(now.toISOString())
// End time = now + 30 days
const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
form.value.endtime = formatDateForInput(endDate.toISOString())
// Recognition and recertification get a server-computed display window
// (recognition clears at 8 AM Eastern; recertification runs two weeks). Set
// start to now and leave end blank so the backend applies the per-type rule.
if (isEmployeeType.value) {
form.value.starttime = formatDateForInput(new Date().toISOString())
form.value.endtime = ''
}
}
@@ -401,15 +419,31 @@ function selectEmployee(emp) {
updateEmployeeSso()
}
function addCustomEmployee() {
const name = employeeSearch.value.trim()
if (!name) return
// Add one or many at once. The input may hold a single value or a
// comma-separated list of SSOs and/or names. Numeric tokens are treated as
// SSOs and resolved to a real name; everything else is a custom name.
async function addCustomEmployee() {
const raw = employeeSearch.value.trim()
if (!raw) return
// Add as custom name (no SSO)
selectedEmployees.value.push({
sso: `NAME:${name}`,
name: name
})
const tokens = raw.split(',').map(t => t.trim()).filter(Boolean)
for (const token of tokens) {
if (/^\d{4,}$/.test(token)) {
if (selectedEmployees.value.some(e => e.sso === token)) continue
let name = token
try {
const emp = (await employeesApi.lookup(token)).data.data
if (emp) name = `${emp.First_Name} ${emp.Last_Name}`.trim() || token
} catch (err) {
// SSO not found - keep the number as the label
}
selectedEmployees.value.push({ sso: token, name })
} else {
const key = `NAME:${token}`
if (selectedEmployees.value.some(e => e.sso === key)) continue
selectedEmployees.value.push({ sso: key, name: token })
}
}
employeeSearch.value = ''
employeeResults.value = []
@@ -428,9 +462,9 @@ function updateEmployeeSso() {
async function saveNotification() {
error.value = ''
// Validation for Recognition type
if (isRecognition.value && selectedEmployees.value.length === 0) {
error.value = 'Please select at least one employee for recognition'
// Employee-photo types require at least one employee
if (isEmployeeType.value && selectedEmployees.value.length === 0) {
error.value = `Please select at least one employee for ${isRecertification.value ? 'recertification' : 'recognition'}`
return
}
@@ -451,8 +485,8 @@ async function saveNotification() {
employeesso: form.value.employeesso || null
}
// For recognition, also send employeename
if (isRecognition.value && selectedEmployees.value.length > 0) {
// For employee-photo types, also send employeename
if (isEmployeeType.value && selectedEmployees.value.length > 0) {
data.employeename = selectedEmployees.value.map(e => e.name).join(', ')
}

View File

@@ -0,0 +1,349 @@
<template>
<div>
<div class="page-header">
<h1>Notification Types</h1>
<div class="actions">
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
<button class="btn btn-primary" @click="openNew">New Type</button>
</div>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Color</th>
<th>Display style</th>
<th>Employee</th>
<th>Auto-expiry</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in types" :key="t.notificationtypeid">
<td>
<strong>{{ t.typename }}</strong>
<div v-if="t.typedescription" class="muted">{{ t.typedescription }}</div>
</td>
<td>
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
<span class="mono">{{ t.typecolor }}</span>
</td>
<td><span class="badge">{{ t.displaystyle || 'standard' }}</span></td>
<td>
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
<span v-if="!t.splitperemployee && !t.showemployeephoto" class="muted">-</span>
</td>
<td>{{ expiryLabel(t) }}</td>
<td>
<span class="badge" :class="t.isactive ? 'badge-success' : 'badge-secondary'">
{{ t.isactive ? 'yes' : 'no' }}
</span>
</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openEdit(t)">Edit</button>
</td>
</tr>
<tr v-if="!loading && !types.length">
<td colspan="7" class="muted" style="text-align:center;">No notification types.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Editor -->
<div v-if="editing" class="modal-overlay" @click.self="close">
<div class="modal-panel">
<h2>{{ form.notificationtypeid ? 'Edit' : 'New' }} Notification Type</h2>
<div class="form-grid">
<label class="field">
<span>Name</span>
<input v-model="form.typename" type="text" maxlength="50" placeholder="e.g. Safety Alert" />
</label>
<label class="field">
<span>Description</span>
<input v-model="form.typedescription" type="text" placeholder="Shown to editors" />
</label>
<label class="field">
<span>Color</span>
<ColorSwatchPicker v-model="form.typecolor" />
</label>
<label class="field">
<span>Display style</span>
<select v-model="form.displaystyle">
<option value="standard">Standard rows</option>
<option value="carousel">Carousel (rotating photo card)</option>
<option value="grid">Grid (cycling row of tiles)</option>
<option value="banner">Banner (full-width strip)</option>
</select>
</label>
<label class="field checkbox">
<input v-model="form.splitperemployee" type="checkbox" />
<span>Split one card per employee</span>
</label>
<label class="field checkbox">
<input v-model="form.showemployeephoto" type="checkbox" />
<span>Show employee photo + name (HR lookup)</span>
</label>
<label class="field">
<span>Auto-expiry</span>
<select v-model="form.expirymode">
<option value="none">None (stays until end time / indefinite)</option>
<option value="duration">Duration (N days after posting)</option>
<option value="dailytime">Daily reset (clears at an hour, Eastern)</option>
</select>
</label>
<label v-if="form.expirymode === 'duration'" class="field">
<span>Days</span>
<input v-model.number="form.expirydays" type="number" min="1" />
</label>
<label v-if="form.expirymode === 'dailytime'" class="field">
<span>Hour (0-23, Eastern)</span>
<input v-model.number="form.expiryhour" type="number" min="0" max="23" />
</label>
<label class="field checkbox">
<input v-model="form.isactive" type="checkbox" />
<span>Active</span>
</label>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="close">Cancel</button>
<button class="btn btn-primary" :disabled="saving || !form.typename" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { notificationsApi } from '@/api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
const types = ref([])
const loading = ref(true)
const editing = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({})
// Keyword typecolors the shopfloor board maps to fixed accent colors; anything
// else is a literal hex.
const KEYWORD_COLORS = {
recognition: '#ffc107',
recertification: '#0d6efd',
training: '#17a2b8',
success: '#04b962',
warning: '#ff8800',
danger: '#f5365c',
info: '#14abef',
primary: '#7934f3'
}
function isHex(c) {
return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)
}
function swatchColor(c) {
if (!c) return '#888'
return KEYWORD_COLORS[c] || c
}
function expiryLabel(t) {
if (t.expirymode === 'duration' && t.expirydays) return `${t.expirydays} day(s)`
if (t.expirymode === 'dailytime') return `daily @ ${String(t.expiryhour ?? 8).padStart(2, '0')}:00 ET`
return 'none'
}
async function load() {
loading.value = true
try {
const response = await notificationsApi.types.list()
types.value = response.data.data || []
} catch (err) {
console.error('Error loading notification types:', err)
} finally {
loading.value = false
}
}
function openNew() {
error.value = ''
form.value = {
typename: '',
typedescription: '',
typecolor: '#17a2b8',
displaystyle: 'standard',
splitperemployee: false,
showemployeephoto: false,
expirymode: 'none',
expirydays: null,
expiryhour: null,
isactive: true
}
editing.value = true
}
function openEdit(t) {
error.value = ''
form.value = {
notificationtypeid: t.notificationtypeid,
typename: t.typename || '',
typedescription: t.typedescription || '',
typecolor: t.typecolor || '#17a2b8',
displaystyle: t.displaystyle || 'standard',
splitperemployee: !!t.splitperemployee,
showemployeephoto: !!t.showemployeephoto,
expirymode: t.expirymode || 'none',
expirydays: t.expirydays ?? null,
expiryhour: t.expiryhour ?? null,
isactive: t.isactive !== false
}
editing.value = true
}
function close() {
editing.value = false
}
async function save() {
saving.value = true
error.value = ''
const payload = { ...form.value }
try {
if (payload.notificationtypeid) {
await notificationsApi.types.update(payload.notificationtypeid, payload)
} else {
await notificationsApi.types.create(payload)
}
editing.value = false
await load()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Save failed.'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.muted {
color: var(--text-light);
font-size: 0.85rem;
}
.mono {
font-family: monospace;
font-size: 0.85rem;
}
.swatch {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 3px;
vertical-align: middle;
margin-right: 6px;
border: 1px solid var(--border);
}
.swatch.lg {
width: 28px;
height: 28px;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
overflow-y: auto;
z-index: 1000;
}
.modal-panel {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
width: 100%;
max-width: 560px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.modal-panel h2 {
margin: 0 0 18px;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-size: 0.85rem;
color: var(--text-light);
}
.field input[type="text"],
.field input[type="number"],
.field select {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.field.checkbox {
flex-direction: row;
align-items: center;
gap: 8px;
}
.field.checkbox > span {
color: var(--text);
font-size: 1rem;
}
.color-row {
display: flex;
align-items: center;
gap: 10px;
}
.color-row input[type="text"] {
flex: 1;
}
.error {
color: var(--danger);
margin: 12px 0 0;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
</style>

View File

@@ -1,7 +1,10 @@
<template>
<div class="page-header">
<h1>Notifications</h1>
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
<div class="actions">
<router-link to="/settings/notificationtypes" class="btn btn-secondary">Manage Types</router-link>
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
</div>
</div>
<div class="filters">
@@ -45,7 +48,7 @@
<tbody>
<tr v-for="notification in notifications" :key="notification.notificationid">
<td>
<router-link :to="`/notifications/${notification.notificationid}`">
<router-link :to="`/notifications/${notification.notificationid}/edit`">
{{ notification.title }}
</router-link>
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>

View File

@@ -20,7 +20,7 @@
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :class="getStatusClass(computer.statusname)">
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }}
</span>
</div>
@@ -84,6 +84,14 @@
<span class="info-label">Computer Type</span>
<span class="info-value">{{ computer.computer?.computertypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ computer.computer?.vendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ computer.computer?.modelname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Operating System</span>
<span class="info-value">{{ computer.computer?.osname || '-' }}</span>
@@ -91,36 +99,58 @@
</div>
</div>
<!-- PC Status -->
<!-- Remote Access -->
<div class="section-card">
<h3 class="section-title">Status</h3>
<div class="info-list">
<div class="info-row" v-if="computer.computer?.loggedinuser">
<span class="info-label">Logged In User</span>
<span class="info-value">{{ computer.computer.loggedinuser }}</span>
</div>
<div class="info-row">
<span class="info-label">Features</span>
<span class="info-value">
<span class="feature-tag" :class="{ active: computer.computer?.isvnc }">VNC</span>
<span class="feature-tag" :class="{ active: computer.computer?.iswinrm }">WinRM</span>
<span class="feature-tag" :class="{ active: computer.computer?.isshopfloor }">Shopfloor</span>
</span>
</div>
<div class="info-row" v-if="computer.computer?.lastreporteddate">
<span class="info-label">Last Reported</span>
<span class="info-value">{{ formatDate(computer.computer.lastreporteddate) }}</span>
</div>
<div class="info-row" v-if="computer.computer?.lastboottime">
<span class="info-label">Last Boot</span>
<span class="info-value">{{ formatDate(computer.computer.lastboottime) }}</span>
</div>
<h3 class="section-title">Remote Access</h3>
<div class="access-methods">
<template v-for="a in (computer.accessmethods || [])" :key="a.id">
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link">{{ a.name }}</a>
<span v-else class="access-link disabled" title="No hostname/IP set for this PC">{{ a.name }}</span>
</template>
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Network -->
<div class="section-card">
<h3 class="section-title">Network</h3>
<div v-if="computer.communications?.length" class="network-list">
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
<div class="network-primary">
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
</div>
<div class="network-secondary" v-if="comm.macaddress">
<span class="mac-address mono">{{ comm.macaddress }}</span>
</div>
</div>
</div>
<p v-else class="muted">No network addresses on record</p>
</div>
<!-- Status / Check-in -->
<div class="section-card">
<h3 class="section-title">Status</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Logged In User</span>
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Reported</span>
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Boot</span>
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
</div>
</div>
</div>
<!-- Location -->
<div class="section-card">
<h3 class="section-title">Location</h3>
@@ -189,6 +219,12 @@
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
@@ -212,9 +248,12 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute } from 'vue-router'
import { computersApi, applicationsApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
@@ -301,6 +340,36 @@ function formatDate(dateStr) {
<style scoped>
/* PC-specific styles - shared styles are in global style.css */
/* Remote-access protocol links */
.access-methods {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.access-methods .muted {
color: var(--text-light);
}
.access-link {
display: inline-block;
padding: 3px 12px;
margin: 0 6px 4px 0;
border-radius: 14px;
background: var(--primary);
color: #fff;
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
}
.access-link:hover {
background: var(--primary-dark);
text-decoration: none;
}
.access-link.disabled {
background: var(--secondary);
opacity: 0.5;
cursor: not-allowed;
}
/* Installed Applications */
.app-list {
display: flex;
@@ -370,4 +439,46 @@ function formatDate(dateStr) {
font-size: 1rem;
color: var(--text-light);
}
/* Network card (IP / MAC list) */
.network-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.network-item {
padding: 0.5rem 0.7rem;
background: var(--bg);
border-radius: 6px;
}
.network-primary {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.ip-address {
font-weight: 600;
color: var(--text);
}
.comm-type {
font-size: 0.8rem;
color: var(--text-light);
}
.primary-badge {
padding: 0.1rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
background: var(--primary);
color: #fff;
border-radius: 10px;
}
.mac-address {
font-size: 0.82rem;
color: var(--text-light);
}
.muted {
color: var(--text-light);
margin: 0;
}
</style>

View File

@@ -223,16 +223,25 @@
</div>
</div>
<div class="form-row">
<div class="form-group" style="display: flex; align-items: flex-end; gap: 1.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<input type="checkbox" v-model="form.isvnc" />
VNC Enabled
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<input type="checkbox" v-model="form.iswinrm" />
WinRM Enabled
<div class="form-group">
<label>Remote Access Protocols</label>
<div class="protocol-list">
<label v-for="p in protocols" :key="p.protocolid" class="protocol-item">
<input type="checkbox" :checked="isProtocolOn(p.protocolid)" @change="toggleProtocol(p.protocolid, $event.target.checked)" />
<span>{{ p.name }}</span>
<input
v-if="isProtocolOn(p.protocolid)"
type="number"
class="port-override"
:value="protocolPort(p.protocolid)"
:placeholder="p.defaultport || 'port'"
min="1"
max="65535"
title="Port override (blank = default)"
@input="setProtocolPort(p.protocolid, $event.target.value)"
/>
</label>
<span v-if="!protocols.length" class="muted">No protocols defined. Add them under Settings &gt; PC Access Protocols.</span>
</div>
</div>
@@ -276,6 +285,9 @@
</template>
</Modal>
<!-- Site-defined custom fields for computers -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="COMPUTER_ASSETTYPEID" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
@@ -295,6 +307,7 @@ import { useRoute, useRouter } from 'vue-router'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
@@ -305,6 +318,12 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for computers (see /api/assets/types). Custom-field
// values are keyed by the underlying asset id, captured on load / create.
const COMPUTER_ASSETTYPEID = 2
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
// PC Number (assetnumber) defaults to the serial number while the user hasn't
// typed their own. Editable; only auto-fills on a new PC.
const manualPcNumber = ref(false)
@@ -332,8 +351,7 @@ const form = ref({
locationid: '',
osid: '',
loggedinuser: '',
isvnc: false,
iswinrm: false,
accessmethods: [],
notes: '',
mapx: null,
mapy: null,
@@ -341,7 +359,33 @@ const form = ref({
})
const pcTypes = ref([])
const protocols = ref([])
const statuses = ref([])
// Access-method editor helpers (form.accessmethods = [{protocolid, portoverride}])
function isProtocolOn(protocolid) {
return form.value.accessmethods.some(a => a.protocolid === protocolid)
}
function protocolPort(protocolid) {
const found = form.value.accessmethods.find(a => a.protocolid === protocolid)
return found && found.portoverride != null ? found.portoverride : ''
}
function toggleProtocol(protocolid, on) {
if (on) {
if (!isProtocolOn(protocolid)) {
form.value.accessmethods.push({ protocolid, portoverride: null })
}
} else {
form.value.accessmethods = form.value.accessmethods.filter(a => a.protocolid !== protocolid)
}
}
function setProtocolPort(protocolid, value) {
const found = form.value.accessmethods.find(a => a.protocolid === protocolid)
if (found) {
const n = parseInt(value, 10)
found.portoverride = Number.isFinite(n) ? n : null
}
}
const vendors = ref([])
const models = ref([])
const locations = ref([])
@@ -366,13 +410,14 @@ onMounted(async () => {
try {
// Load reference data
// perpage 100 so dropdowns aren't truncated to the default 20-row page
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes] = await Promise.all([
computersApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 100 }),
modelsApi.listAll(), // backend caps perpage at 100; page through all
locationsApi.list({ perpage: 100 }),
operatingsystemsApi.list({ perpage: 100 })
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list()
])
pcTypes.value = ptRes.data.data || []
@@ -381,12 +426,14 @@ onMounted(async () => {
models.value = allModels
locations.value = locRes.data.data || []
operatingsystems.value = osRes.data.data || []
protocols.value = protoRes.data.data || []
// Load PC if editing (asset-based shape: extension under pc.computer)
if (isEdit.value) {
const response = await computersApi.get(route.params.id)
const pc = response.data.data
const ext = pc.computer || {}
currentAssetId.value = pc.assetid || null
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
@@ -404,8 +451,10 @@ onMounted(async () => {
locationid: pc.locationid || '',
osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '',
isvnc: ext.isvnc || false,
iswinrm: ext.iswinrm || false,
accessmethods: (pc.accessmethods || []).map(a => ({
protocolid: a.protocolid,
portoverride: a.portoverride ?? null
})),
notes: pc.notes || '',
mapx: pc.mapx ?? null,
mapy: pc.mapy ?? null,
@@ -458,8 +507,7 @@ async function savePC() {
locationid: form.value.locationid || null,
osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser || null,
isvnc: form.value.isvnc,
iswinrm: form.value.iswinrm,
accessmethods: form.value.accessmethods,
notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null,
mapx: form.value.mapx,
@@ -470,10 +518,22 @@ async function savePC() {
payload.name = form.value.alias
}
let assetId = currentAssetId.value
if (isEdit.value) {
await computersApi.update(route.params.id, payload)
const response = await computersApi.update(route.params.id, payload)
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
} else {
await computersApi.create(payload)
const response = await computersApi.create(payload)
assetId = response.data?.data?.assetid || response.data?.data?.asset?.assetid
}
// Persist any custom-field values now that we have an asset id.
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push('/pcs')
@@ -487,6 +547,28 @@ async function savePC() {
</script>
<style scoped>
.protocol-list {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.protocol-item {
display: flex;
align-items: center;
gap: 6px;
}
.protocol-item .port-override {
width: 78px;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.muted {
color: var(--text-light);
}
.map-location-control {
display: flex;
align-items: center;

View File

@@ -28,7 +28,7 @@
<th>Hostname</th>
<th>Serial Number</th>
<th>Type</th>
<th>Features</th>
<th>Remote Access</th>
<th>Status</th>
<th>Location</th>
<th>Actions</th>
@@ -41,12 +41,14 @@
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertypename || '-' }}</td>
<td class="features">
<span v-if="item.computer?.isvnc" class="feature-tag active">VNC</span>
<span v-if="item.computer?.iswinrm" class="feature-tag active">WinRM</span>
<span v-if="!item.computer?.isvnc && !item.computer?.iswinrm">-</span>
<template v-for="a in (item.accessmethods || [])" :key="a.id">
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
</template>
<span v-if="!(item.accessmethods || []).length">-</span>
</td>
<td>
<span class="badge" :class="getStatusClass(item.statusname)">
<span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }}
</span>
</td>
@@ -86,6 +88,7 @@
import { ref, onMounted } from 'vue'
import { computersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle'
const computers = ref([])
const loading = ref(true)
@@ -138,17 +141,30 @@ function changePerPage(newPerPage) {
loadComputers()
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair') return 'badge-warning'
if (s === 'retired') return 'badge-danger'
return 'badge-info'
}
</script>
<style scoped>
.access-link {
display: inline-block;
padding: 2px 10px;
margin: 0 4px 3px 0;
border-radius: 12px;
background: var(--primary);
color: #fff;
font-size: 0.78rem;
font-weight: 600;
text-decoration: none;
}
.access-link:hover {
background: var(--primary-dark);
text-decoration: none;
}
.access-link.disabled {
background: var(--secondary);
opacity: 0.5;
cursor: not-allowed;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}

View File

@@ -66,6 +66,7 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '../../api'
import { renderQrDataUrl } from './qrLogo'
import { getSiteBaseUrl } from '@/utils/siteSettings'
const printers = ref([])
const selectedPrinters = ref([])
@@ -105,6 +106,7 @@ watch(selectedPrinters, async () => {
}, { deep: true })
async function generateQRCodes() {
const baseUrl = await getSiteBaseUrl()
const next = {}
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
const page = pages.value[pageIdx]
@@ -112,7 +114,7 @@ async function generateQRCodes() {
const printer = page[idx]
if (!printer) continue
const pos = idx + 1
const qrUrl = `${window.location.origin}/printers/${printer.printer?.printerid || printer.assetid}`
const qrUrl = `${baseUrl}/printers/${printer.printer?.printerid || printer.assetid}`
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
}
}

View File

@@ -48,6 +48,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '../../api'
import { renderQrDataUrl } from './qrLogo'
import { getSiteBaseUrl } from '@/utils/siteSettings'
const route = useRoute()
const loading = ref(true)
@@ -86,7 +87,8 @@ watch(position, async () => {
async function generateQR() {
if (!printer.value) return
const qrUrl = `${window.location.origin}/printers/${printer.value.printer?.printerid || printer.value.assetid}`
const baseUrl = await getSiteBaseUrl()
const qrUrl = `${baseUrl}/printers/${printer.value.printer?.printerid || printer.value.assetid}`
qrImage.value = await renderQrDataUrl(qrUrl)
}

View File

@@ -117,6 +117,12 @@
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="printer.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="printer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="printer.notes">
<h3 class="section-title">Notes</h3>
@@ -209,32 +215,34 @@
</div>
</div>
<!-- Drivers Card -->
<!-- Drivers Card: pulled from the driver catalog by this printer's model -->
<div class="card">
<div class="card-header">
<h3>Assigned Drivers</h3>
<h3>Drivers</h3>
</div>
<div v-if="drivers.length === 0" class="empty-state">
No drivers assigned
No drivers for this model. Add one under Settings &gt; Printer Drivers and
link it to this printer's model.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Driver Name</th>
<th>OS Type</th>
<th>Version</th>
<th>Universal</th>
<th>Name</th>
<th>Location</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr v-for="driver in drivers" :key="driver.driverid">
<td>{{ driver.drivername }}</td>
<td>{{ driver.ostype }}</td>
<td>{{ driver.version || '-' }}</td>
<td>{{ driver.isuniversal ? 'Yes' : 'No' }}</td>
<td><strong>{{ driver.name }}</strong></td>
<td>
<a v-if="isHttp(driver.location)" :href="driver.location" target="_blank" class="mono">{{ driver.location }}</a>
<span v-else class="mono">{{ driver.location }}</span>
</td>
<td>{{ driver.description || '-' }}</td>
</tr>
</tbody>
</table>
@@ -253,6 +261,8 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
@@ -273,6 +283,10 @@ const displayTitle = computed(() => {
return p.printer?.windowsname || p.printer?.hostname || p.assetnumber
})
function isHttp(loc) {
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
}
// Get IP address from communications
const ipAddress = computed(() => {
if (!printer.value?.communications) return null
@@ -282,16 +296,16 @@ const ipAddress = computed(() => {
onMounted(async () => {
try {
const [printerRes, suppliesRes, driversRes] = await Promise.all([
const [printerRes, suppliesRes] = await Promise.all([
printersApi.get(route.params.id),
printersApi.getSupplies(route.params.id).catch(() => ({ data: { data: [] } })),
printersApi.getDrivers(route.params.id).catch(() => ({ data: { data: [] } }))
printersApi.getSupplies(route.params.id).catch(() => ({ data: { data: [] } }))
])
printer.value = printerRes.data.data
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
supplies.value = suppliesRes.data.data?.supplies || []
drivers.value = driversRes.data.data || []
// drivers are attached to the printer detail, matched by model
drivers.value = printerRes.data.data?.drivers || []
} catch (error) {
console.error('Error loading printer:', error)
} finally {
@@ -330,6 +344,8 @@ function formatDate(dateStr) {
}
/* Supplies */
.mono { font-family: monospace; font-size: 0.85rem; word-break: break-all; }
.supplies-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));

View File

@@ -273,6 +273,9 @@
</template>
</Modal>
<!-- Site-defined custom fields for printers -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="PRINTER_ASSETTYPEID" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
@@ -292,6 +295,7 @@ import { useRoute, useRouter } from 'vue-router'
import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
@@ -301,6 +305,11 @@ const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for printers (see /api/assets/types).
const PRINTER_ASSETTYPEID = 4
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const manualHostname = ref(false)
const manualWindowsName = ref(false)
@@ -473,6 +482,7 @@ onMounted(async () => {
if (isEdit.value) {
const response = await printersApi.get(route.params.id)
const printer = response.data.data
currentAssetId.value = printer.assetid || null
// asset-based shape: printer extension fields live under printer.printer
const ext = printer.printer || {}
@@ -566,10 +576,21 @@ async function savePrinter() {
payload.name = form.value.alias
}
let assetId = currentAssetId.value
if (isEdit.value) {
await printersApi.update(route.params.id, payload)
const response = await printersApi.update(route.params.id, payload)
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
} else {
await printersApi.create(payload)
const response = await printersApi.create(payload)
assetId = response.data?.data?.assetid || response.data?.data?.asset?.assetid
}
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push('/printers')

View File

@@ -9,6 +9,11 @@
<p>View printers with low or critical toner/supply levels</p>
<span class="badge">Printers</span>
</div>
<div class="report-card card" @click="router.push('/reports/warranty')">
<h3>Warranty Report</h3>
<p>Assets bucketed by coverage: expired, expiring soon, active</p>
<span class="badge">Warranty</span>
</div>
<div v-for="report in reports" :key="report.id" class="report-card card" @click="runReport(report)">
<h3>{{ report.name }}</h3>
<p>{{ report.description }}</p>

View File

@@ -0,0 +1,103 @@
<template>
<div>
<div class="page-header">
<h1>Warranty Report</h1>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="summary-row">
<div v-for="b in bucketOrder" :key="b.key" class="summary-card" :style="cardStyle(b.color)">
<span class="summary-count">{{ counts[b.key] || 0 }}</span>
<span class="summary-label">{{ b.label }}</span>
</div>
</div>
<template v-for="b in bucketOrder" :key="b.key">
<div class="bucket card" v-if="(buckets[b.key] || []).length">
<h3 class="bucket-title">
<span class="dot" :style="{ background: b.color }"></span>
{{ b.label }} ({{ (buckets[b.key] || []).length }})
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Service Level</th>
<th>Ends</th>
<th>Covers</th>
</tr>
</thead>
<tbody>
<tr v-for="w in buckets[b.key]" :key="w.warrantyid">
<td><strong>{{ w.vendor }}</strong></td>
<td>{{ w.servicelevel || '-' }}</td>
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
<td>
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip">{{ a.assetnumber }}</router-link>
<span v-if="!w.assets.length" class="muted">-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { warrantyApi } from '../../api'
const loading = ref(true)
const counts = ref({})
const buckets = ref({})
const bucketOrder = [
{ key: 'expired', label: 'Expired', color: '#F44336' },
{ key: 'expiring', label: 'Expiring Soon', color: '#FF9800' },
{ key: 'active', label: 'Active', color: '#4CAF50' },
{ key: 'unknown', label: 'Unknown', color: '#9E9E9E' },
]
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', equipment: '/machines/' }
return (map[a.assettypename] || '/assets/') + a.assetid
}
onMounted(async () => {
try {
const response = await warrantyApi.report()
counts.value = response.data.data.counts || {}
buckets.value = response.data.data.buckets || {}
} catch (err) {
console.error('Error loading warranty report:', err)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.summary-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.summary-card {
flex: 1; min-width: 140px; padding: 1rem 1.25rem; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px; display: flex; flex-direction: column; gap: 0.25rem;
}
.summary-count { font-size: 1.8rem; font-weight: 700; color: var(--text); }
.summary-label { font-size: 0.85rem; color: var(--text-light); }
.bucket { margin-bottom: 1.25rem; }
.bucket-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.asset-chip {
display: inline-block; padding: 0.15rem 0.55rem; margin: 0 0.25rem 0.25rem 0;
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
}
.muted { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,219 @@
<template>
<div>
<div class="page-header">
<h1>PC Access Protocols</h1>
<div class="actions">
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
<button class="btn btn-primary" @click="openNew">New Protocol</button>
</div>
</div>
<div class="card">
<p class="hint">
Remote-access protocols offered on PCs. Links are built as
<code>{{ '{scheme}://{hostname}.<pc_access_domain>:{port}' }}</code> from
each protocol's template. Set the domain in
<router-link to="/settings/site">Site &amp; Facility</router-link>.
</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Scheme</th>
<th>Default port</th>
<th>Link template</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in protocols" :key="p.protocolid">
<td><strong>{{ p.name }}</strong></td>
<td class="mono">{{ p.scheme }}</td>
<td>{{ p.defaultport ?? '-' }}</td>
<td class="mono">{{ p.linktemplate }}</td>
<td>
<span class="badge" :class="p.isactive ? 'badge-success' : 'badge-secondary'">
{{ p.isactive ? 'yes' : 'no' }}
</span>
</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openEdit(p)">Edit</button>
<button class="btn btn-sm btn-danger" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="!loading && !protocols.length">
<td colspan="6" class="muted" style="text-align:center;">No protocols.</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="editing" class="modal-overlay" @click.self="close">
<div class="modal-panel">
<h2>{{ form.protocolid ? 'Edit' : 'New' }} Protocol</h2>
<div class="form-grid">
<label class="field">
<span>Name</span>
<input v-model="form.name" type="text" maxlength="50" placeholder="e.g. VNC" />
</label>
<label class="field">
<span>Scheme</span>
<input v-model="form.scheme" type="text" maxlength="20" placeholder="vnc / rdp / https / ssh" />
</label>
<label class="field">
<span>Default port</span>
<input v-model.number="form.defaultport" type="number" min="1" max="65535" placeholder="5900" />
</label>
<label class="field">
<span>Link template</span>
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="vnc://{host}:{port}" />
<small class="muted">Placeholders: <code>{host}</code>, <code>{port}</code>, <code>{scheme}</code></small>
</label>
<label class="field checkbox">
<input v-model="form.isactive" type="checkbox" />
<span>Active</span>
</label>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="close">Cancel</button>
<button class="btn btn-primary" :disabled="saving || !formValid" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { computersApi } from '@/api'
const protocols = ref([])
const loading = ref(true)
const editing = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({})
const formValid = computed(() =>
form.value.name && form.value.scheme && form.value.linktemplate
)
async function load() {
loading.value = true
try {
const response = await computersApi.protocols.list({ active: false })
protocols.value = response.data.data || []
} catch (err) {
console.error('Error loading protocols:', err)
} finally {
loading.value = false
}
}
function openNew() {
error.value = ''
form.value = { name: '', scheme: '', defaultport: null, linktemplate: '', isactive: true }
editing.value = true
}
function openEdit(p) {
error.value = ''
form.value = {
protocolid: p.protocolid,
name: p.name,
scheme: p.scheme,
defaultport: p.defaultport ?? null,
linktemplate: p.linktemplate,
isactive: p.isactive !== false
}
editing.value = true
}
function close() {
editing.value = false
}
async function save() {
saving.value = true
error.value = ''
try {
if (form.value.protocolid) {
await computersApi.protocols.update(form.value.protocolid, form.value)
} else {
await computersApi.protocols.create(form.value)
}
editing.value = false
await load()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Save failed.'
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete protocol "${p.name}"? (kept but deactivated if any PC uses it)`)) return
try {
await computersApi.protocols.remove(p.protocolid)
await load()
} catch (err) {
console.error('Error deleting protocol:', err)
}
}
onMounted(load)
</script>
<style scoped>
.hint {
color: var(--text-light);
font-size: 0.9rem;
margin: 0 0 14px;
}
.mono { font-family: monospace; font-size: 0.85rem; }
.muted { color: var(--text-light); }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
overflow-y: auto;
z-index: 1000;
}
.modal-panel {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
width: 100%;
max-width: 520px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.modal-panel h2 { margin: 0 0 18px; }
.form-grid { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field > span { font-size: 0.85rem; color: var(--text-light); }
.field input[type="text"],
.field input[type="number"] {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.field.checkbox { flex-direction: row; align-items: center; gap: 8px; }
.field.checkbox > span { color: var(--text); font-size: 1rem; }
.error { color: var(--danger); margin: 12px 0 0; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div>
<div class="page-header">
<h2>Asset Type Colors</h2>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<p class="hint">
Top-level asset categories are defined by their plugins, so you can set
their color + description here (used for map markers/legend), but not add
or remove them.
</p>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.assettypeid">
<td><strong>{{ t.assettype }}</strong></td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>Edit {{ form.assettype }}</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { assetsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const loading = ref(true)
const showModal = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({})
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await assetsApi.types.list()
items.value = response.data.data || []
} catch (err) {
console.error('Error loading asset types:', err)
} finally {
loading.value = false
}
}
function openModal(t) {
form.value = { assettypeid: t.assettypeid, assettype: t.assettype, description: t.description || '', color: t.color || '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false }
async function save() {
error.value = ''
saving.value = true
try {
await assetsApi.types.update(form.value.assettypeid, { description: form.value.description, color: form.value.color })
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
.muted { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div>
<div class="page-header">
<h2>Custom Fields</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" :disabled="!assettypeid" @click="openModal()">+ Add Field</button>
</div>
<div class="card">
<p class="hint">
Define extra attributes per asset type. They appear on that asset's detail
page and edit form. Use these instead of asking for a schema change.
</p>
<div class="form-group type-picker">
<label>Asset Type</label>
<select v-model="assettypeid" class="form-control" @change="loadFields">
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettypeid">
{{ typeLabel(t) }}
</option>
</select>
</div>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Label</th>
<th>Key</th>
<th>Type</th>
<th>On Detail</th>
<th>On Form</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="f in visibleFields" :key="f.fieldid">
<td><strong>{{ f.label }}</strong></td>
<td class="mono">{{ f.fieldkey }}</td>
<td>
{{ f.datatype }}
<span v-if="f.datatype === 'select' && f.options.length" class="muted">({{ f.options.join(', ') }})</span>
</td>
<td>{{ f.showondetail ? 'yes' : '-' }}</td>
<td>{{ f.showonform ? 'yes' : '-' }}</td>
<td>{{ f.sortorder }}</td>
<td>
<span class="badge" :class="f.isactive ? 'badge-success' : 'badge-secondary'">{{ f.isactive ? 'yes' : 'no' }}</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(f)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteField(f)">Delete</button>
</td>
</tr>
<tr v-if="visibleFields.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Field</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Label *</label>
<input v-model="form.label" type="text" class="form-control" maxlength="150" required />
</div>
<div class="form-group">
<label>Data Type</label>
<select v-model="form.datatype" class="form-control">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Yes / No</option>
<option value="select">Dropdown</option>
</select>
</div>
<div class="form-group" v-if="form.datatype === 'select'">
<label>Options <span class="hint">(one per line or comma-separated)</span></label>
<textarea v-model="form.options" class="form-control" rows="3" placeholder="Bronze&#10;Silver&#10;Gold"></textarea>
</div>
<div class="form-row">
<label class="checkbox-label"><input type="checkbox" v-model="form.showondetail" /> Show on detail page</label>
<label class="checkbox-label"><input type="checkbox" v-model="form.showonform" /> Show on edit form</label>
</div>
<div class="form-row">
<div class="form-group">
<label>Sort Order</label>
<input v-model.number="form.sortorder" type="number" class="form-control" style="width: 90px;" />
</div>
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { assetsApi, customFieldsApi } from '../../api'
const assetTypes = ref([])
const assettypeid = ref(null)
const items = ref([])
const showInactive = ref(false)
const visibleFields = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(false)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(blankForm())
function blankForm() {
return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, sortorder: 0, isactive: true }
}
function typeLabel(t) {
const name = t.assettype || t.typename || t.name || `Type ${t.assettypeid}`
return name.charAt(0).toUpperCase() + name.slice(1).replace('_', ' ')
}
onMounted(async () => {
try {
const response = await assetsApi.types.list()
assetTypes.value = response.data.data || []
if (assetTypes.value.length) {
assettypeid.value = assetTypes.value[0].assettypeid
await loadFields()
}
} catch (err) {
console.error('Error loading asset types:', err)
}
})
async function loadFields() {
if (!assettypeid.value) return
loading.value = true
try {
const response = await customFieldsApi.list({ assettypeid: assettypeid.value, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading fields:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? {
label: item.label || '',
datatype: item.datatype || 'text',
options: (item.options || []).join('\n'),
showondetail: item.showondetail !== false,
showonform: item.showonform !== false,
sortorder: item.sortorder || 0,
isactive: item.isactive !== false,
}
: blankForm()
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const payload = { ...form.value, assettypeid: assettypeid.value }
if (editing.value) {
await customFieldsApi.update(editing.value.fieldid, payload)
} else {
await customFieldsApi.create(payload)
}
closeModal()
loadFields()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteField(f) {
if (!confirm(`Delete field "${f.label}"? Stored values for it will be removed.`)) return
try {
await customFieldsApi.remove(f.fieldid)
loadFields()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>
<style scoped>
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
.muted { color: var(--text-light); }
.mono { font-family: monospace; font-size: 0.85rem; }
.type-picker { max-width: 280px; }
.form-row { display: flex; gap: 1.25rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
.checkbox-label { display: inline-flex; align-items: center; gap: 0.4rem; }
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Equipment Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Equipment Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Equipment Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.equipmenttypeid">
<td>{{ t.equipmenttype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No equipment types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Equipment Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Equipment Type *</label>
<input v-model="form.equipmenttype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { equipmentApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ equipmenttype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await equipmentApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading equipment types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { equipmenttype: item.equipmenttype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { equipmenttype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await equipmentApi.types.update(editing.value.equipmenttypeid, form.value)
} else {
await equipmentApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete equipment type "${t.equipmenttype}"?`)) return
try {
await equipmentApi.types.remove(t.equipmenttypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Location Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Location Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Location Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.locationtypeid">
<td>{{ t.locationtype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No location types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Location Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Location Type *</label>
<input v-model="form.locationtype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { locationsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ locationtype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await locationsApi.types.list({ active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading location types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { locationtype: item.locationtype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { locationtype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await locationsApi.types.update(editing.value.locationtypeid, form.value)
} else {
await locationsApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete location type "${t.locationtype}"?`)) return
try {
await locationsApi.types.remove(t.locationtypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Network Device Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Network Device Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Network Device Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.networkdevicetypeid">
<td>{{ t.networkdevicetype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No network device types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Network Device Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Network Device Type *</label>
<input v-model="form.networkdevicetype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { networkApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ networkdevicetype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await networkApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading network device types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { networkdevicetype: item.networkdevicetype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { networkdevicetype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await networkApi.types.update(editing.value.networkdevicetypeid, form.value)
} else {
await networkApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete network device type "${t.networkdevicetype}"?`)) return
try {
await networkApi.types.remove(t.networkdevicetypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -2,6 +2,7 @@
<div>
<div class="page-header">
<h2>PC Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
</div>
@@ -15,19 +16,25 @@
<tr>
<th>PC Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="pt in pcTypes" :key="pt.computertypeid">
<tr v-for="pt in visiblePcTypes" :key="pt.computertypeid">
<td>{{ pt.computertype }}</td>
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
<td>
<span class="badge" :style="colorStyle(pt.color)">{{ pt.color || 'auto' }}</span>
</td>
<td class="actions">
<span v-if="pt.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(pt)">Delete</button>
</td>
</tr>
<tr v-if="pcTypes.length === 0">
<td colspan="3" style="text-align: center; color: var(--text-light);">
<tr v-if="visiblePcTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No PC types found
</td>
</tr>
@@ -53,6 +60,13 @@
<label for="description">Description</label>
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
@@ -68,10 +82,14 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { computersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const pcTypes = ref([])
const showInactive = ref(false)
const visiblePcTypes = computed(() => showInactive.value ? pcTypes.value : pcTypes.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
@@ -79,14 +97,14 @@ const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ computertype: '', description: '' })
const form = ref({ computertype: '', description: '', color: '', isactive: true })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await computersApi.types.list({ perpage: 100 })
const response = await computersApi.types.list({ perpage: 200, active: false })
pcTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading PC types:', err)
@@ -98,14 +116,24 @@ async function loadData() {
function openModal(item = null) {
editing.value = item
form.value = item
? { computertype: item.computertype || '', description: item.description || '' }
: { computertype: '', description: '' }
? { computertype: item.computertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { computertype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function deleteType(pt) {
if (!confirm(`Delete PC type "${pt.computertype}"?`)) return
try {
await computersApi.types.remove(pt.computertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
async function save() {
error.value = ''
saving.value = true

View File

@@ -0,0 +1,183 @@
<template>
<div>
<div class="page-header">
<h2>Printer Drivers</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Driver</button>
</div>
<div class="card">
<p class="hint">
Each driver is a name + a link to the driver package - an SMB path
(<code>\\server\share\driver</code>) or an HTTP URL. HTTP links open;
SMB paths are shown for copy-paste (browsers block file:// SMB).
</p>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Printer Model</th>
<th>Location</th>
<th>Description</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="d in visibleItems" :key="d.driverid">
<td><strong>{{ d.name }}</strong></td>
<td>{{ d.modelname || '-' }}</td>
<td>
<a v-if="isHttp(d.location)" :href="d.location" target="_blank" class="mono">{{ d.location }}</a>
<span v-else class="mono">{{ d.location }}</span>
</td>
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
<td>
<span class="badge" :class="d.isactive ? 'badge-success' : 'badge-secondary'">{{ d.isactive ? 'yes' : 'no' }}</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(d)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteDriver(d)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">No drivers</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Driver</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" maxlength="150" required />
</div>
<div class="form-group">
<label>Printer Model <span class="hint">(links driver to a model so it shows on matching printers)</span></label>
<select v-model="form.modelnumberid" class="form-control">
<option :value="null">-- none --</option>
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
{{ m.modelnumber }}<template v-if="m.vendorname"> ({{ m.vendorname }})</template>
</option>
</select>
</div>
<div class="form-group">
<label>Location * <span class="hint">(SMB path or HTTP URL)</span></label>
<input v-model="form.location" type="text" class="form-control" maxlength="500"
placeholder="\\server\share\driver or https://..." required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '../../api'
const items = ref([])
const models = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ name: '', location: '', description: '', modelnumberid: null, isactive: true })
function isHttp(loc) {
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
}
onMounted(() => { loadData(); loadModels() })
async function loadData() {
loading.value = true
try {
const response = await printersApi.drivers.list({ active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading drivers:', err)
} finally {
loading.value = false
}
}
async function loadModels() {
try {
const response = await printersApi.modelSupplies.listModels({ perpage: 100 })
models.value = response.data.data || []
} catch (err) {
console.error('Error loading models:', err)
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { name: item.name || '', location: item.location || '', description: item.description || '', modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
: { name: '', location: '', description: '', modelnumberid: null, isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await printersApi.drivers.update(editing.value.driverid, form.value)
} else {
await printersApi.drivers.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteDriver(d) {
if (!confirm(`Delete driver "${d.name}"?`)) return
try {
await printersApi.drivers.delete(d.driverid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>
<style scoped>
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
.muted { color: var(--text-light); }
.mono { font-family: monospace; font-size: 0.85rem; word-break: break-all; }
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Printer Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Printer Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Printer Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.printertypeid">
<td>{{ t.printertype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No printer types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Printer Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Printer Type *</label>
<input v-model="form.printertype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ printertype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await printersApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading printer types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { printertype: item.printertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { printertype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await printersApi.types.update(editing.value.printertypeid, form.value)
} else {
await printersApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete printer type "${t.printertype}"?`)) return
try {
await printersApi.types.remove(t.printertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<div>
<div class="page-header">
<h2>Relationship Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Relationship Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Relationship Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.relationshiptypeid">
<td><span class="badge" :style="colorStyle(t.color)">{{ t.relationshiptype }}</span></td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="mono">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No relationship types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Relationship Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Relationship Type *</label>
<input v-model="form.relationshiptype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(relationship badges; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { relationshipTypesApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ relationshiptype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await relationshipTypesApi.list()
items.value = response.data.data || []
} catch (err) {
console.error('Error loading relationship types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { relationshiptype: item.relationshiptype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { relationshiptype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await relationshipTypesApi.update(editing.value.relationshiptypeid, form.value)
} else {
await relationshipTypesApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete relationship type "${t.relationshiptype}"?`)) return
try {
await relationshipTypesApi.remove(t.relationshiptypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>
<style scoped>
.mono { font-family: monospace; font-size: 0.85rem; }
</style>

View File

@@ -1,212 +1,92 @@
<template>
<div class="settings-page">
<h1>Settings</h1>
<div class="settings-search">
<input v-model="search" type="text" class="search-input"
placeholder="Search settings (vendors, vlans, plugins...)" />
</div>
<!-- Search results: flat grid of matches across all groups -->
<div v-if="search.trim()" class="settings-grid">
<router-link
v-for="card in searchResults"
:key="card.to"
:to="card.to"
class="settings-card"
>
<div class="card-icon"><component :is="card.icon" :size="28" /></div>
<h3>{{ card.title }}</h3>
<p>{{ card.description }}</p>
</router-link>
<p v-if="!searchResults.length" class="no-results">No matching settings</p>
</div>
<!-- Normal: group tabs on the left, that group's cards on the right -->
<div v-else class="settings-layout">
<nav class="settings-tabs">
<button
v-for="group in groups"
:key="group.title"
class="settings-tab"
:class="{ active: activeGroup === group.title }"
@click="activeGroup = group.title"
>{{ group.title }}</button>
</nav>
<div class="settings-grid">
<router-link
v-for="card in activeCards"
:key="card.to"
:to="card.to"
class="settings-card"
>
<div class="card-icon"><component :is="card.icon" :size="28" /></div>
<h3>{{ card.title }}</h3>
<p>{{ card.description }}</p>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle } from 'lucide-vue-next'
// Settings grouped by purpose so the index stays scannable as it grows.
const groups = [
{
title: 'Asset Reference Data',
cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors' },
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates' },
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
],
},
{
title: 'Locations & Organization',
cards: [
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
],
},
{
title: 'Network',
cards: [
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
],
},
{
title: 'Displays & Kiosks',
cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' },
],
},
{
title: 'System',
cards: [
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations, identifiers, search, and PC-type mapping' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
],
},
{
title: 'Access & Audit',
cards: [
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
],
},
]
const search = ref('')
const activeGroup = ref(groups[0].title)
const activeCards = computed(() =>
groups.find(g => g.title === activeGroup.value)?.cards || [])
// Flat search across every card's title + description.
const searchResults = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return []
return groups.flatMap(g => g.cards).filter(c =>
c.title.toLowerCase().includes(term) ||
c.description.toLowerCase().includes(term))
})
</script>
<style scoped>
.settings-page h1 {
margin-bottom: 1.5rem;
}
.settings-search {
margin-bottom: 1rem;
}
.search-input {
width: 100%;
max-width: 420px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
}
.settings-layout {
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.settings-tabs {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 200px;
position: sticky;
top: 1rem;
}
.settings-tab {
text-align: left;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
font-size: 0.95rem;
}
.settings-tab:hover { background: var(--bg); }
.settings-tab.active { background: var(--primary); color: #fff; }
.no-results {
color: var(--text-light);
font-size: 0.9rem;
}
.settings-grid {
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.settings-card {
display: block;
padding: 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.2s, border-color 0.2s;
}
.settings-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.card-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.settings-card h3 {
margin: 0 0 0.5rem 0;
color: var(--text);
}
.settings-card p {
margin: 0;
color: var(--text-light);
font-size: 0.9rem;
}
</style>
<template>
<div class="settings-landing">
<p class="landing-intro">
Pick a section from the left, or choose one below.
</p>
<section v-for="group in groups" :key="group.title" class="landing-section">
<h2 class="section-heading">{{ group.title }}</h2>
<div class="landing-grid">
<router-link
v-for="card in group.cards"
:key="card.to"
:to="card.to"
class="landing-card"
>
<div class="card-head">
<span class="card-icon"><component :is="card.icon" :size="18" /></span>
<h3>{{ card.title }}</h3>
</div>
<p>{{ card.description }}</p>
</router-link>
</div>
</section>
</div>
</template>
<script setup>
import { settingsGroups as groups } from './settingsNav'
</script>
<style scoped>
.landing-intro {
margin: 0 0 1.5rem 0;
color: var(--text-light);
}
.landing-section {
margin-bottom: 1.75rem;
}
.section-heading {
margin: 0 0 0.6rem 0;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--border);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
}
.landing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.7rem;
}
.landing-card {
display: block;
padding: 0.8rem 0.9rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s, border-color 0.15s;
}
.landing-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.card-head {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.3rem;
}
.card-icon {
display: inline-flex;
color: var(--primary);
}
.landing-card h3 {
margin: 0;
font-size: 0.92rem;
color: var(--text);
}
.landing-card p {
margin: 0;
color: var(--text-light);
font-size: 0.82rem;
line-height: 1.35;
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div class="settings-shell">
<!-- Left rail: grouped, searchable nav that stays put while the right pane swaps -->
<aside class="settings-rail">
<h1 class="rail-title">Settings</h1>
<div class="rail-search">
<input v-model="search" type="text" class="search-input"
placeholder="Search settings..." />
</div>
<nav class="rail-nav">
<template v-for="group in visibleGroups" :key="group.title">
<div class="rail-group-heading">{{ group.title }}</div>
<router-link
v-for="card in group.cards"
:key="card.to"
:to="card.to"
class="rail-link"
:class="{ active: isActive(card.to) }"
>
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
<span class="rail-label">{{ card.title }}</span>
</router-link>
</template>
<p v-if="!visibleGroups.length" class="rail-empty">No matching settings</p>
</nav>
</aside>
<!-- Right pane: the selected settings page renders here -->
<section class="settings-content">
<router-view />
</section>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { settingsGroups as groups } from './settingsNav'
const route = useRoute()
const search = ref('')
// Filter the rail by title/description; drop groups that end up empty.
const visibleGroups = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return groups
return groups
.map(g => ({
title: g.title,
cards: g.cards.filter(c =>
c.title.toLowerCase().includes(term) ||
c.description.toLowerCase().includes(term)),
}))
.filter(g => g.cards.length)
})
// A rail link is active when the current path matches its base path
// (ignoring query, so /settings/system?tab=map and /settings/system stay distinct
// only by their own comparison below).
function isActive(to) {
const base = to.split('?')[0]
const query = to.includes('?') ? to.split('?')[1] : ''
if (route.path !== base) return false
// Floor Map shares /settings/system with System Settings; disambiguate by tab.
if (base === '/settings/system') {
const wantMap = query.includes('tab=map')
const onMap = route.query.tab === 'map'
return wantMap === onMap
}
return true
}
</script>
<style scoped>
.settings-shell {
display: flex;
align-items: flex-start;
gap: 1.5rem;
}
.settings-rail {
flex: 0 0 250px;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.rail-title {
margin: 0 0 0.75rem 0;
font-size: 1.5rem;
}
.rail-search {
margin-bottom: 0.75rem;
}
.search-input {
width: 100%;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.85rem;
}
.rail-group-heading {
margin: 1rem 0 0.3rem 0;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
}
.rail-group-heading:first-child {
margin-top: 0;
}
.rail-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border-radius: 6px;
text-decoration: none;
color: var(--text);
font-size: 0.88rem;
border-left: 2px solid transparent;
}
.rail-link:hover {
background: var(--bg);
}
.rail-link.active {
background: var(--bg);
border-left-color: var(--primary);
color: var(--primary);
font-weight: 600;
}
.rail-icon {
display: inline-flex;
color: var(--text-light);
}
.rail-link.active .rail-icon {
color: var(--primary);
}
.rail-label {
line-height: 1.2;
}
.rail-empty {
color: var(--text-light);
font-size: 0.85rem;
}
.settings-content {
flex: 1 1 auto;
min-width: 0;
}
@media (max-width: 820px) {
.settings-shell {
flex-direction: column;
}
.settings-rail {
position: static;
flex-basis: auto;
width: 100%;
max-height: none;
}
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div>
<div class="page-header">
<h1>Site &amp; Facility</h1>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<div v-if="loading" class="muted">Loading...</div>
<div v-else class="form-grid">
<label v-for="s in items" :key="s.key" class="field">
<span>{{ prettyLabel(s.key) }}</span>
<input v-model="s.value" type="text" :placeholder="s.description" />
<small class="muted">{{ s.description }}</small>
</label>
<div v-if="!items.length" class="muted">No site settings found.</div>
<div class="actions">
<button class="btn btn-primary" :disabled="saving || !items.length" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<span v-if="saved" class="muted">Saved.</span>
<span v-if="error" class="error">{{ error }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
const items = ref([])
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
const LABELS = {
site_base_url: 'Site URL / FQDN',
facility_name: 'Facility Name',
pc_access_domain: 'PC Access Domain'
}
function prettyLabel(key) {
return LABELS[key] || key
}
async function load() {
loading.value = true
try {
const response = await settingsApi.list()
const all = response.data?.data || response.data || []
items.value = all.filter(s => s.category === 'site')
} catch (err) {
console.error('Error loading site settings:', err)
error.value = 'Could not load settings.'
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
saved.value = false
error.value = ''
try {
for (const s of items.value) {
await settingsApi.update(s.key, s.value)
}
saved.value = true
} catch (err) {
error.value = err.response?.data?.error?.message || 'Save failed.'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.form-grid {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 640px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-weight: 600;
}
.field input {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.muted {
color: var(--text-light);
font-size: 0.85rem;
}
.error {
color: var(--danger);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,209 @@
<template>
<div>
<div class="page-header">
<h1>Slides</h1>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<div class="tabs">
<button
v-for="s in surfaces"
:key="s.key"
class="tab"
:class="{ active: surface === s.key }"
@click="switchSurface(s.key)"
>{{ s.label }}</button>
</div>
<div class="toolbar">
<label class="btn btn-primary upload-btn">
{{ uploading ? 'Uploading...' : 'Upload Images' }}
<input type="file" accept="image/*" multiple hidden :disabled="uploading" @change="onUpload" />
</label>
<button
class="btn btn-danger"
:disabled="!selected.length"
@click="deleteSelected"
>Delete Selected ({{ selected.length }})</button>
<span class="hint">Order top-to-bottom is play order. Images show on the {{ surfaceLabel }}.</span>
</div>
<div v-if="loading" class="muted">Loading...</div>
<div v-else-if="!slides.length" class="muted empty">No slides yet. Upload some images.</div>
<div v-else class="slide-grid">
<div v-for="(slide, idx) in slides" :key="slide.slideid" class="slide-tile">
<label class="pick">
<input type="checkbox" :value="slide.filename" v-model="selected" />
</label>
<img :src="slide.url" :alt="slide.filename" class="thumb" />
<div class="slide-meta">
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
<div class="move">
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">&uarr;</button>
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">&darr;</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { slidesApi } from '@/api'
const surfaces = [
{ key: 'lobby', label: 'Lobby Display' },
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
]
const surface = ref('lobby')
const slides = ref([])
const selected = ref([])
const loading = ref(true)
const uploading = ref(false)
const surfaceLabel = computed(() => surfaces.find(s => s.key === surface.value)?.label || surface.value)
async function load() {
loading.value = true
selected.value = []
try {
const response = await slidesApi.list(surface.value)
slides.value = response.data.data || []
} catch (err) {
console.error('Error loading slides:', err)
} finally {
loading.value = false
}
}
function switchSurface(key) {
if (key === surface.value) return
surface.value = key
load()
}
async function onUpload(event) {
const files = Array.from(event.target.files || [])
if (!files.length) return
uploading.value = true
try {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
await slidesApi.upload(surface.value, formData)
await load()
} catch (err) {
console.error('Upload failed:', err)
} finally {
uploading.value = false
event.target.value = ''
}
}
async function move(idx, delta) {
const target = idx + delta
if (target < 0 || target >= slides.value.length) return
const arr = slides.value.slice()
const [item] = arr.splice(idx, 1)
arr.splice(target, 0, item)
slides.value = arr
try {
await slidesApi.reorder(surface.value, arr.map(s => s.filename))
} catch (err) {
console.error('Reorder failed:', err)
await load()
}
}
async function deleteSelected() {
if (!selected.value.length) return
if (!confirm(`Delete ${selected.value.length} slide(s)?`)) return
try {
await slidesApi.remove(surface.value, selected.value)
await load()
} catch (err) {
console.error('Delete failed:', err)
}
}
onMounted(load)
</script>
<style scoped>
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab {
padding: 10px 18px;
border: none;
background: none;
color: var(--text-light);
font-weight: 600;
cursor: pointer;
border-bottom: 3px solid transparent;
}
.tab.active {
color: var(--text);
border-bottom-color: var(--primary);
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.upload-btn { position: relative; cursor: pointer; }
.hint { color: var(--text-light); font-size: 0.85rem; }
.muted { color: var(--text-light); }
.empty { padding: 30px 0; text-align: center; }
.slide-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
}
.slide-tile {
position: relative;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg);
}
.slide-tile .pick {
position: absolute;
top: 6px;
left: 6px;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
padding: 2px 4px;
}
.thumb {
width: 100%;
height: 130px;
object-fit: cover;
display: block;
background: #000;
}
.slide-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 6px 8px;
}
.fname {
font-size: 0.78rem;
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.move { display: flex; gap: 4px; flex-shrink: 0; }
</style>

View File

@@ -86,21 +86,8 @@
</div>
<div class="form-group">
<label for="color">Color</label>
<div class="color-input-row">
<input
id="color"
v-model="form.color"
type="color"
class="color-picker"
/>
<input
v-model="form.color"
type="text"
class="form-control"
placeholder="#000000"
/>
</div>
<label>Color</label>
<ColorSwatchPicker v-model="form.color" />
<small class="form-hint">Used in UI to visually distinguish statuses</small>
</div>
@@ -151,6 +138,7 @@
import { ref, onMounted } from 'vue'
import { assetsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
const statuses = ref([])
const loading = ref(true)

View File

@@ -263,6 +263,79 @@
</div>
</div>
<!-- Floor Map Section -->
<div class="section-card" v-show="isVisible('map')">
<h2 class="section-title">Floor Map</h2>
<div class="setting-group">
<h3>Facility Blueprint</h3>
<p class="setting-description">
The floor-plan image and its pixel dimensions for this facility. Map
markers are positioned against these dimensions, so the width and
height must match the native size of the blueprint image. Leave the
image paths at their defaults to use the bundled sitemap.
</p>
<div class="setting-row">
<label>
<span>Blueprint image URL (light theme)</span>
<input
type="text"
v-model="settings.map_blueprint_light"
placeholder="/static/images/sitemap2025-light.png"
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
:disabled="saving"
>
<small class="input-hint">Path or URL to the light-theme floor plan</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint image URL (dark theme)</span>
<input
type="text"
v-model="settings.map_blueprint_dark"
placeholder="/static/images/sitemap2025-dark.png"
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
:disabled="saving"
>
<small class="input-hint">Path or URL to the dark-theme floor plan</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint width (pixels)</span>
<input
type="number"
v-model="settings.map_width"
min="1"
placeholder="3300"
@blur="saveSetting('map_width', settings.map_width)"
:disabled="saving"
>
<small class="input-hint">Native pixel width of the blueprint image</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint height (pixels)</span>
<input
type="number"
v-model="settings.map_height"
min="1"
placeholder="2550"
@blur="saveSetting('map_height', settings.map_height)"
:disabled="saving"
>
<small class="input-hint">Native pixel height of the blueprint image</small>
</label>
</div>
</div>
</div>
<!-- Authentication Section -->
<div class="section-card" v-show="isVisible('auth')">
<h2 class="section-title">Authentication</h2>
@@ -501,6 +574,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { settingsApi, computersApi } from '../../api'
import { setIdentifierFlag } from '../../composables/identifierSettings'
@@ -513,10 +587,17 @@ const SETTINGS_TABS = [
{ key: 'auth', label: 'Authentication', keywords: 'auth saml sso login users idp' },
{ key: 'identifiers', label: 'Asset Identifiers', keywords: 'identifier gauge lab maintenance fqdn hostname asset' },
{ key: 'search', label: 'Global Search', keywords: 'search results domains' },
{ key: 'map', label: 'Floor Map', keywords: 'map floor plan blueprint image facility site dimensions width height' },
{ key: 'pctype', label: 'PC Type Mapping', keywords: 'pc type mapping collector enrollment shopfloor computer type' },
]
const settingsSearch = ref('')
const activeTab = ref('integrations')
const route = useRoute()
// Allow deep-linking to a tab, e.g. /settings/system?tab=map (Floor Map)
const activeTab = ref(
route.query.tab && SETTINGS_TABS.some(t => t.key === route.query.tab)
? String(route.query.tab)
: 'integrations'
)
const visibleTabs = computed(() => {
const term = settingsSearch.value.trim().toLowerCase()
@@ -556,6 +637,11 @@ const settings = reactive({
alert_recipients: '',
// Audit
audit_retention_days: 90,
// Floor map blueprint (per-facility)
map_blueprint_light: '',
map_blueprint_dark: '',
map_width: 3300,
map_height: 2550,
// SAML
saml_enabled: false,
saml_idp_metadata_url: '',

View File

@@ -0,0 +1,85 @@
// Shared settings navigation catalog.
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
// grouping lives in one place.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal } from 'lucide-vue-next'
export const settingsGroups = [
{
title: 'Site & Facility',
cards: [
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, and PC access domain' },
{ to: '/settings/system?tab=map', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint and dimensions' },
],
},
{
title: 'General Reference',
cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
],
},
{
title: 'PCs',
cards: [
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates' },
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
],
},
{
title: 'Printers',
cards: [
{ to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' },
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
{ to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' },
],
},
{
title: 'Equipment',
cards: [
{ to: '/settings/equipmenttypes', icon: Wrench, title: 'Equipment Types', description: 'Manage equipment subtypes + map colors' },
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
],
},
{
title: 'Locations & Organization',
cards: [
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
{ to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' },
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
],
},
{
title: 'Network',
cards: [
{ to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' },
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
],
},
{
title: 'Displays & Kiosks',
cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' },
{ to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
],
},
{
title: 'System',
cards: [
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations, identifiers, search, and PC-type mapping' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
],
},
{
title: 'Access & Audit',
cards: [
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
],
},
]

View File

@@ -0,0 +1,292 @@
<template>
<div>
<div class="page-header">
<h2>Warranties</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Warranty</button>
</div>
<div class="filters">
<label>Status
<select v-model="statusFilter" class="form-control" @change="loadData">
<option value="">All</option>
<option value="active">Active</option>
<option value="expiring">Expiring Soon</option>
<option value="expired">Expired</option>
<option value="unknown">Unknown</option>
</select>
</label>
</div>
<div class="card">
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Status</th>
<th>Service Level</th>
<th>Ends</th>
<th>Covers</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="w in items" :key="w.warrantyid">
<td><strong>{{ w.vendor }}</strong><span v-if="w.provider !== 'manual'" class="muted"> ({{ w.provider }})</span></td>
<td><span class="status-badge" :style="colorStyle(w.statuscolor)">{{ statusLabel(w.status) }}</span></td>
<td>{{ w.servicelevel || '-' }}</td>
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
<td>
<span v-if="!w.assets.length" class="muted">-</span>
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip">{{ a.assetnumber }}</router-link>
</td>
<td class="actions">
<button v-if="w.provider !== 'manual'" class="btn btn-secondary btn-sm" @click="refresh(w)">Refresh</button>
<button class="btn btn-secondary btn-sm" @click="openModal(w)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteWarranty(w)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">No warranties</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Warranty</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label>Vendor *</label>
<input v-model="form.vendor" type="text" class="form-control" maxlength="100" required />
</div>
<div class="form-group">
<label>Provider</label>
<select v-model="form.provider" class="form-control">
<option value="manual">Manual</option>
<option value="dell">Dell</option>
<option value="lenovo">Lenovo</option>
<option value="hp">HP</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Service Tag / Serial</label>
<input v-model="form.servicetag" type="text" class="form-control" maxlength="100" />
</div>
<div class="form-group">
<label>Service Level</label>
<input v-model="form.servicelevel" type="text" class="form-control" maxlength="150" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Start Date</label>
<input v-model="form.startdate" type="date" class="form-control" />
</div>
<div class="form-group">
<label>End Date</label>
<input v-model="form.enddate" type="date" class="form-control" />
</div>
</div>
<div class="form-group">
<label>Covered Assets</label>
<div class="asset-search">
<input v-model="assetQuery" type="text" class="form-control" placeholder="Search asset number or name..."
@input="searchAssets" />
<ul v-if="assetResults.length" class="asset-results">
<li v-for="a in assetResults" :key="a.assetid" @click="addAsset(a)">
{{ a.assetnumber }}<span v-if="a.name" class="muted"> - {{ a.name }}</span>
</li>
</ul>
</div>
<div class="asset-chips">
<span v-for="a in selectedAssets" :key="a.assetid" class="asset-chip removable">
{{ a.assetnumber }}
<button type="button" @click="removeAsset(a)">x</button>
</span>
<span v-if="!selectedAssets.length" class="muted">None linked</span>
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea v-model="form.notes" class="form-control" rows="2"></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi } from '../../api'
const items = ref([])
const loading = ref(true)
const statusFilter = ref('')
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(blankForm())
const selectedAssets = ref([])
const assetQuery = ref('')
const assetResults = ref([])
function blankForm() {
return { vendor: '', provider: 'manual', servicetag: '', servicelevel: '', startdate: '', enddate: '', notes: '' }
}
function statusLabel(status) {
return { active: 'Active', expiring: 'Expiring Soon', expired: 'Expired', unknown: 'Unknown' }[status] || status
}
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
// Route to the right detail page by asset type.
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', equipment: '/machines/' }
const base = map[a.assettypename] || '/assets/'
return base + a.assetid
}
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const params = statusFilter.value ? { status: statusFilter.value } : {}
const response = await warrantyApi.list(params)
items.value = response.data.data || []
} catch (err) {
console.error('Error loading warranties:', err)
} finally {
loading.value = false
}
}
let searchTimer = null
function searchAssets() {
clearTimeout(searchTimer)
const q = assetQuery.value.trim()
if (!q) { assetResults.value = []; return }
searchTimer = setTimeout(async () => {
try {
const response = await assetsApi.search(q, { perpage: 8 })
assetResults.value = response.data.data || []
} catch (err) {
assetResults.value = []
}
}, 250)
}
function addAsset(a) {
if (!selectedAssets.value.some(x => x.assetid === a.assetid)) {
selectedAssets.value.push({ assetid: a.assetid, assetnumber: a.assetnumber })
}
assetQuery.value = ''
assetResults.value = []
}
function removeAsset(a) {
selectedAssets.value = selectedAssets.value.filter(x => x.assetid !== a.assetid)
}
function openModal(item = null) {
editing.value = item
if (item) {
form.value = {
vendor: item.vendor || '', provider: item.provider || 'manual',
servicetag: item.servicetag || '', servicelevel: item.servicelevel || '',
startdate: item.startdate || '', enddate: item.enddate || '', notes: item.notes || '',
}
selectedAssets.value = (item.assets || []).map(a => ({ assetid: a.assetid, assetnumber: a.assetnumber }))
} else {
form.value = blankForm()
selectedAssets.value = []
}
assetQuery.value = ''
assetResults.value = []
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const payload = { ...form.value, assetids: selectedAssets.value.map(a => a.assetid) }
if (editing.value) {
await warrantyApi.update(editing.value.warrantyid, payload)
} else {
await warrantyApi.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteWarranty(w) {
if (!confirm(`Delete the ${w.vendor} warranty?`)) return
try {
await warrantyApi.remove(w.warrantyid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || 'Failed to delete')
}
}
async function refresh(w) {
try {
await warrantyApi.refresh(w.warrantyid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Refresh failed')
}
}
</script>
<style scoped>
.muted { color: var(--text-light); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.78rem; font-weight: 600; }
.form-row { display: flex; gap: 1rem; }
.form-row .form-group { flex: 1; }
.asset-search { position: relative; }
.asset-results {
position: absolute; z-index: 10; left: 0; right: 0; margin: 2px 0 0;
padding: 0; list-style: none; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 6px; max-height: 200px; overflow-y: auto;
}
.asset-results li { padding: 0.45rem 0.6rem; cursor: pointer; }
.asset-results li:hover { background: var(--bg); }
.asset-chips { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.asset-chip {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.15rem 0.55rem; margin: 0 0.25rem 0.25rem 0;
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
}
.asset-chip.removable button {
border: none; background: none; color: var(--text-light); cursor: pointer; font-size: 0.85rem; padding: 0;
}
</style>

View File

@@ -4,6 +4,27 @@ from logging.config import fileConfig
from flask import current_app
from alembic import context
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.schema import CreateTable
# Force every table the migrations create on MySQL to utf8mb4 + DYNAMIC row
# format. Without this a fresh `flask db upgrade` inherits the server default
# charset, so a box whose default is latin1 (common on older MySQL) silently
# builds a latin1 schema that drifts from the utf8mb4 production target. The
# DYNAMIC row format also keeps utf8mb4 indexes under the 767-byte prefix limit
# on pre-5.7 InnoDB. Scoped to the mysql dialect so the SQLite test DB is
# untouched.
@compiles(CreateTable, "mysql")
def _mysql_create_table_utf8mb4(element, compiler, **kw):
sql = compiler.visit_create_table(element, **kw)
if "CHARSET" not in sql.upper():
sql = sql.rstrip().rstrip(";")
sql += (
" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
" COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC"
)
return sql
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.

View File

@@ -0,0 +1,37 @@
"""Widen notifications.employeesso / employeename to TEXT
Recognition and recertification notifications comma-join every listed
employee's SSO and name into a single column. VARCHAR(100) truncated the list
at ~11 people, dropping names off the shopfloor grid. TEXT removes the cap.
Revision ID: 7d02_widen_notification_employee_cols
Revises: 7d01_dashboarddefaults
Create Date: 2026-07-07
"""
from alembic import op
import sqlalchemy as sa
revision = '7d02_widen_notification_employee_cols'
down_revision = '7d01_dashboarddefaults'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('notifications', 'employeesso',
existing_type=sa.String(length=100), type_=sa.Text(),
existing_nullable=True)
op.alter_column('notifications', 'employeename',
existing_type=sa.String(length=100), type_=sa.Text(),
existing_nullable=True)
def downgrade():
op.alter_column('notifications', 'employeesso',
existing_type=sa.Text(), type_=sa.String(length=100),
existing_nullable=True)
op.alter_column('notifications', 'employeename',
existing_type=sa.Text(), type_=sa.String(length=100),
existing_nullable=True)

View File

@@ -0,0 +1,53 @@
"""Per-type auto-expiry rule on notificationtypes
Makes the shopfloor auto-expiry window configurable per notification type
instead of hardcoding recognition (8 AM Eastern) and recertification (14 days)
in the API. Adds:
expirymode 'none' | 'duration' | 'dailytime'
expirydays days for 'duration'
expiryhour hour (Eastern) for 'dailytime'
expiryminute minute (Eastern) for 'dailytime'
Seeds the two existing rule-bearing types so behavior is unchanged:
Recognition (typecolor 'recognition') -> dailytime 08:00 Eastern
Recertification (typecolor 'recertification') -> duration 14 days
Revision ID: 7d03_notificationtype_expiry
Revises: 7d02_widen_notification_employee_cols
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d03_notificationtype_expiry'
down_revision = '7d02_widen_notification_employee_cols'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('notificationtypes', sa.Column('expirymode', sa.String(length=20), nullable=True, server_default='none'))
op.add_column('notificationtypes', sa.Column('expirydays', sa.Integer(), nullable=True))
op.add_column('notificationtypes', sa.Column('expiryhour', sa.SmallInteger(), nullable=True))
op.add_column('notificationtypes', sa.Column('expiryminute', sa.SmallInteger(), nullable=True, server_default='0'))
# preserve current behavior for the two rule-bearing types
op.execute(
"UPDATE notificationtypes SET expirymode='dailytime', expiryhour=8, expiryminute=0 "
"WHERE typecolor='recognition'"
)
op.execute(
"UPDATE notificationtypes SET expirymode='duration', expirydays=14 "
"WHERE typecolor='recertification'"
)
# everything else: explicit 'none' (indefinite unless the creator sets an end time)
op.execute("UPDATE notificationtypes SET expirymode='none' WHERE expirymode IS NULL")
def downgrade():
op.drop_column('notificationtypes', 'expiryminute')
op.drop_column('notificationtypes', 'expiryhour')
op.drop_column('notificationtypes', 'expirydays')
op.drop_column('notificationtypes', 'expirymode')

View File

@@ -0,0 +1,43 @@
"""Add data-driven shopfloor display behavior to notification types
Replaces the hardcoded recognition/training/recertification logic with per-type
columns: split one card per employee, show employee photo, and the display
style (standard rows / carousel / grid / banner). Seeds the built-in special
types by their typecolor keyword.
Revision ID: 7d04_notificationtype_display
Revises: 7d03_notificationtype_expiry
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d04_notificationtype_display'
down_revision = '7d03_notificationtype_expiry'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('notificationtypes', sa.Column('splitperemployee', sa.Boolean(), nullable=True, server_default='0'))
op.add_column('notificationtypes', sa.Column('showemployeephoto', sa.Boolean(), nullable=True, server_default='0'))
op.add_column('notificationtypes', sa.Column('displaystyle', sa.String(length=20), nullable=True, server_default='standard'))
conn = op.get_bind()
conn.execute(sa.text(
"UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='carousel' "
"WHERE typecolor='recognition'"))
conn.execute(sa.text(
"UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='grid' "
"WHERE typecolor='recertification'"))
conn.execute(sa.text(
"UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='carousel' "
"WHERE typecolor='training'"))
def downgrade():
op.drop_column('notificationtypes', 'displaystyle')
op.drop_column('notificationtypes', 'showemployeephoto')
op.drop_column('notificationtypes', 'splitperemployee')

View File

@@ -0,0 +1,76 @@
"""PC access protocols: catalog + per-PC links, retire isvnc/iswinrm
Adds an admin-managed protocol catalog (accessprotocols) and a per-PC link
table (computeraccess). Seeds VNC/WinRM/RDP, migrates each PC's isvnc/iswinrm
into computeraccess rows, then drops the two boolean columns.
Revision ID: 7d05_pc_access_protocols
Revises: 7d04_notificationtype_display
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d05_pc_access_protocols'
down_revision = '7d04_notificationtype_display'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'accessprotocols',
sa.Column('protocolid', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('scheme', sa.String(length=20), nullable=False),
sa.Column('defaultport', sa.Integer(), nullable=True),
sa.Column('linktemplate', sa.String(length=255), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
sa.UniqueConstraint('name', name='uq_accessprotocol_name'),
)
op.create_table(
'computeraccess',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('computerid', sa.Integer(), nullable=False),
sa.Column('protocolid', sa.Integer(), nullable=False),
sa.Column('portoverride', sa.Integer(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
sa.ForeignKeyConstraint(['computerid'], ['computers.computerid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['protocolid'], ['accessprotocols.protocolid']),
sa.UniqueConstraint('computerid', 'protocolid', name='uq_computer_protocol'),
)
op.create_index('idx_compaccess_computer', 'computeraccess', ['computerid'])
conn = op.get_bind()
# Seed the protocol catalog. exec_driver_sql so the ':' / '{}' in the
# templates are not parsed as bind params.
conn.exec_driver_sql(
"INSERT INTO accessprotocols (name, scheme, defaultport, linktemplate, isactive) VALUES "
"('VNC','vnc',5900,'vnc://{host}:{port}',1),"
"('WinRM','https',5986,'https://{host}:{port}/wsman',1),"
"('RDP','rdp',3389,'rdp://{host}:{port}',1)"
)
# Migrate the old booleans into per-PC access rows.
conn.exec_driver_sql(
"INSERT INTO computeraccess (computerid, protocolid, isactive) "
"SELECT c.computerid, p.protocolid, 1 FROM computers c "
"JOIN accessprotocols p ON p.name='VNC' WHERE c.isvnc = 1"
)
conn.exec_driver_sql(
"INSERT INTO computeraccess (computerid, protocolid, isactive) "
"SELECT c.computerid, p.protocolid, 1 FROM computers c "
"JOIN accessprotocols p ON p.name='WinRM' WHERE c.iswinrm = 1"
)
op.drop_column('computers', 'isvnc')
op.drop_column('computers', 'iswinrm')
def downgrade():
op.add_column('computers', sa.Column('iswinrm', sa.Boolean(), nullable=True))
op.add_column('computers', sa.Column('isvnc', sa.Boolean(), nullable=True))
op.drop_index('idx_compaccess_computer', table_name='computeraccess')
op.drop_table('computeraccess')
op.drop_table('accessprotocols')

View File

@@ -0,0 +1,46 @@
"""Give the built-in notification types real hex colors
Recognition/Recertification/Training stored keyword typecolors ('recognition'
etc.) that static color maps had to translate. Every other type already stores
a hex. This converts the three to hex so color is fully data-driven and the
static maps can go away. Their special behavior now rides on displaystyle /
splitperemployee / showemployeephoto, not the color keyword.
Revision ID: 7d06_typecolor_to_hex
Revises: 7d05_pc_access_protocols
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d06_typecolor_to_hex'
down_revision = '7d05_pc_access_protocols'
branch_labels = None
depends_on = None
KEYWORD_HEX = {
'recognition': '#ffc107',
'recertification': '#0d6efd',
'training': '#17a2b8',
}
def upgrade():
conn = op.get_bind()
for keyword, hexcolor in KEYWORD_HEX.items():
conn.execute(
sa.text("UPDATE notificationtypes SET typecolor = :hex WHERE typecolor = :kw"),
{'hex': hexcolor, 'kw': keyword}
)
def downgrade():
conn = op.get_bind()
for keyword, hexcolor in KEYWORD_HEX.items():
conn.execute(
sa.text("UPDATE notificationtypes SET typecolor = :kw WHERE typecolor = :hex"),
{'kw': keyword, 'hex': hexcolor}
)

View File

@@ -0,0 +1,42 @@
"""Add color to asset types (data-driven map/type colors)
Gives AssetType a color column (like AssetStatus) so the map's top-level type
colors come from data instead of the hardcoded map in mapColors.js. Seeds the
four base types with the exact colors that were hardcoded, so nothing changes
visually until someone edits them.
Revision ID: 7d07_assettype_color
Revises: 7d06_typecolor_to_hex
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d07_assettype_color'
down_revision = '7d06_typecolor_to_hex'
branch_labels = None
depends_on = None
SEED = {
'equipment': '#F44336',
'computer': '#2196F3',
'printer': '#4CAF50',
'network_device': '#FF9800',
}
def upgrade():
op.add_column('assettypes', sa.Column('color', sa.String(length=20), nullable=True))
conn = op.get_bind()
for assettype, hexcolor in SEED.items():
conn.execute(
sa.text("UPDATE assettypes SET color = :c WHERE assettype = :t AND (color IS NULL OR color = '')"),
{'c': hexcolor, 't': assettype}
)
def downgrade():
op.drop_column('assettypes', 'color')

View File

@@ -0,0 +1,36 @@
"""Slide manager: tvslides table
Backing table for the slides plugin (lobby display + shopfloor screensaver
playlists). Image files live on disk; this holds order + per-slide duration.
Revision ID: 7d08_tvslides
Revises: 7d07_assettype_color
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d08_tvslides'
down_revision = '7d07_assettype_color'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'tvslides',
sa.Column('slideid', sa.Integer(), primary_key=True),
sa.Column('surface', sa.String(length=20), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False, server_default='0'),
sa.Column('seconds', sa.Integer(), nullable=False, server_default='0'),
sa.Column('uploadeddate', sa.DateTime(), nullable=True),
)
op.create_index('idx_tvslide_surface', 'tvslides', ['surface'])
def downgrade():
op.drop_index('idx_tvslide_surface', table_name='tvslides')
op.drop_table('tvslides')

View File

@@ -0,0 +1,31 @@
"""Add color to asset subtype tables (data-driven map colors)
The map colors equipment/computer/network/printer subtypes round-robin from a
palette. Give each subtype table a color column so a site can set stable, chosen
colors that the map reads instead of the auto-assigned palette.
Revision ID: 7d09_subtype_colors
Revises: 7d08_tvslides
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d09_subtype_colors'
down_revision = '7d08_tvslides'
branch_labels = None
depends_on = None
TABLES = ['equipmenttypes', 'computertypes', 'networkdevicetypes', 'printertypes']
def upgrade():
for table in TABLES:
op.add_column(table, sa.Column('color', sa.String(length=20), nullable=True))
def downgrade():
for table in TABLES:
op.drop_column(table, 'color')

View File

@@ -0,0 +1,26 @@
"""Add color to relationship types
Lets sites color relationship types (Controls, Contains, Stored At...) so the
asset relationship graph/badges are visually distinct, like statuses.
Revision ID: 7d10_relationshiptype_color
Revises: 7d09_subtype_colors
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d10_relationshiptype_color'
down_revision = '7d09_subtype_colors'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('relationshiptypes', sa.Column('color', sa.String(length=20), nullable=True))
def downgrade():
op.drop_column('relationshiptypes', 'color')

View File

@@ -0,0 +1,23 @@
"""Add color to location types
Revision ID: 7d11_locationtype_color
Revises: 7d10_relationshiptype_color
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d11_locationtype_color'
down_revision = '7d10_relationshiptype_color'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('locationtypes', sa.Column('color', sa.String(length=20), nullable=True))
def downgrade():
op.drop_column('locationtypes', 'color')

View File

@@ -0,0 +1,26 @@
"""Drop computers.isshopfloor (redundant with the Shopfloor PC type)
The flag was never populated (0 rows) and duplicated the 'Shopfloor' computer
type. Shopfloor classification now lives entirely in computertype.
Revision ID: 7d12_drop_isshopfloor
Revises: 7d11_locationtype_color
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d12_drop_isshopfloor'
down_revision = '7d11_locationtype_color'
branch_labels = None
depends_on = None
def upgrade():
op.drop_column('computers', 'isshopfloor')
def downgrade():
op.add_column('computers', sa.Column('isshopfloor', sa.Boolean(), nullable=True, server_default='0'))

View File

@@ -0,0 +1,32 @@
"""Printer drivers: named SMB/HTTP links to driver packages
Revision ID: 7d13_printerdrivers
Revises: 7d12_drop_isshopfloor
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d13_printerdrivers'
down_revision = '7d12_drop_isshopfloor'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'printerdrivers',
sa.Column('driverid', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('location', sa.String(length=500), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('modelnumberid', sa.Integer(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid']),
)
def downgrade():
op.drop_table('printerdrivers')

View File

@@ -0,0 +1,48 @@
"""Custom fields: site-defined extra attributes per asset type
Revision ID: 7d14_customfields
Revises: 7d13_printerdrivers
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d14_customfields'
down_revision = '7d13_printerdrivers'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'customfields',
sa.Column('fieldid', sa.Integer(), primary_key=True),
sa.Column('assettypeid', sa.Integer(), nullable=False),
sa.Column('fieldkey', sa.String(length=50), nullable=False),
sa.Column('label', sa.String(length=150), nullable=False),
sa.Column('datatype', sa.String(length=20), nullable=False, server_default='text'),
sa.Column('options', sa.Text(), nullable=True),
sa.Column('showondetail', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('showonform', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('sortorder', sa.Integer(), nullable=False, server_default='0'),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
sa.ForeignKeyConstraint(['assettypeid'], ['assettypes.assettypeid']),
sa.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'),
)
op.create_table(
'customfieldvalues',
sa.Column('valueid', sa.Integer(), primary_key=True),
sa.Column('fieldid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('value', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['fieldid'], ['customfields.fieldid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.UniqueConstraint('fieldid', 'assetid', name='uq_customfieldvalue_field_asset'),
)
def downgrade():
op.drop_table('customfieldvalues')
op.drop_table('customfields')

View File

@@ -0,0 +1,45 @@
"""Warranty plugin: warranties + warrantyassets
Revision ID: 7d15_warranties
Revises: 7d14_customfields
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d15_warranties'
down_revision = '7d14_customfields'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'warranties',
sa.Column('warrantyid', sa.Integer(), primary_key=True),
sa.Column('vendor', sa.String(length=100), nullable=False),
sa.Column('servicetag', sa.String(length=100), nullable=True),
sa.Column('provider', sa.String(length=20), nullable=False, server_default='manual'),
sa.Column('servicelevel', sa.String(length=150), nullable=True),
sa.Column('startdate', sa.Date(), nullable=True),
sa.Column('enddate', sa.Date(), nullable=True),
sa.Column('lastcheckeddate', sa.DateTime(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
)
op.create_table(
'warrantyassets',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('warrantyid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['warrantyid'], ['warranties.warrantyid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.UniqueConstraint('warrantyid', 'assetid', name='uq_warrantyasset_warranty_asset'),
)
def downgrade():
op.drop_table('warrantyassets')
op.drop_table('warranties')

View File

@@ -5,7 +5,9 @@ from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Computer, ComputerType, ComputerInstalledApp
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from shopdb.api import require_permission, require_role
computers_bp = Blueprint('computers', __name__)
@@ -54,6 +56,7 @@ def get_computer_type(type_id: int):
@computers_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def create_computer_type():
"""Create a new computer type."""
data = request.get_json()
@@ -61,7 +64,16 @@ def create_computer_type():
if not data or not data.get('computertype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'computertype is required')
if ComputerType.query.filter_by(computertype=data['computertype']).first():
existing = ComputerType.query.filter_by(computertype=data['computertype']).first()
if existing:
if not existing.isactive:
# Adding a name that matches a deactivated type revives it.
existing.isactive = True
for key in ('description', 'icon', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing computer type')
return error_response(
ErrorCodes.CONFLICT,
f"Computer type '{data['computertype']}' already exists",
@@ -71,7 +83,7 @@ def create_computer_type():
t = ComputerType(
computertype=data['computertype'],
description=data.get('description'),
icon=data.get('icon')
icon=data.get('icon'), color=data.get('color')
)
db.session.add(t)
@@ -82,6 +94,7 @@ def create_computer_type():
@computers_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('computers.edit')
def update_computer_type(type_id: int):
"""Update a computer type."""
t = ComputerType.query.get(type_id)
@@ -105,7 +118,7 @@ def update_computer_type(type_id: int):
http_code=409
)
for key in ['computertype', 'description', 'icon', 'isactive']:
for key in ['computertype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
@@ -113,6 +126,168 @@ def update_computer_type(type_id: int):
return success_response(t.to_dict(), message='Computer type updated')
@computers_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def delete_computer_type(type_id: int):
"""Delete a computer type. Refused if any PC still uses it."""
t = ComputerType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Computer type not found', http_code=404)
inuse = Computer.query.filter_by(computertypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} PC(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Computer type deleted')
# =============================================================================
# Access protocol catalog (VNC / WinRM / RDP / ...) - admin-managed
# =============================================================================
@computers_bp.route('/protocols', methods=['GET'])
@jwt_required(optional=True)
def list_protocols():
"""List access protocols. ?active=false includes disabled ones."""
query = AccessProtocol.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(AccessProtocol.isactive == True)
protocols = query.order_by(AccessProtocol.name).all()
return success_response([p.to_dict() for p in protocols])
@computers_bp.route('/protocols', methods=['POST'])
@jwt_required()
@require_permission('computers.edit')
def create_protocol():
data = request.get_json() or {}
if not (data.get('name') and data.get('scheme') and data.get('linktemplate')):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name, scheme and linktemplate are required')
if AccessProtocol.query.filter_by(name=data['name']).first():
return error_response(ErrorCodes.CONFLICT, f"Protocol '{data['name']}' already exists", http_code=409)
p = AccessProtocol(
name=data['name'],
scheme=data['scheme'],
defaultport=data.get('defaultport') or None,
linktemplate=data['linktemplate'],
isactive=data.get('isactive', True),
)
db.session.add(p)
db.session.commit()
return success_response(p.to_dict(), message='Protocol created', http_code=201)
@computers_bp.route('/protocols/<int:protocol_id>', methods=['PUT', 'PATCH'])
@jwt_required()
@require_permission('computers.edit')
def update_protocol(protocol_id):
p = AccessProtocol.query.get(protocol_id)
if not p:
return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404)
data = request.get_json() or {}
for field in ('name', 'scheme', 'linktemplate'):
if data.get(field):
setattr(p, field, data[field])
if 'defaultport' in data:
p.defaultport = data['defaultport'] or None
if 'isactive' in data:
p.isactive = bool(data['isactive'])
db.session.commit()
return success_response(p.to_dict(), message='Protocol updated')
@computers_bp.route('/protocols/<int:protocol_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.edit')
def delete_protocol(protocol_id):
p = AccessProtocol.query.get(protocol_id)
if not p:
return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404)
# If any PC still references it, deactivate rather than hard-delete.
if ComputerAccess.query.filter_by(protocolid=protocol_id).first():
p.isactive = False
db.session.commit()
return success_response(message='Protocol is in use; deactivated instead of deleted')
db.session.delete(p)
db.session.commit()
return success_response(message='Protocol deleted')
def _computer_access_links(comp):
"""Resolved remote-access links for a computer: each enabled protocol's
template filled with the PC hostname joined to the pc_access_domain setting.
A hostname that is already an FQDN (has a dot) is used as-is."""
from shopdb.core.api.settings import get_cached_settings
settings = get_cached_settings()
domain = (settings.get('pc_access_domain') or '').strip()
hostname = (comp.hostname or '').strip()
if not hostname:
host = ''
elif '.' in hostname or not domain:
host = hostname
else:
host = f"{hostname}.{domain}"
links = []
for am in comp.accessmethods:
protocol = am.protocol
if not (am.isactive and protocol and protocol.isactive):
continue
port = am.portoverride or protocol.defaultport
link = None
if host:
try:
link = protocol.linktemplate.format(
host=host,
port=(port if port is not None else ''),
scheme=protocol.scheme,
)
except (KeyError, IndexError, ValueError):
link = None
links.append({
'id': am.id,
'protocolid': protocol.protocolid,
'name': protocol.name,
'scheme': protocol.scheme,
'port': port,
'portoverride': am.portoverride,
'link': link,
})
return links
def _sync_access_methods(comp, data):
"""Replace a computer's enabled protocols from data['accessmethods'] (a list
of {protocolid, portoverride?}). No-op if the key is absent, so callers that
don't touch access aren't affected."""
if 'accessmethods' not in data:
return
desired = data.get('accessmethods') or []
ComputerAccess.query.filter_by(computerid=comp.computerid).delete()
seen = set()
for m in desired:
try:
pid = int(m.get('protocolid'))
except (TypeError, ValueError):
continue
if pid in seen:
continue
seen.add(pid)
port = m.get('portoverride')
try:
port = int(port) if port not in (None, '') else None
except (TypeError, ValueError):
port = None
db.session.add(ComputerAccess(
computerid=comp.computerid,
protocolid=pid,
portoverride=port,
isactive=True,
))
# =============================================================================
# Computers CRUD
# =============================================================================
@@ -169,9 +344,15 @@ def list_computers():
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(bu_id))
# Shopfloor filter
# Shopfloor filter (by the Shopfloor computer type)
if shopfloor := request.args.get('shopfloor'):
query = query.filter(Computer.isshopfloor == (shopfloor.lower() == 'true'))
sf = ComputerType.query.filter_by(computertype='Shopfloor').first()
sf_id = sf.computertypeid if sf else -1
if shopfloor.lower() == 'true':
query = query.filter(Computer.computertypeid == sf_id)
else:
query = query.filter(db.or_(Computer.computertypeid != sf_id,
Computer.computertypeid.is_(None)))
# Sorting
sort_by = request.args.get('sort', 'hostname')
@@ -197,6 +378,7 @@ def list_computers():
for comp in items:
item = comp.asset.to_dict() if comp.asset else {}
item['computer'] = comp.to_dict()
item['accessmethods'] = _computer_access_links(comp)
data.append(item)
return paginated_response(data, page, per_page, total)
@@ -221,6 +403,7 @@ def get_computer(computer_id: int):
c.to_dict() for c in
Communication.query.filter_by(assetid=comp.assetid).all()
]
result['accessmethods'] = _computer_access_links(comp)
return success_response(result)
@@ -265,6 +448,7 @@ def get_computer_by_hostname(hostname: str):
@computers_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def create_computer():
"""
Create new computer (creates both Asset and Computer records).
@@ -275,7 +459,6 @@ def create_computer():
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- computertypeid, hostname, osid
- isvnc, iswinrm, isshopfloor
- mapx, mapy, notes
"""
data = request.get_json()
@@ -341,10 +524,7 @@ def create_computer():
modelnumberid=data.get('modelnumberid'),
loggedinuser=data.get('loggedinuser'),
lastreporteddate=data.get('lastreporteddate'),
lastboottime=data.get('lastboottime'),
isvnc=data.get('isvnc', False),
iswinrm=data.get('iswinrm', False),
isshopfloor=data.get('isshopfloor', False)
lastboottime=data.get('lastboottime')
)
db.session.add(comp)
@@ -361,6 +541,9 @@ def create_computer():
isprimary=True,
))
# Remote-access protocols
_sync_access_methods(comp, data)
# Audit log
AuditLog.log('created', 'Computer', entityid=comp.computerid,
entityname=data.get('hostname') or data['assetnumber'])
@@ -369,12 +552,14 @@ def create_computer():
result = asset.to_dict()
result['computer'] = comp.to_dict()
result['accessmethods'] = _computer_access_links(comp)
return success_response(result, message='Computer created', http_code=201)
@computers_bp.route('/<int:computer_id>', methods=['PUT'])
@jwt_required()
@require_permission('computers.edit')
def update_computer(computer_id: int):
"""Update computer (both Asset and Computer records)."""
comp = Computer.query.get(computer_id)
@@ -429,7 +614,7 @@ def update_computer(computer_id: int):
# Update computer fields
computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
'modelnumberid', 'loggedinuser', 'lastreporteddate',
'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
'lastboottime']
for key in computer_fields:
if key in data:
old_val = getattr(comp, key)
@@ -455,6 +640,9 @@ def update_computer(computer_id: int):
elif primary:
primary.ipaddress = None
# Remote-access protocols
_sync_access_methods(comp, data)
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Computer', entityid=comp.computerid,
@@ -464,12 +652,14 @@ def update_computer(computer_id: int):
result = asset.to_dict()
result['computer'] = comp.to_dict()
result['accessmethods'] = _computer_access_links(comp)
return success_response(result, message='Computer updated')
@computers_bp.route('/<int:computer_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def delete_computer(computer_id: int):
"""Delete (soft delete) computer."""
comp = Computer.query.get(computer_id)
@@ -522,6 +712,7 @@ def get_installed_apps(computer_id: int):
@computers_bp.route('/<int:computer_id>/apps', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def add_installed_app(computer_id: int):
"""Add an installed application to a computer."""
comp = Computer.query.get(computer_id)
@@ -578,6 +769,7 @@ def add_installed_app(computer_id: int):
@computers_bp.route('/<int:computer_id>/apps/<int:app_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def remove_installed_app(computer_id: int, app_id: int):
"""Remove an installed application from a computer."""
installed = ComputerInstalledApp.query.filter_by(
@@ -605,6 +797,7 @@ def remove_installed_app(computer_id: int, app_id: int):
@computers_bp.route('/<int:computer_id>/report', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def report_status(computer_id: int):
"""
Report computer status (for agent-based reporting).
@@ -671,9 +864,10 @@ def dashboard_summary():
).all()
# Count shopfloor vs non-shopfloor
sf = ComputerType.query.filter_by(computertype='Shopfloor').first()
shopfloor_count = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True,
Computer.isshopfloor == True
Computer.computertypeid == (sf.computertypeid if sf else -1)
).count()
return success_response({

View File

@@ -1,9 +1,17 @@
"""Computers plugin models."""
from .computer import Computer, ComputerType, ComputerInstalledApp
from .computer import (
Computer,
ComputerType,
ComputerInstalledApp,
AccessProtocol,
ComputerAccess,
)
__all__ = [
'Computer',
'ComputerType',
'ComputerInstalledApp',
'AccessProtocol',
'ComputerAccess',
]

View File

@@ -15,6 +15,7 @@ class ComputerType(BaseModel):
computertype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<ComputerType {self.computertype}>"
@@ -78,24 +79,8 @@ class Computer(BaseModel):
lastreporteddate = db.Column(db.DateTime, nullable=True)
lastboottime = db.Column(db.DateTime, nullable=True)
# Remote access features
isvnc = db.Column(
db.Boolean,
default=False,
comment='VNC remote access enabled'
)
iswinrm = db.Column(
db.Boolean,
default=False,
comment='WinRM enabled'
)
# Classification flags
isshopfloor = db.Column(
db.Boolean,
default=False,
comment='Shopfloor PC (vs office PC)'
)
# Remote access is now modeled per-protocol via the accessmethods
# relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm.
# Relationships
asset = db.relationship(
@@ -115,6 +100,14 @@ class Computer(BaseModel):
lazy='dynamic'
)
# Remote-access protocols enabled on this PC (replaces isvnc/iswinrm)
accessmethods = db.relationship(
'ComputerAccess',
back_populates='computer',
cascade='all, delete-orphan',
lazy='selectin'
)
__table_args__ = (
db.Index('idx_computer_type', 'computertypeid'),
db.Index('idx_computer_hostname', 'hostname'),
@@ -138,6 +131,12 @@ class Computer(BaseModel):
if self.model:
result['modelname'] = self.model.modelnumber
# Names of enabled remote-access protocols (for list badges)
result['accessprotocolnames'] = [
am.protocol.name for am in self.accessmethods
if am.isactive and am.protocol and am.protocol.isactive
]
return result
@@ -182,6 +181,61 @@ class ComputerInstalledApp(db.Model):
db.Index('idx_compapp_app', 'appid'),
)
class AccessProtocol(db.Model):
"""
Catalog of remote-access protocols a PC can expose (VNC, WinRM, RDP, SSH...).
linktemplate builds a connection URL from placeholders {host}, {port},
{scheme}. {host} is the PC hostname joined to the pc_access_domain setting.
Admin-managed; replaces the old fixed isvnc/iswinrm booleans.
"""
__tablename__ = 'accessprotocols'
protocolid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
scheme = db.Column(db.String(20), nullable=False)
defaultport = db.Column(db.Integer, nullable=True)
linktemplate = db.Column(db.String(255), nullable=False)
isactive = db.Column(db.Boolean, default=True, nullable=False)
def to_dict(self):
return {
'protocolid': self.protocolid,
'name': self.name,
'scheme': self.scheme,
'defaultport': self.defaultport,
'linktemplate': self.linktemplate,
'isactive': bool(self.isactive),
}
class ComputerAccess(db.Model):
"""A protocol enabled on a specific PC, with an optional port override."""
__tablename__ = 'computeraccess'
id = db.Column(db.Integer, primary_key=True)
computerid = db.Column(
db.Integer,
db.ForeignKey('computers.computerid', ondelete='CASCADE'),
nullable=False
)
protocolid = db.Column(
db.Integer,
db.ForeignKey('accessprotocols.protocolid'),
nullable=False
)
portoverride = db.Column(db.Integer, nullable=True)
isactive = db.Column(db.Boolean, default=True, nullable=False)
protocol = db.relationship('AccessProtocol')
computer = db.relationship('Computer', back_populates='accessmethods')
__table_args__ = (
db.UniqueConstraint('computerid', 'protocolid', name='uq_computer_protocol'),
db.Index('idx_compaccess_computer', 'computerid'),
)
def to_dict(self):
"""Convert to dictionary."""
return {

View File

@@ -11,7 +11,7 @@ import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType
from .models import Computer, ComputerType, ComputerInstalledApp
from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from .api import computers_bp
logger = logging.getLogger(__name__)
@@ -58,7 +58,7 @@ class ComputersPlugin(BasePlugin):
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Computer, ComputerType, ComputerInstalledApp]
return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
@@ -329,10 +329,11 @@ class ComputersPlugin(BasePlugin):
click.echo(f"Total active computers: {total}")
# Shopfloor count
# Shopfloor count (by the Shopfloor computer type)
sf = ComputerType.query.filter_by(computertype='Shopfloor').first()
shopfloor = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True,
Computer.isshopfloor == True
Computer.computertypeid == (sf.computertypeid if sf else -1)
).count()
click.echo(f" Shopfloor PCs: {shopfloor}")

View File

@@ -7,6 +7,8 @@ from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_re
from ..models import Equipment, EquipmentType
from shopdb.api import require_permission, require_role
equipment_bp = Blueprint('equipment', __name__)
@@ -54,6 +56,7 @@ def get_equipment_type(type_id: int):
@equipment_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment_type():
"""Create a new equipment type."""
data = request.get_json()
@@ -61,7 +64,15 @@ def create_equipment_type():
if not data or not data.get('equipmenttype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'equipmenttype is required')
if EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first():
existing = EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
for key in ('description', 'icon', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
@@ -71,7 +82,7 @@ def create_equipment_type():
t = EquipmentType(
equipmenttype=data['equipmenttype'],
description=data.get('description'),
icon=data.get('icon')
icon=data.get('icon'), color=data.get('color')
)
db.session.add(t)
@@ -82,6 +93,7 @@ def create_equipment_type():
@equipment_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment_type(type_id: int):
"""Update an equipment type."""
t = EquipmentType.query.get(type_id)
@@ -105,7 +117,7 @@ def update_equipment_type(type_id: int):
http_code=409
)
for key in ['equipmenttype', 'description', 'icon', 'isactive']:
for key in ['equipmenttype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
@@ -113,6 +125,23 @@ def update_equipment_type(type_id: int):
return success_response(t.to_dict(), message='Equipment type updated')
@equipment_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment_type(type_id: int):
"""Delete an equipment type. Refused if any asset still uses it."""
t = EquipmentType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Equipment type not found', http_code=404)
inuse = Equipment.query.filter_by(equipmenttypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} asset(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Equipment type deleted')
# =============================================================================
# Equipment CRUD
# =============================================================================
@@ -232,6 +261,7 @@ def get_equipment_by_asset(asset_id: int):
@equipment_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment():
"""
Create new equipment (creates both Asset and Equipment records).
@@ -321,6 +351,7 @@ def create_equipment():
@equipment_bp.route('/<int:equipment_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment(equipment_id: int):
"""Update equipment (both Asset and Equipment records)."""
equip = Equipment.query.get(equipment_id)
@@ -391,6 +422,7 @@ def update_equipment(equipment_id: int):
@equipment_bp.route('/<int:equipment_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment(equipment_id: int):
"""Delete (soft delete) equipment."""
equip = Equipment.query.get(equipment_id)

View File

@@ -15,6 +15,7 @@ class EquipmentType(BaseModel):
equipmenttype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<EquipmentType {self.equipmenttype}>"

View File

@@ -16,6 +16,8 @@ from shopdb.api import (
from ..models import KnowledgeBase
from shopdb.api import require_permission, require_role
knowledgebase_bp = Blueprint('knowledgebase', __name__)
@@ -137,6 +139,7 @@ def track_click(link_id: int):
@knowledgebase_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('kb.create')
def create_article():
"""Create a new knowledge base article."""
data = request.get_json()
@@ -169,6 +172,7 @@ def create_article():
@knowledgebase_bp.route('/<int:link_id>', methods=['PUT'])
@jwt_required()
@require_permission('kb.edit')
def update_article(link_id: int):
"""Update a knowledge base article."""
article = KnowledgeBase.query.get(link_id)
@@ -197,6 +201,7 @@ def update_article(link_id: int):
@knowledgebase_bp.route('/<int:link_id>', methods=['DELETE'])
@jwt_required()
@require_permission('kb.delete')
def delete_article(link_id: int):
"""Delete (deactivate) a knowledge base article."""
article = KnowledgeBase.query.get(link_id)

View File

@@ -7,6 +7,8 @@ from shopdb.api import db, Asset, AssetType, Vendor, AuditLog, success_response,
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from shopdb.api import require_permission, require_role
network_bp = Blueprint('network', __name__)
@@ -54,6 +56,7 @@ def get_network_device_type(type_id: int):
@network_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('network.create')
def create_network_device_type():
"""Create a new network device type."""
data = request.get_json()
@@ -61,7 +64,15 @@ def create_network_device_type():
if not data or not data.get('networkdevicetype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'networkdevicetype is required')
if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first():
existing = NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
for key in ('description', 'icon', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(
ErrorCodes.CONFLICT,
f"Network device type '{data['networkdevicetype']}' already exists",
@@ -71,7 +82,7 @@ def create_network_device_type():
t = NetworkDeviceType(
networkdevicetype=data['networkdevicetype'],
description=data.get('description'),
icon=data.get('icon')
icon=data.get('icon'), color=data.get('color')
)
db.session.add(t)
@@ -82,6 +93,7 @@ def create_network_device_type():
@network_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('network.edit')
def update_network_device_type(type_id: int):
"""Update a network device type."""
t = NetworkDeviceType.query.get(type_id)
@@ -105,7 +117,7 @@ def update_network_device_type(type_id: int):
http_code=409
)
for key in ['networkdevicetype', 'description', 'icon', 'isactive']:
for key in ['networkdevicetype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
@@ -113,6 +125,23 @@ def update_network_device_type(type_id: int):
return success_response(t.to_dict(), message='Network device type updated')
@network_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('network.delete')
def delete_network_device_type(type_id: int):
"""Delete a network device type. Refused if any device still uses it."""
t = NetworkDeviceType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Network device type not found', http_code=404)
inuse = NetworkDevice.query.filter_by(networkdevicetypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} device(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Network device type deleted')
# =============================================================================
# Network Devices CRUD
# =============================================================================
@@ -264,6 +293,7 @@ def get_network_device_by_hostname(hostname: str):
@network_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('network.create')
def create_network_device():
"""
Create new network device (creates both Asset and NetworkDevice records).
@@ -360,6 +390,7 @@ def create_network_device():
@network_bp.route('/<int:device_id>', methods=['PUT'])
@jwt_required()
@require_permission('network.edit')
def update_network_device(device_id: int):
"""Update network device (both Asset and NetworkDevice records)."""
netdev = NetworkDevice.query.get(device_id)
@@ -437,6 +468,7 @@ def update_network_device(device_id: int):
@network_bp.route('/<int:device_id>', methods=['DELETE'])
@jwt_required()
@require_permission('network.delete')
def delete_network_device(device_id: int):
"""Delete (soft delete) network device."""
netdev = NetworkDevice.query.get(device_id)
@@ -567,6 +599,7 @@ def get_vlan(vlan_id: int):
@network_bp.route('/vlans', methods=['POST'])
@jwt_required()
@require_permission('network.create')
def create_vlan():
"""Create a new VLAN."""
data = request.get_json()
@@ -608,6 +641,7 @@ def create_vlan():
@network_bp.route('/vlans/<int:vlan_id>', methods=['PUT'])
@jwt_required()
@require_permission('network.edit')
def update_vlan(vlan_id: int):
"""Update a VLAN."""
vlan = VLAN.query.get(vlan_id)
@@ -653,6 +687,7 @@ def update_vlan(vlan_id: int):
@network_bp.route('/vlans/<int:vlan_id>', methods=['DELETE'])
@jwt_required()
@require_permission('network.delete')
def delete_vlan(vlan_id: int):
"""Delete (soft delete) a VLAN."""
vlan = VLAN.query.get(vlan_id)
@@ -746,6 +781,7 @@ def get_subnet(subnet_id: int):
@network_bp.route('/subnets', methods=['POST'])
@jwt_required()
@require_permission('network.create')
def create_subnet():
"""Create a new subnet."""
data = request.get_json()
@@ -811,6 +847,7 @@ def create_subnet():
@network_bp.route('/subnets/<int:subnet_id>', methods=['PUT'])
@jwt_required()
@require_permission('network.edit')
def update_subnet(subnet_id: int):
"""Update a subnet."""
subnet = Subnet.query.get(subnet_id)
@@ -861,6 +898,7 @@ def update_subnet(subnet_id: int):
@network_bp.route('/subnets/<int:subnet_id>', methods=['DELETE'])
@jwt_required()
@require_permission('network.delete')
def delete_subnet(subnet_id: int):
"""Delete (soft delete) a subnet."""
subnet = Subnet.query.get(subnet_id)

View File

@@ -15,6 +15,7 @@ class NetworkDeviceType(BaseModel):
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<NetworkDeviceType {self.networkdevicetype}>"

View File

@@ -1,6 +1,10 @@
"""Notifications plugin API endpoints - adapted to existing schema."""
from datetime import datetime
import hashlib
import os
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
@@ -8,8 +12,157 @@ from shopdb.api import db, success_response, error_response, paginated_response,
from ..models import Notification, NotificationType
from shopdb.api import require_permission, require_role
notifications_bp = Blueprint('notifications', __name__)
# Notification types whose multi-employee cards get split into one card per
# employee on the shopfloor dashboard. typecolor drives this (not typename), so
# recognition, training and recertification all fan out; every other type
# stays one card.
SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'})
# How long a card stays up on the shopfloor board when the creator does not set
# an explicit end time, by notification typecolor.
# recognition - clears at the next 8:00 AM Eastern (daily reset)
# recertification - stays up two weeks (employees have time to book the course)
EASTERN = ZoneInfo('America/New_York')
RECERTIFICATION_DAYS = 14
def _next_eastern_time(after, hour, minute=0):
"""Next hour:minute America/New_York strictly after `after` (naive UTC),
returned as naive UTC. Uses the tz database so it is correct across EST/EDT."""
after_east = after.replace(tzinfo=timezone.utc).astimezone(EASTERN)
target = after_east.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
if target <= after_east:
target += timedelta(days=1)
return target.astimezone(timezone.utc).replace(tzinfo=None)
def _auto_endtime(ntype, starttime):
"""Default display window for a notification with no explicit end time, from
the notification type's configured expiry rule:
'dailytime' -> next expiryhour:expiryminute Eastern (daily reset)
'duration' -> starttime + expirydays days
'none' -> None (show indefinitely)
Falls back to the legacy typecolor rules when the expiry columns are unset,
so it is safe before/after the 7d03 migration."""
mode = getattr(ntype, 'expirymode', None) or 'none'
if mode == 'dailytime':
hour = ntype.expiryhour if ntype.expiryhour is not None else 8
return _next_eastern_time(starttime, hour, ntype.expiryminute or 0)
if mode == 'duration' and ntype.expirydays:
return starttime + timedelta(days=int(ntype.expirydays))
if mode == 'none':
# legacy fallback for rule-bearing types created before the expiry columns
if getattr(ntype, 'typecolor', None) == 'recognition':
return _next_eastern_time(starttime, 8, 0)
if getattr(ntype, 'typecolor', None) == 'recertification':
return starttime + timedelta(days=RECERTIFICATION_DAYS)
return None
_EXPIRY_MODES = ('none', 'duration', 'dailytime')
def _apply_expiry_fields(t, data):
"""Set expiry-rule columns on a NotificationType from request data. Only
touches fields that are present. Returns an error string, or None on ok."""
if 'expirymode' in data:
mode = data.get('expirymode') or 'none'
if mode not in _EXPIRY_MODES:
return "expirymode must be one of: %s" % ", ".join(_EXPIRY_MODES)
t.expirymode = mode
if 'expirydays' in data:
v = data.get('expirydays')
if v in (None, ''):
t.expirydays = None
else:
try:
t.expirydays = int(v)
except (TypeError, ValueError):
return "expirydays must be an integer"
if t.expirydays < 1:
return "expirydays must be >= 1"
if 'expiryhour' in data:
v = data.get('expiryhour')
if v in (None, ''):
t.expiryhour = None
else:
try:
t.expiryhour = int(v)
except (TypeError, ValueError):
return "expiryhour must be an integer"
if not (0 <= t.expiryhour <= 23):
return "expiryhour must be 0-23"
if 'expiryminute' in data:
v = data.get('expiryminute')
try:
t.expiryminute = int(v) if v not in (None, '') else 0
except (TypeError, ValueError):
return "expiryminute must be an integer"
if not (0 <= t.expiryminute <= 59):
return "expiryminute must be 0-59"
# cross-field consistency
mode = t.expirymode or 'none'
if mode == 'dailytime' and t.expiryhour is None:
t.expiryhour = 8
if mode == 'duration' and not t.expirydays:
return "duration expiry requires expirydays >= 1"
return None
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
def _apply_display_fields(t, data):
"""Set shopfloor display-behavior columns on a NotificationType from request
data. Only touches fields that are present. Returns an error string, or None."""
if 'splitperemployee' in data:
t.splitperemployee = bool(data.get('splitperemployee'))
if 'showemployeephoto' in data:
t.showemployeephoto = bool(data.get('showemployeephoto'))
if 'displaystyle' in data:
ds = data.get('displaystyle') or 'standard'
if ds not in _DISPLAY_STYLES:
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
t.displaystyle = ds
return None
def _config_version():
"""Short hash of everything that changes the board's LAYOUT: per-type display
config plus an optional deploy stamp (SHOPFLOOR_BUILD env). The shopfloor
kiosks reload when this changes, so type/layout edits and frontend deploys
reach pages that are already open."""
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
parts = [
"%s|%s|%s|%d|%d|%s|%s|%s|%d" % (
t.notificationtypeid, t.typecolor, t.displaystyle,
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
)
for t in types
]
parts.append(os.environ.get('SHOPFLOOR_BUILD', ''))
return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12]
def _employee_picture(sso):
"""Best-effort Picture blob for an SSO from the HR directory. None on any miss."""
if not (sso and str(sso).isdigit()):
return None
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone()
conn.close()
return emp.get('Picture') if emp else None
except Exception:
return None
# =============================================================================
# Notification Types
@@ -35,6 +188,7 @@ def list_notification_types():
@notifications_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('notifications.create')
def create_notification_type():
"""Create a new notification type."""
data = request.get_json()
@@ -55,12 +209,57 @@ def create_notification_type():
typecolor=data.get('typecolor') or data.get('color', '#17a2b8')
)
err = _apply_expiry_fields(t, data)
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
err = _apply_display_fields(t, data)
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Notification type created', http_code=201)
@notifications_bp.route('/types/<int:type_id>', methods=['PUT', 'PATCH'])
@jwt_required()
@require_permission('notifications.create')
def update_notification_type(type_id: int):
"""Update a notification type, including its auto-expiry rule."""
t = NotificationType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, f'Notification type {type_id} not found', http_code=404)
data = request.get_json() or {}
if data.get('typename'):
dup = NotificationType.query.filter(
NotificationType.typename == data['typename'],
NotificationType.notificationtypeid != type_id
).first()
if dup:
return error_response(ErrorCodes.CONFLICT,
f"Notification type '{data['typename']}' already exists", http_code=409)
t.typename = data['typename']
if 'typedescription' in data or 'description' in data:
t.typedescription = data.get('typedescription') or data.get('description')
if 'typecolor' in data or 'color' in data:
t.typecolor = data.get('typecolor') or data.get('color')
if 'isactive' in data:
t.isactive = bool(data['isactive'])
err = _apply_expiry_fields(t, data)
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
err = _apply_display_fields(t, data)
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
db.session.commit()
return success_response(t.to_dict(), message='Notification type updated')
# =============================================================================
# Notifications CRUD
# =============================================================================
@@ -132,6 +331,7 @@ def get_notification(notification_id: int):
@notifications_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('notifications.create')
def create_notification():
"""Create a new notification."""
data = request.get_json()
@@ -161,6 +361,13 @@ def create_notification():
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
# No explicit end time: apply the per-type display window (recognition
# clears at the next 8 AM Eastern, recertification runs two weeks).
if endtime is None and data.get('notificationtypeid'):
ntype = NotificationType.query.get(data['notificationtypeid'])
if ntype:
endtime = _auto_endtime(ntype, starttime)
n = Notification(
notification=notification_text,
notificationtypeid=data.get('notificationtypeid'),
@@ -184,6 +391,7 @@ def create_notification():
@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
@jwt_required()
@require_permission('notifications.edit')
def update_notification(notification_id: int):
"""Update a notification."""
n = Notification.query.get(notification_id)
@@ -250,6 +458,7 @@ def update_notification(notification_id: int):
@notifications_bp.route('/<int:notification_id>', methods=['DELETE'])
@jwt_required()
@require_permission('notifications.delete')
def delete_notification(notification_id: int):
"""Delete (soft delete) a notification."""
n = Notification.query.get(notification_id)
@@ -427,7 +636,7 @@ def get_shopfloor_notifications():
Get notifications for shopfloor TV dashboard.
Returns current and upcoming notifications with isshopfloor=1.
Splits multi-employee recognition into separate entries.
Splits multi-employee recognition and training into separate entries.
Query parameters:
- businessunit: Filter by business unit ID (null = all units)
@@ -487,6 +696,8 @@ def get_shopfloor_notifications():
def notification_to_shopfloor(n, employee_override=None):
"""Convert notification to shopfloor format."""
is_resolved = n.endtime and n.endtime < now
ntype = n.notificationtype
show_photo = bool(ntype and ntype.showemployeephoto)
result = {
'notificationid': n.notificationid,
@@ -498,11 +709,13 @@ def get_shopfloor_notifications():
'isactive': n.isactive,
'isshopfloor': True,
'resolved': is_resolved,
'typename': n.notificationtype.typename if n.notificationtype else None,
'typecolor': n.notificationtype.typecolor if n.notificationtype else None,
'typename': ntype.typename if ntype else None,
'typecolor': ntype.typecolor if ntype else None,
# Per-type display behavior the dashboard groups/renders by.
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
}
# Employee info
# Employee info (photo only when the type wants it)
if employee_override:
result['employeesso'] = employee_override.get('sso')
result['employeename'] = employee_override.get('name')
@@ -510,92 +723,36 @@ def get_shopfloor_notifications():
else:
result['employeesso'] = n.employeesso
result['employeename'] = n.employeename
result['employeepicture'] = None
# Try to get picture from wjf_employees
if n.employeesso and n.employeesso.isdigit():
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),))
emp = cur.fetchone()
if emp and emp.get('Picture'):
result['employeepicture'] = emp['Picture']
conn.close()
except Exception:
pass
result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None
return result
# Process current notifications (split multi-employee recognition)
current_data = []
for n in current_notifications:
is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition'
def expand(n):
"""One shopfloor card per notification, or one per employee when the
type is configured to split multi-employee lists."""
ntype = n.notificationtype
is_split = bool(ntype and ntype.splitperemployee)
if not (is_split and n.employeesso and ',' in n.employeesso):
return [notification_to_shopfloor(n)]
if is_recognition and n.employeesso and ',' in n.employeesso:
# Split into individual cards for each employee
ssos = [s.strip() for s in n.employeesso.split(',')]
names = n.employeename.split(', ') if n.employeename else []
show_photo = bool(ntype and ntype.showemployeephoto)
ssos = [s.strip() for s in n.employeesso.split(',')]
names = n.employeename.split(', ') if n.employeename else []
return [
notification_to_shopfloor(n, {
'sso': sso,
'name': names[i] if i < len(names) else sso,
'picture': _employee_picture(sso) if show_photo else None,
})
for i, sso in enumerate(ssos)
]
for i, sso in enumerate(ssos):
name = names[i] if i < len(names) else sso
# Look up picture
picture = None
if sso.isdigit():
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone()
if emp:
picture = emp.get('Picture')
conn.close()
except Exception:
pass
current_data.append(notification_to_shopfloor(n, {
'sso': sso,
'name': name,
'picture': picture
}))
else:
current_data.append(notification_to_shopfloor(n))
# Process upcoming notifications
upcoming_data = []
for n in upcoming_notifications:
is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition'
if is_recognition and n.employeesso and ',' in n.employeesso:
ssos = [s.strip() for s in n.employeesso.split(',')]
names = n.employeename.split(', ') if n.employeename else []
for i, sso in enumerate(ssos):
name = names[i] if i < len(names) else sso
picture = None
if sso.isdigit():
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone()
if emp:
picture = emp.get('Picture')
conn.close()
except Exception:
pass
upcoming_data.append(notification_to_shopfloor(n, {
'sso': sso,
'name': name,
'picture': picture
}))
else:
upcoming_data.append(notification_to_shopfloor(n))
current_data = [card for n in current_notifications for card in expand(n)]
upcoming_data = [card for n in upcoming_notifications for card in expand(n)]
return success_response({
'timestamp': now.isoformat(),
'current': current_data,
'upcoming': upcoming_data
'upcoming': upcoming_data,
'configversion': _config_version(),
})

View File

@@ -17,6 +17,25 @@ class NotificationType(db.Model):
typecolor = db.Column(db.String(20), default='#17a2b8')
isactive = db.Column(db.Boolean, default=True)
# Auto-expiry rule: when a notification of this type has no explicit end time,
# how long it stays up on the shopfloor board.
# 'none' -> indefinite (never auto-expires)
# 'duration' -> starttime + expirydays days
# 'dailytime' -> next expiryhour:expiryminute Eastern (daily reset)
expirymode = db.Column(db.String(20), default='none')
expirydays = db.Column(db.Integer, nullable=True)
expiryhour = db.Column(db.SmallInteger, nullable=True)
expiryminute = db.Column(db.SmallInteger, nullable=True, default=0)
# Shopfloor display behavior (data-driven; replaces hardcoded per-type logic).
# splitperemployee -> one card per listed employee SSO
# showemployeephoto -> resolve + show each employee's photo + name
# displaystyle -> 'standard' (rows) | 'carousel' (rotating photo card)
# | 'grid' (cycling row of tiles) | 'banner'
splitperemployee = db.Column(db.Boolean, default=False)
showemployeephoto = db.Column(db.Boolean, default=False)
displaystyle = db.Column(db.String(20), default='standard')
def __repr__(self):
return f"<NotificationType {self.typename}>"
@@ -26,7 +45,14 @@ class NotificationType(db.Model):
'typename': self.typename,
'typedescription': self.typedescription,
'typecolor': self.typecolor,
'isactive': self.isactive
'isactive': self.isactive,
'expirymode': self.expirymode or 'none',
'expirydays': self.expirydays,
'expiryhour': self.expiryhour,
'expiryminute': self.expiryminute if self.expiryminute is not None else 0,
'splitperemployee': bool(self.splitperemployee),
'showemployeephoto': bool(self.showemployeephoto),
'displaystyle': self.displaystyle or 'standard'
}
@@ -52,8 +78,11 @@ class Notification(db.Model):
link = db.Column(db.String(500), nullable=True)
isactive = db.Column(db.Boolean, default=True)
isshopfloor = db.Column(db.Boolean, default=False)
employeesso = db.Column(db.String(100), nullable=True)
employeename = db.Column(db.String(100), nullable=True)
# TEXT (not VARCHAR): recognition/recertification notifications comma-join
# every employee's SSO/name into one field, which overflows 100 chars once
# ~11 people are listed.
employeesso = db.Column(db.Text, nullable=True)
employeename = db.Column(db.Text, nullable=True)
# Relationships
notificationtype = db.relationship('NotificationType', backref='notifications')
@@ -114,24 +143,25 @@ class Notification(db.Model):
def to_calendar_event(self):
"""Convert to FullCalendar event format."""
# Map Bootstrap color names to hex colors
color_map = {
# Color is data-driven: types store a hex typecolor. Only the legacy
# Bootstrap color-name aliases still need translating; hex passes through.
color_aliases = {
'success': '#04b962',
'warning': '#ff8800',
'danger': '#f5365c',
'info': '#14abef',
'primary': '#7934f3',
'secondary': '#94614f',
'recognition': '#14abef', # Blue for recognition
}
raw_color = self.notificationtype.typecolor if self.notificationtype else 'info'
# Use mapped color if it's a Bootstrap name, otherwise use as-is (hex)
color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
ntype = self.notificationtype
raw_color = ntype.typecolor if ntype else '#14abef'
color = color_aliases.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
show_photo = bool(ntype and getattr(ntype, 'showemployeephoto', False))
# For recognition notifications, include employee name (or SSO as fallback) in title
# Employee-photo types prefix the card with the person's name/SSO.
title = self.title
if raw_color == 'recognition':
if show_photo:
employee_display = self.employeename or self.employeesso
if employee_display:
title = f"{employee_display}: {title}"
@@ -147,8 +177,10 @@ class Notification(db.Model):
'extendedProps': {
'notificationid': self.notificationid,
'message': self.notification,
'typename': self.notificationtype.typename if self.notificationtype else None,
'typename': ntype.typename if ntype else None,
'typecolor': raw_color,
'showemployeephoto': show_photo,
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
'linkurl': self.link,
'ticketnumber': self.ticketnumber,
'employeename': self.employeename,

View File

@@ -73,23 +73,41 @@ class NotificationsPlugin(BasePlugin):
logger.info("Notifications plugin installed")
def _ensure_notification_types(self) -> None:
"""Ensure default notification types exist."""
"""Ensure default notification types exist.
For the special shopfloor types (recognition, training,
recertification) the typecolor is a keyword the dashboard maps to a
style and the feed uses to split multi-employee cards per employee;
generic types use a hex color.
"""
# (typename, typedescription, typecolor, expirymode, expirydays,
# expiryhour, splitperemployee, showemployeephoto, displaystyle)
default_types = [
('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'),
('Change', 'Planned change notification', '#ffc107', 'exchange-alt'),
('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'),
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'),
('General', 'General announcement', '#28a745', 'bullhorn'),
('Awareness', 'General awareness notification', '#17a2b8', 'none', None, None, False, False, 'standard'),
('Change', 'Planned change notification', '#ffc107', 'none', None, None, False, False, 'standard'),
('Incident', 'Incident or outage notification', '#dc3545', 'none', None, None, False, False, 'standard'),
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'none', None, None, False, False, 'standard'),
('General', 'General announcement', '#28a745', 'none', None, None, False, False, 'standard'),
('Recognition', 'Employee recognition (clears at 8 AM Eastern)', '#ffc107', 'dailytime', None, 8, True, True, 'carousel'),
('Training', 'Training notice (one card per employee)', '#17a2b8', 'none', None, None, True, True, 'carousel'),
('Recertification', 'Employees due to retake a training course (shows two weeks)', '#0d6efd', 'duration', 14, None, True, True, 'grid'),
]
for typename, description, color, icon in default_types:
for (typename, typedescription, typecolor, expirymode, expirydays,
expiryhour, splitperemployee, showemployeephoto, displaystyle) in default_types:
existing = NotificationType.query.filter_by(typename=typename).first()
if not existing:
t = NotificationType(
typename=typename,
description=description,
color=color,
icon=icon
typedescription=typedescription,
typecolor=typecolor,
expirymode=expirymode,
expirydays=expirydays,
expiryhour=expiryhour,
expiryminute=0,
splitperemployee=splitperemployee,
showemployeephoto=showemployeephoto,
displaystyle=displaystyle
)
db.session.add(t)
logger.debug(f"Created notification type: {typename}")

View File

@@ -5,9 +5,9 @@ import logging
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Printer, PrinterType, ModelSupply
from ..models import Printer, PrinterType, ModelSupply, PrinterDriver
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
from ..services import (
ZabbixService,
@@ -19,6 +19,8 @@ from ..services import (
logger = logging.getLogger(__name__)
from shopdb.api import require_permission, require_role
printers_asset_bp = Blueprint('printers_asset', __name__)
@@ -66,6 +68,7 @@ def get_printer_type(type_id: int):
@printers_asset_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def create_printer_type():
"""Create a new printer type."""
data = request.get_json()
@@ -73,7 +76,15 @@ def create_printer_type():
if not data or not data.get('printertype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'printertype is required')
if PrinterType.query.filter_by(printertype=data['printertype']).first():
existing = PrinterType.query.filter_by(printertype=data['printertype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
for key in ('description', 'icon', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(
ErrorCodes.CONFLICT,
f"Printer type '{data['printertype']}' already exists",
@@ -83,7 +94,7 @@ def create_printer_type():
t = PrinterType(
printertype=data['printertype'],
description=data.get('description'),
icon=data.get('icon')
icon=data.get('icon'), color=data.get('color')
)
db.session.add(t)
@@ -92,6 +103,110 @@ def create_printer_type():
return success_response(t.to_dict(), message='Printer type created', http_code=201)
@printers_asset_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('printers.edit')
def update_printer_type(type_id: int):
"""Update a printer type."""
t = PrinterType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND,
f'Printer type with ID {type_id} not found', http_code=404)
data = request.get_json() or {}
if 'printertype' in data and data['printertype'] != t.printertype:
if PrinterType.query.filter_by(printertype=data['printertype']).first():
return error_response(ErrorCodes.CONFLICT,
f"Printer type '{data['printertype']}' already exists", http_code=409)
for key in ['printertype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Printer type updated')
@printers_asset_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('printers.delete')
def delete_printer_type(type_id: int):
"""Delete a printer type. Refused if any printer still uses it."""
t = PrinterType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Printer type not found', http_code=404)
inuse = Printer.query.filter_by(printertypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} printer(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Printer type deleted')
# =============================================================================
# Printer Drivers (named SMB / HTTP links to driver packages)
# =============================================================================
@printers_asset_bp.route('/drivers', methods=['GET'])
@jwt_required(optional=True)
def list_drivers():
"""List printer drivers. ?active=false includes inactive ones."""
query = PrinterDriver.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter_by(isactive=True)
drivers = query.order_by(PrinterDriver.name).all()
return success_response([d.to_dict() for d in drivers])
@printers_asset_bp.route('/drivers', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def create_driver():
data = request.get_json() or {}
if not (data.get('name') and data.get('location')):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name and location are required')
d = PrinterDriver(
name=data['name'],
location=data['location'],
description=data.get('description'),
modelnumberid=data.get('modelnumberid') or None,
isactive=data.get('isactive', True),
)
db.session.add(d)
db.session.commit()
return success_response(d.to_dict(), message='Driver created', http_code=201)
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['PUT'])
@jwt_required()
@require_permission('printers.edit')
def update_driver(driver_id):
d = PrinterDriver.query.get(driver_id)
if not d:
return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404)
data = request.get_json() or {}
for key in ('name', 'location', 'description', 'isactive'):
if key in data:
setattr(d, key, data[key])
if 'modelnumberid' in data:
d.modelnumberid = data['modelnumberid'] or None
db.session.commit()
return success_response(d.to_dict(), message='Driver updated')
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['DELETE'])
@jwt_required()
@require_permission('printers.delete')
def delete_driver(driver_id):
d = PrinterDriver.query.get(driver_id)
if not d:
return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404)
db.session.delete(d)
db.session.commit()
return success_response(message='Driver deleted')
# =============================================================================
# Printers CRUD
# =============================================================================
@@ -235,6 +350,50 @@ def printer_install_list():
return success_response(rows)
@printers_asset_bp.route('/pc-default', methods=['GET'])
@jwt_required(optional=True)
def pc_default_printer():
"""Default printer for a PC, by machine (asset) number.
Parity with classic apipcdefaultprinter.asp: the signed installer EXE
preselects a PC's default-printer hotspot on the site-map wizard using the
machine number persisted at PXE enrollment. The link is a `defaultprinter`
asset relationship (PC asset -> printer asset), so this stays inside the
contract surface (no cross-plugin model import).
Returns {printerid, windowsname}, or {} when the machine is unknown or has
no active default printer set.
"""
machine = (request.args.get('machine') or '').strip()
if not machine:
return success_response({})
pc = Asset.query.filter_by(assetnumber=machine, isactive=True).first()
dp_type = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first()
if not pc or not dp_type:
return success_response({})
rel = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid,
relationshiptypeid=dp_type.relationshiptypeid,
isactive=True,
).first()
if not rel:
return success_response({})
printer = db.session.query(Printer).join(Asset).filter(
Printer.assetid == rel.targetassetid,
Asset.isactive == True,
).first()
if not printer:
return success_response({})
return success_response({
'printerid': printer.printerid,
'windowsname': printer.windowsname,
})
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
@jwt_required(optional=True)
def get_printer(printer_id: int):
@@ -256,6 +415,15 @@ def get_printer(printer_id: int):
comms = Communication.query.filter_by(assetid=printer.asset.assetid).all()
result['communications'] = [c.to_dict() for c in comms]
# Attach active drivers that match this printer's model
if printer.modelnumberid:
drivers = PrinterDriver.query.filter_by(
modelnumberid=printer.modelnumberid, isactive=True
).order_by(PrinterDriver.name).all()
result['drivers'] = [d.to_dict() for d in drivers]
else:
result['drivers'] = []
return success_response(result)
@@ -280,6 +448,7 @@ def get_printer_by_asset(asset_id: int):
@printers_asset_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def create_printer():
"""
Create new printer (creates both Asset and Printer records).
@@ -379,6 +548,7 @@ def create_printer():
@printers_asset_bp.route('/<int:printer_id>', methods=['PUT'])
@jwt_required()
@require_permission('printers.edit')
def update_printer(printer_id: int):
"""Update printer (both Asset and Printer records)."""
printer = Printer.query.get(printer_id)
@@ -453,6 +623,7 @@ def update_printer(printer_id: int):
@printers_asset_bp.route('/<int:printer_id>', methods=['DELETE'])
@jwt_required()
@require_permission('printers.delete')
def delete_printer(printer_id: int):
"""Delete (soft delete) printer."""
printer = Printer.query.get(printer_id)
@@ -697,6 +868,7 @@ def printer_lookup():
@printers_asset_bp.route('/supplies/refresh', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def refresh_supplies_cache():
"""Clear cached Zabbix supply data so the next read pulls fresh values.
@@ -878,6 +1050,7 @@ def list_model_supplies(modelnumberid: int):
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def create_model_supply(modelnumberid: int):
"""Add a supply to a model."""
model = Model.query.get(modelnumberid)
@@ -918,6 +1091,7 @@ def create_model_supply(modelnumberid: int):
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['PUT'])
@jwt_required()
@require_permission('printers.edit')
def update_model_supply(modelsupplyid: int):
"""Update a model supply."""
supply = ModelSupply.query.get(modelsupplyid)
@@ -962,6 +1136,7 @@ def update_model_supply(modelsupplyid: int):
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['DELETE'])
@jwt_required()
@require_permission('printers.delete')
def delete_model_supply(modelsupplyid: int):
"""Delete a model supply."""
supply = ModelSupply.query.get(modelsupplyid)

Some files were not shown because too many files have changed in this diff Show More