diff --git a/.env.example b/.env.example index 0fa2371..15e2970 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,11 @@ JWT_SECRET_KEY=change-this-to-another-secure-random-string # ---- Database (required) ---- -# Format: mysql+pymysql://:@:/ +# Format: mysql+pymysql://:@:/?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 diff --git a/deploy/windows/web.config b/deploy/windows/web.config new file mode 100644 index 0000000..3d06ba3 --- /dev/null +++ b/deploy/windows/web.config @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml index aae8bdd..95b8543 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docs/DEPLOY-WINDOWS-IIS.md b/docs/DEPLOY-WINDOWS-IIS.md new file mode 100644 index 0000000..7a027a7 --- /dev/null +++ b/docs/DEPLOY-WINDOWS-IIS.md @@ -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= +DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@:3306/shopdb_flask?charset=utf8mb4 +CORS_ORIGINS=https:// +``` + +`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:/// # returns index.html +# API rejects an empty login with a validation error (health signal): +curl.exe -k -X POST https:///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). | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index e1f5190..d7a3c94 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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 diff --git a/docs/adr/ADR-004-deployment-topology.md b/docs/adr/ADR-004-deployment-topology.md index 0bb2340..666e630 100644 --- a/docs/adr/ADR-004-deployment-topology.md +++ b/docs/adr/ADR-004-deployment-topology.md @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6734fcc..520ea3f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 56ec675..92d5290 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 2c2bd43..ce36406 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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') + } +} diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 681aeb3..d87047f 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -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; +} diff --git a/frontend/src/components/AssetRelationships.vue b/frontend/src/components/AssetRelationships.vue index f378717..e82146a 100644 --- a/frontend/src/components/AssetRelationships.vue +++ b/frontend/src/components/AssetRelationships.vue @@ -33,7 +33,7 @@ {{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
- {{ rel.relationshiptypename }} + {{ rel.relationshiptypename }} {{ rel.targetasset?.assettype }}
{{ rel.notes }}
@@ -65,7 +65,7 @@ {{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
- {{ rel.relationshiptypename }} + {{ rel.relationshiptypename }} {{ rel.sourceasset?.assettype }}
{{ rel.notes }}
@@ -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 diff --git a/frontend/src/components/ColorSwatchPicker.vue b/frontend/src/components/ColorSwatchPicker.vue new file mode 100644 index 0000000..ffc197a --- /dev/null +++ b/frontend/src/components/ColorSwatchPicker.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/components/CustomFieldsInputs.vue b/frontend/src/components/CustomFieldsInputs.vue new file mode 100644 index 0000000..59ef192 --- /dev/null +++ b/frontend/src/components/CustomFieldsInputs.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/components/CustomFieldsSection.vue b/frontend/src/components/CustomFieldsSection.vue new file mode 100644 index 0000000..f86c766 --- /dev/null +++ b/frontend/src/components/CustomFieldsSection.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/components/EmbeddedLocationMap.vue b/frontend/src/components/EmbeddedLocationMap.vue index b6903ff..22180a6 100644 --- a/frontend/src/components/EmbeddedLocationMap.vue +++ b/frontend/src/components/EmbeddedLocationMap.vue @@ -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() }) diff --git a/frontend/src/components/LocationMapTooltip.vue b/frontend/src/components/LocationMapTooltip.vue index 10e6a93..e4532d2 100644 --- a/frontend/src/components/LocationMapTooltip.vue +++ b/frontend/src/components/LocationMapTooltip.vue @@ -43,6 +43,11 @@ + + diff --git a/frontend/src/composables/mapConfig.js b/frontend/src/composables/mapConfig.js new file mode 100644 index 0000000..8ef8dce --- /dev/null +++ b/frontend/src/composables/mapConfig.js @@ -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 } +} diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 7b8aed1..3e11e4e 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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 = [ { diff --git a/frontend/src/router/routes/computers.js b/frontend/src/router/routes/computers.js index 9da09ff..121f29f 100644 --- a/frontend/src/router/routes/computers.js +++ b/frontend/src/router/routes/computers.js @@ -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 } } ] diff --git a/frontend/src/router/routes/core.js b/frontend/src/router/routes/core.js index fd818aa..c9d7754 100644 --- a/frontend/src/router/routes/core.js +++ b/frontend/src/router/routes/core.js @@ -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', diff --git a/frontend/src/router/routes/notifications.js b/frontend/src/router/routes/notifications.js index b0401dc..e5dadf9 100644 --- a/frontend/src/router/routes/notifications.js +++ b/frontend/src/router/routes/notifications.js @@ -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', diff --git a/frontend/src/router/routes/printers.js b/frontend/src/router/routes/printers.js index 2dd83d8..763f683 100644 --- a/frontend/src/router/routes/printers.js +++ b/frontend/src/router/routes/printers.js @@ -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 } } ] diff --git a/frontend/src/router/routes/warranty.js b/frontend/src/router/routes/warranty.js new file mode 100644 index 0000000..2249b28 --- /dev/null +++ b/frontend/src/router/routes/warranty.js @@ -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') + } +] diff --git a/frontend/src/utils/assetTypes.js b/frontend/src/utils/assetTypes.js new file mode 100644 index 0000000..677cf91 --- /dev/null +++ b/frontend/src/utils/assetTypes.js @@ -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}` +} diff --git a/frontend/src/utils/colorStyle.js b/frontend/src/utils/colorStyle.js new file mode 100644 index 0000000..e2b76c2 --- /dev/null +++ b/frontend/src/utils/colorStyle.js @@ -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) } +} diff --git a/frontend/src/utils/mapColors.js b/frontend/src/utils/mapColors.js new file mode 100644 index 0000000..e7988e3 --- /dev/null +++ b/frontend/src/utils/mapColors.js @@ -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) +} diff --git a/frontend/src/utils/mapPdf.js b/frontend/src/utils/mapPdf.js new file mode 100644 index 0000000..8601eac --- /dev/null +++ b/frontend/src/utils/mapPdf.js @@ -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`) +} diff --git a/frontend/src/utils/siteSettings.js b/frontend/src/utils/siteSettings.js new file mode 100644 index 0000000..2033c9e --- /dev/null +++ b/frontend/src/utils/siteSettings.js @@ -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') +} diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 272bfc0..83bf707 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -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) diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index dc83ab0..cb2fc62 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -29,13 +29,13 @@