commit e9506b400ff435eed06a17a741c9b1b61051bacc Author: setonc Date: Fri Mar 27 09:06:25 2026 -0400 Initial commit — asset browser web app diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..defe0d5 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy this file to .env and fill in the values. +# Generate SESSION_SECRET with: +# node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" + +NODE_ENV=production +PORT=3000 + +# Must be a long random string — never reuse across apps, never commit the real value +SESSION_SECRET=REPLACE_WITH_64_BYTE_HEX_STRING \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..97725b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +.env +*.log diff --git a/auth/admin.js b/auth/admin.js new file mode 100755 index 0000000..87f50e2 --- /dev/null +++ b/auth/admin.js @@ -0,0 +1,125 @@ +'use strict'; +const express = require('express'); +const bcrypt = require('bcryptjs'); +const { getAllUsers, getUserById, createUser, updateUser } = require('./db'); +const { requireRole } = require('./middleware'); +const { getUsage: getSyncroUsage, getHistory: getSyncroHistory, getLimitHits: getSyncroLimitHits } = require('../syncroStats'); + +const router = express.Router(); + +const VALID_ROLES = ['superduperadmin', 'admin', 'tech', 'client']; +const ELEVATED_ROLES = ['superduperadmin', 'admin']; + +// GET /admin/users +router.get('/users', (req, res) => { + res.json({ users: getAllUsers() }); +}); + +// POST /admin/users — create user +router.post('/users', async (req, res) => { + const username = String(req.body?.username ?? '').trim().toLowerCase(); + const name = String(req.body?.name ?? '').trim(); + const company = String(req.body?.company ?? '').trim(); + const syncroCustomerId = req.body?.syncro_customer_id != null ? parseInt(req.body.syncro_customer_id, 10) : null; + const role = String(req.body?.role ?? ''); + const password = String(req.body?.password ?? ''); + + if (!username || !name || !role || !password) { + return res.status(400).json({ error: 'All fields are required.' }); + } + if (!VALID_ROLES.includes(role)) { + return res.status(400).json({ error: 'Invalid role.' }); + } + if (ELEVATED_ROLES.includes(role) && req.session.user.role !== 'superduperadmin') { + return res.status(403).json({ error: 'Only superduperadmin can assign admin-level roles.' }); + } + if (password.length < 12) { + return res.status(400).json({ error: 'Password must be at least 12 characters.' }); + } + + const hash = await bcrypt.hash(password, 12); + try { + createUser(username, hash, name, role, company, syncroCustomerId); + res.json({ ok: true }); + } catch (err) { + if (err.message?.includes('UNIQUE constraint')) { + return res.status(409).json({ error: 'Username already exists.' }); + } + throw err; + } +}); + +// PATCH /admin/users/:id — update user fields +router.patch('/users/:id', async (req, res) => { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID.' }); + + const current = getUserById(id); + if (!current) return res.status(404).json({ error: 'User not found.' }); + + const isSelf = id === req.session.user.id; + + // Nobody can change their own active status or role via the API + if (isSelf && 'active' in req.body) { + return res.status(403).json({ error: 'Cannot change your own active status.' }); + } + + const fields = {}; + const { name, company, syncro_customer_id, role, active, password } = req.body ?? {}; + + if (name !== undefined) { + fields.name = String(name).trim(); + if (!fields.name) return res.status(400).json({ error: 'Name cannot be empty.' }); + } + + if (company !== undefined) { + fields.company = String(company).trim(); + } + + if (syncro_customer_id !== undefined) { + fields.syncro_customer_id = syncro_customer_id != null ? parseInt(syncro_customer_id, 10) : null; + } + + if (role !== undefined && !isSelf) { + if (!VALID_ROLES.includes(role)) return res.status(400).json({ error: 'Invalid role.' }); + if (ELEVATED_ROLES.includes(role) && req.session.user.role !== 'superduperadmin') { + return res.status(403).json({ error: 'Only superduperadmin can assign admin-level roles.' }); + } + fields.role = role; + } + + if (active !== undefined) { + fields.active = active ? 1 : 0; + } + + if (password !== undefined && password !== '') { + if (String(password).length < 12) { + return res.status(400).json({ error: 'Password must be at least 12 characters.' }); + } + fields.password_hash = await bcrypt.hash(String(password), 12); + } + + updateUser(id, fields); + res.json({ ok: true }); +}); + +// GET /admin/syncro-usage — rolling 60s Syncro API request count + 7-day limit-hit count +router.get('/syncro-usage', (req, res) => { + const limit = 180; + res.json({ requests: getSyncroUsage(), limit, windowMs: 60_000, limitHits7d: getSyncroLimitHits(limit) }); +}); + +// GET /admin/syncro-history?window= — per-minute buckets for the given window (max 7 days) +router.get('/syncro-history', (req, res) => { + const raw = parseInt(req.query.window ?? '3600000', 10); + const windowMs = isNaN(raw) ? 3_600_000 : Math.min(Math.max(raw, 60_000), 7 * 24 * 60 * 60 * 1000); + res.json({ buckets: getSyncroHistory(windowMs), windowMs }); +}); + +// POST /admin/restart — exit cleanly; PM2 will restart the process +router.post('/restart', requireRole('superduperadmin'), (req, res) => { + res.json({ ok: true }); + setTimeout(() => process.exit(0), 150); +}); + +module.exports = router; diff --git a/auth/db.js b/auth/db.js new file mode 100755 index 0000000..9b5f171 --- /dev/null +++ b/auth/db.js @@ -0,0 +1,110 @@ +'use strict'; +const Database = require('better-sqlite3'); +const path = require('path'); + +let _db; + +function getDb() { + if (!_db) { + _db = new Database(path.join(__dirname, '..', 'data', 'app.db')); + _db.pragma('journal_mode = WAL'); + _db.pragma('foreign_keys = ON'); + } + return _db; +} + +function initDb() { + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL COLLATE NOCASE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('superduperadmin','admin','tech','client')), + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + sid TEXT PRIMARY KEY NOT NULL, + sess TEXT NOT NULL, + expired INTEGER NOT NULL + ); + `); + + // Migrations + const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!cols.includes('company')) { + db.exec("ALTER TABLE users ADD COLUMN company TEXT NOT NULL DEFAULT ''"); + } + if (!cols.includes('syncro_customer_id')) { + db.exec('ALTER TABLE users ADD COLUMN syncro_customer_id INTEGER DEFAULT NULL'); + } + + // Label queue migrations + const lqCols = db.prepare('PRAGMA table_info(label_queue)').all().map(c => c.name); + if (lqCols.length && !lqCols.includes('custom_line')) { + db.exec("ALTER TABLE label_queue ADD COLUMN custom_line TEXT DEFAULT NULL"); + } + + // Label Center queue — cross-device label batching + db.exec(` + CREATE TABLE IF NOT EXISTS label_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id INTEGER NOT NULL, + asset_name TEXT NOT NULL, + asset_serial TEXT, + customer_name TEXT NOT NULL, + customer_phone TEXT, + custom_line TEXT, + sheet_position INTEGER DEFAULT NULL, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, asset_id), + UNIQUE(user_id, sheet_position) + ); + CREATE INDEX IF NOT EXISTS idx_label_queue_user ON label_queue(user_id); + `); +} + +// ── User queries ────────────────────────────────────────────────────────────── + +function getUserByUsername(username) { + return getDb() + .prepare('SELECT * FROM users WHERE username = ? AND active = 1') + .get(username); +} + +function getUserById(id) { + return getDb() + .prepare('SELECT id, username, name, company, syncro_customer_id, role, active, created_at FROM users WHERE id = ?') + .get(id); +} + +function getAllUsers() { + return getDb() + .prepare('SELECT id, username, name, company, syncro_customer_id, role, active, created_at FROM users ORDER BY company COLLATE NOCASE, username COLLATE NOCASE') + .all(); +} + +function createUser(username, passwordHash, name, role, company = '', syncroCustomerId = null) { + return getDb() + .prepare('INSERT INTO users (username, password_hash, name, role, company, syncro_customer_id) VALUES (?, ?, ?, ?, ?, ?)') + .run(username, passwordHash, name, role, company, syncroCustomerId); +} + +function updateUser(id, fields) { + const allowed = ['name', 'company', 'syncro_customer_id', 'role', 'active', 'password_hash']; + const filtered = Object.fromEntries( + Object.entries(fields).filter(([k]) => allowed.includes(k)) + ); + if (!Object.keys(filtered).length) return; + const sets = Object.keys(filtered).map(k => `${k} = ?`).join(', '); + return getDb() + .prepare(`UPDATE users SET ${sets}, updated_at = datetime('now') WHERE id = ?`) + .run(...Object.values(filtered), id); +} + +module.exports = { initDb, getDb, getUserByUsername, getUserById, getAllUsers, createUser, updateUser }; diff --git a/auth/middleware.js b/auth/middleware.js new file mode 100755 index 0000000..8734404 --- /dev/null +++ b/auth/middleware.js @@ -0,0 +1,33 @@ +'use strict'; + +function requireAuth(req, res, next) { + if (req.session?.user) return next(); + + const wantsJson = + req.path.startsWith('/auth/me') || + req.headers.accept?.includes('application/json') || + req.xhr; + + if (wantsJson) { + return res.status(401).json({ error: 'Session expired. Please log in.', code: 'SESSION_EXPIRED' }); + } + + const next_ = encodeURIComponent(req.originalUrl); + res.redirect(`/login.html?next=${next_}`); +} + +function requireRole(...roles) { + return (req, res, next) => { + if (!req.session?.user) { + return res.status(401).json({ error: 'Unauthorized.' }); + } + if (!roles.includes(req.session.user.role)) { + const wantsJson = req.headers.accept?.includes('application/json') || req.xhr; + if (wantsJson) return res.status(403).json({ error: 'Insufficient permissions.' }); + return res.redirect('/403.html'); + } + next(); + }; +} + +module.exports = { requireAuth, requireRole }; diff --git a/auth/routes.js b/auth/routes.js new file mode 100755 index 0000000..a011ae7 --- /dev/null +++ b/auth/routes.js @@ -0,0 +1,62 @@ +'use strict'; +const express = require('express'); +const bcrypt = require('bcryptjs'); +const { getUserByUsername } = require('./db'); + +const router = express.Router(); + +// Dummy hash — used when user is not found to prevent timing attacks +const DUMMY_HASH = '$2b$12$invaliddummyhashfortimingattttttttttttttttttttttttttt'; + +// POST /auth/login +router.post('/login', async (req, res) => { + const username = String(req.body?.username ?? '').trim(); + const password = String(req.body?.password ?? ''); + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required.' }); + } + + const user = getUserByUsername(username); + const hash = user?.password_hash ?? DUMMY_HASH; + + // Always run bcrypt comparison — even for unknown users — to prevent timing-based enumeration + const valid = await bcrypt.compare(password, hash); + + if (!valid || !user) { + return res.status(401).json({ error: 'Invalid username or password.' }); + } + + // Regenerate session ID to prevent session fixation + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Session error.' }); + + req.session.user = { + id: user.id, + username: user.username, + name: user.name, + role: user.role, + syncro_customer_id: user.syncro_customer_id ?? null, + }; + + res.json({ ok: true, user: req.session.user }); + }); +}); + +// POST /auth/logout +router.post('/logout', (req, res) => { + req.session.destroy(() => { + res.clearCookie('sid'); + res.json({ ok: true }); + }); +}); + +// GET /auth/me — returns current session user (used by the SPA on startup) +router.get('/me', (req, res) => { + if (!req.session?.user) { + return res.status(401).json({ error: 'Not authenticated.' }); + } + res.json({ user: req.session.user }); +}); + +module.exports = router; diff --git a/auth/sessionStore.js b/auth/sessionStore.js new file mode 100755 index 0000000..002a2f6 --- /dev/null +++ b/auth/sessionStore.js @@ -0,0 +1,65 @@ +'use strict'; +// Custom session store backed by the same better-sqlite3 instance. +// Avoids pulling in a second SQLite driver (connect-sqlite3 / sqlite3). + +const session = require('express-session'); + +const TTL_SECONDS = 8 * 60 * 60; // 8 hours — matches cookie maxAge + +function createSessionStore(db) { + class SQLiteStore extends session.Store { + constructor() { + super(); + // Purge expired sessions every 15 minutes + setInterval(() => { + db.prepare('DELETE FROM sessions WHERE expired < ?').run(Math.floor(Date.now() / 1000)); + }, 15 * 60 * 1000).unref(); + } + + get(sid, cb) { + try { + const row = db.prepare('SELECT sess, expired FROM sessions WHERE sid = ?').get(sid); + if (!row) return cb(null, null); + if (row.expired < Math.floor(Date.now() / 1000)) { + db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); + return cb(null, null); + } + cb(null, JSON.parse(row.sess)); + } catch (e) { cb(e); } + } + + set(sid, sessionData, cb) { + try { + const ttl = sessionData.cookie?.maxAge + ? Math.floor(sessionData.cookie.maxAge / 1000) + : TTL_SECONDS; + const expired = Math.floor(Date.now() / 1000) + ttl; + db.prepare('INSERT OR REPLACE INTO sessions (sid, sess, expired) VALUES (?, ?, ?)') + .run(sid, JSON.stringify(sessionData), expired); + cb(null); + } catch (e) { cb(e); } + } + + destroy(sid, cb) { + try { + db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); + cb(null); + } catch (e) { cb(e); } + } + + touch(sid, sessionData, cb) { + try { + const ttl = sessionData.cookie?.maxAge + ? Math.floor(sessionData.cookie.maxAge / 1000) + : TTL_SECONDS; + const expired = Math.floor(Date.now() / 1000) + ttl; + db.prepare('UPDATE sessions SET expired = ? WHERE sid = ?').run(expired, sid); + cb(null); + } catch (e) { cb(e); } + } + } + + return new SQLiteStore(); +} + +module.exports = { createSessionStore }; diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100755 index 0000000..3362579 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,18 @@ +'use strict'; + +module.exports = { + apps: [ + { + name: 'assets.derenzyit.com', + script: './server.js', + cwd: '/home/derenzyit-assets/htdocs/assets.derenzyit.com', + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '256M', + env: { + NODE_ENV: 'production', + }, + }, + ], +}; diff --git a/package-lock.json b/package-lock.json new file mode 100755 index 0000000..9cc13a1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1354 @@ +{ + "name": "assets-derenzyit", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "assets-derenzyit", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^9.4.3", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-session": "^1.17.3", + "helmet": "^7.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..00c2127 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "assets-derenzyit", + "version": "1.0.0", + "private": true, + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "node server.js", + "create-user": "node scripts/create-user.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^9.4.3", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-session": "^1.17.3", + "helmet": "^7.1.0" + } +} diff --git a/public/403.html b/public/403.html new file mode 100755 index 0000000..782ad14 --- /dev/null +++ b/public/403.html @@ -0,0 +1,46 @@ + + + + + + Access Denied — deRenzy BT Asset Browser + + + + + + + + + + diff --git a/public/admin/admin.js b/public/admin/admin.js new file mode 100755 index 0000000..f9f68e8 --- /dev/null +++ b/public/admin/admin.js @@ -0,0 +1,750 @@ +'use strict'; + +// ── State ────────────────────────────────────────────────────────────────── +let currentUser = null; +let editingUserId = null; +let users = []; +let companyFilter = ''; +let roleFilter = new Set(); // empty = all roles +let statusFilter = null; // null | 'active' | 'inactive' +let groupBy = 'company'; // 'company' | 'role' | 'status' | null +let syncroCustomers = []; // [{ id, name }] loaded once from Syncro + +// ── Init ─────────────────────────────────────────────────────────────────── +(async function init() { + try { + const r = await fetch('/auth/me'); + if (!r.ok) { location.replace('/login.html?next=' + encodeURIComponent(location.pathname)); return; } + const { user } = await r.json(); + currentUser = user; + + document.getElementById('user-name').textContent = user.name; + const roleBadge = document.getElementById('user-role'); + roleBadge.textContent = user.role; + roleBadge.dataset.role = user.role; + + // Show server section only for superduperadmin + if (user.role === 'superduperadmin') { + document.getElementById('section-server').style.display = ''; + } + + // Hide elevated role options for non-superduperadmin + if (user.role !== 'superduperadmin') { + document.querySelector('#f-role option[value="superduperadmin"]').remove(); + document.querySelector('#f-role option[value="admin"]').remove(); + } + + // Load users and Syncro customers in parallel + await Promise.all([loadUsers(), loadSyncroCustomers()]); + bindEvents(); + loadApiUsage(); + setInterval(loadApiUsage, 10_000); + } catch (e) { + console.error(e); + } +})(); + +// ── API helpers ──────────────────────────────────────────────────────────── +async function api(method, path, body) { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body !== undefined) opts.body = JSON.stringify(body); + const r = await fetch(path, opts); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); + return data; +} + +// ── Syncro customers ─────────────────────────────────────────────────────── +async function loadSyncroCustomers() { + const hint = document.getElementById('f-customer-hint'); + const listbox = document.getElementById('f-syncro-customer'); + try { + let page = 1, all = []; + while (true) { + const r = await fetch(`/syncro-api/customers?per_page=100&page=${page}`); + const data = await r.json(); + const batch = data.customers ?? []; + const totalPages = data.meta?.total_pages ?? 1; + all = all.concat(batch); + if (page >= totalPages) break; + page++; + } + syncroCustomers = all + .map(c => ({ id: c.id, name: c.business_name ?? c.name ?? `Customer ${c.id}` })) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + + populateCustomerListbox(''); + hint.textContent = 'Links this user to a Syncro customer for access control.'; + } catch (e) { + hint.textContent = 'Could not load Syncro customers.'; + console.error('Syncro customer load failed:', e); + } +} + +function populateCustomerListbox(filter) { + const listbox = document.getElementById('f-syncro-customer'); + const current = listbox.value; + const q = filter.toLowerCase(); + + // Keep the "None" option then add matching customers + const matches = q + ? syncroCustomers.filter(c => c.name.toLowerCase().includes(q)) + : syncroCustomers; + + listbox.innerHTML = ''; + for (const c of matches) { + const opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = c.name; + listbox.appendChild(opt); + } + + // Restore selection if still present + if (current && listbox.querySelector(`option[value="${current}"]`)) { + listbox.value = current; + } +} + +// ── Load & render users ──────────────────────────────────────────────────── +async function loadUsers() { + try { + const { users: list } = await api('GET', '/admin/users'); + users = list; + rebuildCompanyFilter(); + renderUsers(); + } catch (e) { + document.getElementById('user-tbody').innerHTML = + `Failed to load users: ${e.message}`; + } +} + +function rebuildCompanyFilter() { + // Collect unique non-empty companies, sorted + const companies = [...new Set( + users.map(u => u.company).filter(Boolean) + )].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + + // Filter dropdown + const select = document.getElementById('company-filter'); + const prev = select.value; + select.innerHTML = ''; + for (const c of companies) { + const opt = document.createElement('option'); + opt.value = c; + opt.textContent = c; + select.appendChild(opt); + } + select.value = companies.includes(prev) ? prev : ''; + companyFilter = select.value; + +} + +function renderUsers() { + const tbody = document.getElementById('user-tbody'); + + const visible = users.filter(u => { + if (companyFilter && u.company !== companyFilter) return false; + if (roleFilter.size > 0 && !roleFilter.has(u.role)) return false; + if (statusFilter === 'active' && !u.active) return false; + if (statusFilter === 'inactive' && u.active) return false; + return true; + }); + + const hasFilter = companyFilter || roleFilter.size > 0 || statusFilter !== null; + if (!visible.length) { + tbody.innerHTML = `${hasFilter ? 'No users match the current filters.' : 'No users found.'}`; + return; + } + + const rows = []; + + if (groupBy) { + const keyOf = u => { + if (groupBy === 'company') return u.company || ''; + if (groupBy === 'role') return u.role; + if (groupBy === 'status') return u.active ? 'active' : 'inactive'; + return ''; + }; + + const labelOf = key => { + if (groupBy === 'company') return key || 'No Company'; + if (groupBy === 'role') return { client: 'Client', tech: 'Tech', admin: 'Admin', superduperadmin: 'Super Admin' }[key] ?? key; + if (groupBy === 'status') return key === 'active' ? 'Active' : 'Inactive'; + return key; + }; + + const sortKeys = keys => { + if (groupBy === 'company') return keys.sort((a, b) => !a && b ? 1 : a && !b ? -1 : a.localeCompare(b, undefined, { sensitivity: 'base' })); + if (groupBy === 'role') return keys.sort((a, b) => (['superduperadmin','admin','tech','client'].indexOf(a) - ['superduperadmin','admin','tech','client'].indexOf(b))); + if (groupBy === 'status') return keys.sort((a, b) => a === 'active' ? -1 : 1); + return keys; + }; + + const grouped = new Map(); + for (const u of visible) { + const key = keyOf(u); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(u); + } + + for (const key of sortKeys([...grouped.keys()])) { + rows.push(`${esc(labelOf(key))}`); + for (const u of grouped.get(key)) rows.push(userRow(u)); + } + } else { + for (const u of visible) rows.push(userRow(u)); + } + + tbody.innerHTML = rows.join(''); +} + +function userRow(u) { + const activeClass = u.active ? 'active' : 'inactive'; + const activeLabel = u.active ? 'Active' : 'Inactive'; + const created = new Date(u.created_at + 'Z').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + const isSelf = u.id === currentUser.id; + + const toggleBtn = u.active + ? `` + : ``; + + return ` + + ${esc(u.name)}${isSelf ? ' (you)' : ''} + ${esc(u.username)} + ${u.company ? esc(u.company) : ''} + ${esc(u.role)} + ${activeLabel} + ${created} + + + ${toggleBtn} + + `; +} + +// ── App menu ─────────────────────────────────────────────────────────────── +function initAdminMenu() { + const toggle = document.getElementById('btn-menu-toggle'); + const menu = document.getElementById('app-menu'); + if (!toggle || !menu) return; + + toggle.addEventListener('click', e => { + e.stopPropagation(); + const opening = menu.hidden; + menu.hidden = !opening; + toggle.setAttribute('aria-expanded', String(opening)); + }); + + document.addEventListener('click', e => { + if (!menu.hidden && !menu.contains(e.target) && e.target !== toggle && !toggle.contains(e.target)) { + menu.hidden = true; + toggle.setAttribute('aria-expanded', 'false'); + } + }); + + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !menu.hidden) { + menu.hidden = true; + toggle.setAttribute('aria-expanded', 'false'); + } + }); +} + +// ── User filter panel ────────────────────────────────────────────────────── +function bindFilterPanel() { + const panel = document.getElementById('user-filter-panel'); + const filterBtn = document.getElementById('btn-user-filter'); + + filterBtn?.addEventListener('click', () => { + panel.hidden = !panel.hidden; + filterBtn.classList.toggle('active', !panel.hidden); + }); + + panel?.addEventListener('click', e => { + const chip = e.target.closest('.af-chip'); + if (!chip) return; + const group = chip.closest('[data-filter-group]')?.dataset.filterGroup; + + if (group === 'role') { + const val = chip.dataset.value; + if (roleFilter.has(val)) { roleFilter.delete(val); chip.classList.remove('active'); } + else { roleFilter.add(val); chip.classList.add('active'); } + } else if (group === 'status') { + statusFilter = chip.dataset.value === '' ? null : chip.dataset.value; + chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c => + c.classList.toggle('active', c.dataset.value === (chip.dataset.value)) + ); + } else if (group === 'groupby') { + groupBy = chip.dataset.value === '' ? null : chip.dataset.value; + chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c => + c.classList.toggle('active', c.dataset.value === chip.dataset.value) + ); + } + + updateFilterBadge(); + renderUsers(); + }); + + document.getElementById('af-clear')?.addEventListener('click', () => { + roleFilter.clear(); + statusFilter = null; + companyFilter = ''; + document.getElementById('company-filter').value = ''; + panel?.querySelectorAll('[data-filter-group="role"] .af-chip').forEach(c => c.classList.remove('active')); + panel?.querySelectorAll('[data-filter-group="status"] .af-chip').forEach(c => + c.classList.toggle('active', c.dataset.value === '') + ); + groupBy = 'company'; + panel?.querySelectorAll('[data-filter-group="groupby"] .af-chip').forEach(c => + c.classList.toggle('active', c.dataset.value === 'company') + ); + updateFilterBadge(); + renderUsers(); + }); + + document.getElementById('company-filter')?.addEventListener('change', e => { + companyFilter = e.target.value; + updateFilterBadge(); + renderUsers(); + }); +} + +function updateFilterBadge() { + const count = (companyFilter ? 1 : 0) + roleFilter.size + (statusFilter !== null ? 1 : 0); + const badge = document.getElementById('user-filter-badge'); + if (badge) { badge.textContent = count; badge.hidden = count === 0; } +} + +// ── Bind events ──────────────────────────────────────────────────────────── +function bindEvents() { + initAdminMenu(); + bindFilterPanel(); + + document.getElementById('btn-new-user').addEventListener('click', openCreate); + + document.getElementById('btn-logout').addEventListener('click', async () => { + await fetch('/auth/logout', { method: 'POST' }); + location.replace('/login.html'); + }); + + // Table actions — delegated so they work after innerHTML re-renders + document.getElementById('user-tbody').addEventListener('click', e => { + const btn = e.target.closest('button[data-action]'); + if (!btn || btn.disabled) return; + const id = Number(btn.dataset.id); + if (btn.dataset.action === 'edit') { + openEdit(id); + } else if (btn.dataset.action === 'toggle') { + confirmToggleActive(id, btn.dataset.activate === 'true'); + } + }); + + // Customer picker search + document.getElementById('f-customer-search').addEventListener('input', e => { + populateCustomerListbox(e.target.value); + }); + + // Modal close / cancel + document.getElementById('modal-close').addEventListener('click', closeUserModal); + document.getElementById('modal-cancel').addEventListener('click', closeUserModal); + document.getElementById('user-modal').addEventListener('click', e => { + if (e.target === e.currentTarget) closeUserModal(); + }); + + // Confirm modal + document.getElementById('confirm-close').addEventListener('click', closeConfirm); + document.getElementById('confirm-cancel').addEventListener('click', closeConfirm); + document.getElementById('confirm-modal').addEventListener('click', e => { + if (e.target === e.currentTarget) closeConfirm(); + }); + + document.getElementById('user-form').addEventListener('submit', handleUserFormSubmit); + + const btnRestart = document.getElementById('btn-restart'); + if (btnRestart) { + btnRestart.addEventListener('click', () => { + showConfirm( + 'Restart Server', + 'The server process will exit and PM2 will restart it automatically. This takes about 1–2 seconds. Proceed?', + 'Restart', + 'btn-accent', + doRestart + ); + }); + } + + document.getElementById('btn-refresh-usage')?.addEventListener('click', loadApiUsage); + + // History panel toggle + document.getElementById('btn-history-toggle')?.addEventListener('click', () => { + _historyPanelVisible = !_historyPanelVisible; + const panel = document.getElementById('usage-history-panel'); + if (panel) panel.hidden = !_historyPanelVisible; + document.getElementById('btn-history-toggle')?.classList.toggle('active', _historyPanelVisible); + _setHistoryWindowDescription(); + loadApiUsage(); // immediate refresh so bar + chart update + }); + + // Timeframe chips + document.querySelectorAll('.usage-tf-chip').forEach(chip => { + chip.addEventListener('click', () => { + _historyWindowMs = parseInt(chip.dataset.window, 10); + document.querySelectorAll('.usage-tf-chip').forEach(c => + c.classList.toggle('active', c === chip) + ); + _setHistoryWindowDescription(); + loadApiUsage(); + }); + }); + + document.addEventListener('keydown', e => { + if (e.key === 'Escape') { closeUserModal(); closeConfirm(); } + }); +} + +// ── User modal ───────────────────────────────────────────────────────────── +function openCreate() { + editingUserId = null; + document.getElementById('modal-title').textContent = 'New User'; + document.getElementById('modal-submit').textContent = 'Create User'; + document.getElementById('f-password-label').textContent = 'Password'; + document.getElementById('f-password-hint').textContent = 'Minimum 12 characters.'; + document.getElementById('f-password').required = true; + document.getElementById('user-form').reset(); + document.getElementById('modal-error').style.display = 'none'; + document.getElementById('f-username').disabled = false; + document.getElementById('f-role').disabled = false; + document.getElementById('f-customer-search').value = ''; + populateCustomerListbox(''); + document.getElementById('f-syncro-customer').value = ''; + document.getElementById('user-modal').style.display = 'flex'; + document.getElementById('f-name').focus(); +} + +function openEdit(id) { + const u = users.find(x => x.id === id); + if (!u) return; + editingUserId = id; + + document.getElementById('modal-title').textContent = 'Edit User'; + document.getElementById('modal-submit').textContent = 'Save Changes'; + document.getElementById('f-password-label').textContent = 'New Password'; + document.getElementById('f-password-hint').textContent = 'Leave blank to keep current password. Minimum 12 characters if changing.'; + document.getElementById('f-password').required = false; + document.getElementById('modal-error').style.display = 'none'; + + const isSelf = u.id === currentUser.id; + + document.getElementById('f-name').value = u.name; + document.getElementById('f-username').value = u.username; + document.getElementById('f-username').disabled = true; + document.getElementById('f-role').disabled = isSelf; + document.getElementById('f-password').value = ''; + + // Pre-select Syncro customer + document.getElementById('f-customer-search').value = ''; + populateCustomerListbox(''); + document.getElementById('f-syncro-customer').value = u.syncro_customer_id ?? ''; + + const roleSelect = document.getElementById('f-role'); + if (!roleSelect.querySelector(`option[value="${u.role}"]`)) { + const opt = document.createElement('option'); + opt.value = u.role; + opt.textContent = u.role; + opt.dataset.temp = '1'; + roleSelect.insertBefore(opt, roleSelect.firstChild); + } + roleSelect.value = u.role; + + document.getElementById('user-modal').style.display = 'flex'; + document.getElementById('f-name').focus(); +} + +function closeUserModal() { + document.getElementById('user-modal').style.display = 'none'; + document.getElementById('f-role').disabled = false; + document.querySelectorAll('#f-role option[data-temp]').forEach(o => o.remove()); +} + +async function handleUserFormSubmit(e) { + e.preventDefault(); + const errEl = document.getElementById('modal-error'); + const submit = document.getElementById('modal-submit'); + errEl.style.display = 'none'; + submit.disabled = true; + + const name = document.getElementById('f-name').value.trim(); + const username = document.getElementById('f-username').value.trim(); + const role = document.getElementById('f-role').value; + const password = document.getElementById('f-password').value; + + const customerSelect = document.getElementById('f-syncro-customer'); + const syncroCustomerId = customerSelect.value ? Number(customerSelect.value) : null; + const company = syncroCustomerId + ? (customerSelect.options[customerSelect.selectedIndex]?.text ?? '') + : ''; + + try { + if (editingUserId === null) { + await api('POST', '/admin/users', { name, username, company, syncro_customer_id: syncroCustomerId, role, password }); + toast('User created successfully.', 'success'); + } else { + const body = { name, company, syncro_customer_id: syncroCustomerId, role }; + if (password) body.password = password; + await api('PATCH', `/admin/users/${editingUserId}`, body); + toast('User updated successfully.', 'success'); + } + closeUserModal(); + await loadUsers(); + } catch (err) { + errEl.textContent = err.message; + errEl.style.display = ''; + } finally { + submit.disabled = false; + } +} + +// ── Toggle active ────────────────────────────────────────────────────────── +function confirmToggleActive(id, activate) { + const u = users.find(x => x.id === id); + if (!u) return; + const action = activate ? 'activate' : 'deactivate'; + showConfirm( + `${activate ? 'Activate' : 'Deactivate'} User`, + `Are you sure you want to ${action} ${esc(u.name)} (${esc(u.username)})?${!activate ? ' They will be unable to log in.' : ''}`, + activate ? 'Activate' : 'Deactivate', + activate ? 'btn-primary' : 'btn-danger', + async () => { + try { + await api('PATCH', `/admin/users/${id}`, { active: activate }); + toast(`User ${activate ? 'activated' : 'deactivated'}.`, activate ? 'success' : 'info'); + await loadUsers(); + } catch (err) { + toast(err.message, 'error'); + } + } + ); +} + +// ── Restart ──────────────────────────────────────────────────────────────── +async function doRestart() { + const btn = document.getElementById('btn-restart'); + btn.disabled = true; + btn.innerHTML = ` + + + + + Restarting…`; + try { + await api('POST', '/admin/restart'); + } catch { + // Server closed before responding — expected + } + toast('Server is restarting… page will refresh in a few seconds.', 'info'); + setTimeout(() => location.reload(), 4000); +} + +// ── Confirm modal ────────────────────────────────────────────────────────── +function showConfirm(title, message, okLabel, okClass, callback) { + document.getElementById('confirm-title').textContent = title; + document.getElementById('confirm-message').innerHTML = message; + const okBtn = document.getElementById('confirm-ok'); + okBtn.textContent = okLabel; + okBtn.className = `btn btn-sm ${okClass}`; + okBtn.onclick = async () => { closeConfirm(); await callback(); }; + document.getElementById('confirm-modal').style.display = 'flex'; +} + +function closeConfirm() { + document.getElementById('confirm-modal').style.display = 'none'; +} + +// ── Toast ────────────────────────────────────────────────────────────────── +function toast(msg, type = 'info') { + const container = document.getElementById('toast-container'); + const el = document.createElement('div'); + el.className = `toast ${type}`; + el.textContent = msg; + container.appendChild(el); + setTimeout(() => el.remove(), 4000); +} + +// ── API usage ────────────────────────────────────────────────────────────── +let _historyWindowMs = 3_600_000; // 1h — active only when panel is visible +let _historyPanelVisible = false; + +async function loadApiUsage() { + const bar = document.getElementById('api-usage-bar'); + const label = document.getElementById('api-usage-label'); + const sub = document.getElementById('api-usage-sub'); + if (!bar) return; + + try { + // Always fetch the current 60s count + 7-day limit-hit tally + const { requests, limit, limitHits7d = 0 } = await api('GET', '/admin/syncro-usage'); + + // Update limit-hit badge on the History button + const badge = document.getElementById('history-badge'); + if (badge) { + badge.textContent = limitHits7d > 999 ? '999+' : String(limitHits7d); + badge.hidden = limitHits7d === 0; + } + + let displayReq, displayLimit, pct; + + if (_historyPanelVisible) { + // Fetch per-minute history for the selected window + const { buckets } = await api('GET', `/admin/syncro-history?window=${_historyWindowMs}`); + const windowTotal = (buckets ?? []).reduce((s, b) => s + b.count, 0); + displayReq = windowTotal; + displayLimit = Math.round(_historyWindowMs / 60_000) * limit; // minutes × 180 + pct = Math.min(100, (windowTotal / displayLimit) * 100); + drawUsageChart(buckets ?? []); + } else { + displayReq = requests; + displayLimit = limit; + pct = Math.min(100, (requests / limit) * 100); + } + + bar.style.width = pct + '%'; + bar.className = 'api-usage-bar' + (pct >= 80 ? ' crit' : pct >= 50 ? ' warn' : ''); + label.textContent = displayReq === 0 + ? `— / ${displayLimit.toLocaleString()}` + : `${displayReq.toLocaleString()} / ${displayLimit.toLocaleString()}`; + + const timeStr = new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + sub.textContent = `Updated ${timeStr} · auto-refreshes every 10s`; + } catch (e) { + if (sub) sub.textContent = 'Failed to load usage data.'; + } +} + +function _setHistoryWindowDescription() { + const el = document.getElementById('api-usage-desc'); + if (!el) return; + if (!_historyPanelVisible) { + el.textContent = 'Rolling 60-second request window'; + return; + } + const labels = { 3600000: '1-hour', 86400000: '24-hour', 604800000: '7-day' }; + el.textContent = `Rolling ${labels[_historyWindowMs] ?? ''} request window`; +} + +function drawUsageChart(buckets) { + const canvas = document.getElementById('usage-chart'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + // Size the backing store to physical pixels for crispness + const dpr = window.devicePixelRatio || 1; + const displayW = canvas.offsetWidth; + const displayH = canvas.offsetHeight; + if (displayW === 0 || displayH === 0) return; + canvas.width = displayW * dpr; + canvas.height = displayH * dpr; + ctx.scale(dpr, dpr); + + const W = displayW, H = displayH; + const now = Date.now(); + + // Aggregation block size + let blockMs; + if (_historyWindowMs <= 3_600_000) blockMs = 60_000; // 1h → 1-min blocks (60 pts) + else if (_historyWindowMs <= 86_400_000) blockMs = 10 * 60_000; // 24h → 10-min blocks (144 pts) + else blockMs = 60 * 60_000; // 7d → 1-hour blocks (168 pts) + + // Build lookup map from raw per-minute buckets + const bucketMap = new Map(buckets.map(b => [b.ts, b.count])); + + // Generate aggregated points (avg req/min in each block) + const windowStart = now - _historyWindowMs; + const firstBlockStart = Math.floor(windowStart / blockMs) * blockMs; + const lastBlockStart = Math.floor(now / blockMs) * blockMs; + const points = []; + for (let t = firstBlockStart; t <= lastBlockStart; t += blockMs) { + let total = 0, mins = 0; + for (let m = t; m < t + blockMs; m += 60_000) { total += bucketMap.get(m) ?? 0; mins++; } + points.push({ t, value: mins > 0 ? total / mins : 0 }); + } + if (points.length === 0) return; + + // Layout padding + const padL = 28, padR = 8, padT = 10, padB = 20; + const chartW = W - padL - padR; + const chartH = H - padT - padB; + const maxY = 180; + + const toX = i => padL + (points.length > 1 ? i / (points.length - 1) : 0.5) * chartW; + const toY = v => padT + chartH * (1 - Math.min(v, maxY) / maxY); + + ctx.clearRect(0, 0, W, H); + + // Grid lines + Y-axis labels + [0, 60, 120, 180].forEach(v => { + const y = toY(v); + ctx.strokeStyle = v === 0 ? '#d1d5db' : '#e5e7eb'; + ctx.lineWidth = 1; + ctx.setLineDash(v === 0 ? [] : [3, 3]); + ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + chartW, y); ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = '#9ca3af'; + ctx.font = '9px sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(v), padL - 4, y); + }); + + // Filled area gradient + const gradient = ctx.createLinearGradient(0, padT, 0, padT + chartH); + gradient.addColorStop(0, 'rgba(43,84,153,.22)'); + gradient.addColorStop(1, 'rgba(43,84,153,.02)'); + + ctx.beginPath(); + points.forEach((p, i) => { + if (i === 0) ctx.moveTo(toX(i), toY(p.value)); + else ctx.lineTo(toX(i), toY(p.value)); + }); + ctx.lineTo(toX(points.length - 1), padT + chartH); + ctx.lineTo(toX(0), padT + chartH); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + + // Line + ctx.beginPath(); + points.forEach((p, i) => { + if (i === 0) ctx.moveTo(toX(i), toY(p.value)); + else ctx.lineTo(toX(i), toY(p.value)); + }); + ctx.strokeStyle = '#2b5499'; + ctx.lineWidth = 1.5; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // X-axis labels (5 evenly spaced) + const labelCount = Math.min(5, points.length); + ctx.fillStyle = '#9ca3af'; + ctx.font = '9px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + for (let li = 0; li < labelCount; li++) { + const idx = Math.round(li * (points.length - 1) / (labelCount - 1)); + const d = new Date(points[idx].t); + const label = blockMs >= 60 * 60_000 + ? d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + ctx.fillText(label, toX(idx), H - 3); + } +} + +// ── Utility ─────────────────────────────────────────────────────────────── +function esc(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100755 index 0000000..295c02d --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,1013 @@ + + + + + + Admin Panel — deRenzy BT + + + + + + + + + + +
+
+ +
+ Admin Panel + deRenzy Business Technologies +
+
+ +
+ +
+ + + + +
+ + + +
+
+
+ + +
+ + +
+
+

Admin Panel

+

Manage users and server settings

+
+
+ + +
+
+
+

Users

+

Manage user accounts and roles

+
+
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + +
NameUsernameCompanyRoleStatusCreated
Loading…
+
+
+ + +
+
+
+

Syncro API Usage

+

Rolling 60-second request window

+
+
+ + +
+
+ + + + +
+
+
+
+
+
— / 180
+
+
Updated just now · auto-refreshes every 10s
+
+
+ + + + +
+ +
+ © Carmichael Computing +
+ + +
+ + + + + + + + + + + diff --git a/public/api/syncro.js b/public/api/syncro.js new file mode 100755 index 0000000..66cbaf7 --- /dev/null +++ b/public/api/syncro.js @@ -0,0 +1,72 @@ +import { apiRequest } from './utils.js'; +import CONFIG from '../config.js'; + +const { defaultCustomerId } = CONFIG.app; + +// ── Assets ─────────────────────────────────────────────────────────────────── + +export async function getAsset(id) { + const data = await apiRequest('GET', `/asset/${id}`, null, {}, { base: '/api' }); + return data.asset ?? data; +} + +export async function searchAssets(query) { + const data = await apiRequest('GET', '/asset/search', null, { + query, + per_page: 10, + }, { base: '/api' }); + return data.assets ?? data.customer_assets ?? []; +} + +export async function updateAsset(id, fields, customerId) { + // Routes through Node.js cache layer (/api/) which invalidates the server cache + // and forwards to Syncro. customer_id is used server-side for cache invalidation only. + const data = await apiRequest('PUT', `/customer_assets/${id}`, { asset: fields, customer_id: customerId }, {}, { base: '/api' }); + return data.asset ?? data; +} + +// ── Contacts ───────────────────────────────────────────────────────────────── + +// Fetches via Node.js server cache — server handles pagination internally +export async function getContacts(customerId = defaultCustomerId) { + const res = await fetch(`/api/contacts/${customerId}`, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + return data.contacts ?? []; +} + +// ── Customers ──────────────────────────────────────────────────────────────── + +// Fetches via Node.js server cache — server handles pagination internally +export async function getCustomers() { + const res = await fetch('/api/customers', { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + return data.customers ?? []; +} + +export async function getCustomerAssets(customerId) { + const res = await fetch(`/api/customer_assets/${customerId}`, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + return data.assets ?? []; +} + +// ── Tickets ────────────────────────────────────────────────────────────────── + +export async function getTickets(customerId = defaultCustomerId) { + const data = await apiRequest('GET', '/tickets', null, { + customer_id: customerId, + per_page: 100, // fetch enough to filter client-side by contact + }); + return data.tickets ?? []; +} diff --git a/public/api/utils.js b/public/api/utils.js new file mode 100755 index 0000000..c4d45c7 --- /dev/null +++ b/public/api/utils.js @@ -0,0 +1,59 @@ +// Request wrapper — all calls go through the Express proxy at /syncro-api/ +// The Authorization header is injected server-side; never sent from the browser. +// Session cookie is attached automatically by the browser (same-origin, httpOnly). + +export async function apiRequest(method, path, body = null, params = {}, { base = '/syncro-api' } = {}) { + const url = new URL(base + path, window.location.origin); + + for (const [k, v] of Object.entries(params)) { + if (v !== null && v !== undefined && v !== '') { + url.searchParams.set(k, v); + } + } + + const options = { + method, + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + }; + + if (body && (method === 'PUT' || method === 'POST')) { + options.body = JSON.stringify(body); + } + + let response; + try { + response = await fetch(url.toString(), options); + } catch (err) { + throw new Error('Network error — check connection. ' + err.message); + } + + // Session expired — redirect to login only if the 401 came from our own auth + // wall (code: SESSION_EXPIRED), not from an upstream Syncro API auth error. + if (response.status === 401) { + let body = {}; + try { body = await response.clone().json(); } catch { /* ignore */ } + if (body.code === 'SESSION_EXPIRED') { + window.location.href = '/login.html?expired=1'; + throw new Error('Session expired.'); + } + throw new Error('Upstream authorization error (HTTP 401).'); + } + + // Syncro returns 204 No Content on successful PUT + if (response.status === 204) return {}; + + let data; + try { + data = await response.json(); + } catch { + throw new Error(`Non-JSON response (HTTP ${response.status})`); + } + + if (!response.ok) { + const msg = data?.error || data?.message || data?.errors?.join?.(', ') || `HTTP ${response.status}`; + throw new Error(msg); + } + + return data; +} diff --git a/public/app.js b/public/app.js new file mode 100755 index 0000000..0608504 --- /dev/null +++ b/public/app.js @@ -0,0 +1,603 @@ +// app.js — entry point: initialises all modules, wires scan flow and mode switching. +import CONFIG from './config.js'; +import { getAsset, searchAssets, updateAsset } from './api/syncro.js'; +import { initScanner, resetIdleTimer, focusScanInput, cancelIdleTimer, setTimerDuration, getTimerDuration, setKeyInterceptor, pause as pauseScanner, resume as resumeScanner } from './modules/scanner.js'; +import { init as initCameraScanner, start as startCamera, stop as stopCamera, isActive as isCameraActive } from './modules/cameraScanner.js'; +import { renderAssetCard } from './modules/assetCard.js'; +import { initActions } from './modules/actions.js'; +import { initTicketHistory } from './modules/ticketHistory.js'; +import { showToast } from './modules/toast.js'; +import { initAssetBrowser, setActiveAsset, setFiltersAndRender } from './modules/assetBrowser.js'; +import { initSearchAutocomplete, handleAutocompleteKey } from './modules/searchAutocomplete.js'; +import { initLabelCenter, closeLabelCenter, refreshBadge } from './modules/labelCenter.js'; +import { renderClientDashboard } from './modules/clientDashboard.js'; + +// ── View management ─────────────────────────────────────────────────────────── + +const VIEWS = ['neutral', 'idle', 'loading', 'asset', 'search-results', 'quick-view', 'error']; + +// ── Timer cycle ─────────────────────────────────────────────────────────────── +const TIMER_STEPS = [30_000, 60_000, 300_000, 900_000, null]; +const TIMER_LABELS = ['Timer: 30s', 'Timer: 1m', 'Timer: 5m', 'Timer: 15m', 'Timer: OFF']; + +function _updateTimerButton(ms) { + const idx = TIMER_STEPS.indexOf(ms); + document.getElementById('timer-toggle-label').textContent = TIMER_LABELS[idx] ?? 'Timer: OFF'; + document.getElementById('btn-timer-toggle')?.classList.toggle('active', ms !== null); +} + +// Views worth returning to when closing an asset card +const _BACK_VIEWS = new Set(['neutral', 'idle', 'search-results', 'quick-view']); +let _returnView = 'neutral'; + +export function showView(name) { + // Capture where we came from so closeAssetView() can go back there + if (name === 'asset') { + const current = VIEWS.find(v => document.getElementById(`view-${v}`)?.classList.contains('active')); + if (current && _BACK_VIEWS.has(current)) _returnView = current; + } + VIEWS.forEach(v => { + const el = document.getElementById(`view-${v}`); + if (el) el.classList.toggle('active', v === name); + }); + if (name === 'quick-view') { + document.getElementById('btn-quick-view')?.classList.add('active'); + } else { + document.getElementById('btn-quick-view')?.classList.remove('active'); + } +} + +export function closeAssetView() { + setActiveAsset(null); + const card = document.querySelector('.asset-card'); + if (card) { + card.classList.add('closing'); + setTimeout(() => showView(_returnView), 180); + } else { + showView(_returnView); + } +} + +// ── Scan mode state ─────────────────────────────────────────────────────────── + +let scanModeActive = false; + +function setScanMode(active) { + scanModeActive = active; + setActiveMode(active ? 'scan' : null); + localStorage.setItem('appMode', active ? 'scan' : 'label'); +} + +// ── Scan flow ───────────────────────────────────────────────────────────────── + +async function handleScan(rawValue) { + const value = rawValue.trim(); + if (!value) return; + + showView('loading'); + + try { + if (/^\d+$/.test(value)) { + await resolveById(parseInt(value, 10)); + } else { + await resolveBySearch(value); + } + } catch (err) { + showView('error'); + document.getElementById('error-container').innerHTML = errorCardHTML( + 'Asset Not Found', + err.message, + value + ); + } +} + +async function resolveById(id) { + let asset; + try { + asset = await getAsset(id); + } catch (err) { + if (err.message.includes('404') || err.message.toLowerCase().includes('not found')) { + await resolveBySearch(String(id)); + return; + } + throw err; + } + await showAsset(asset); +} + +async function resolveBySearch(query) { + const assets = await searchAssets(query); + if (!assets.length) { + throw new Error(`No asset found matching "${query}"`); + } + if (assets.length === 1) { + await showAsset(assets[0]); + return; + } + showSearchResults(assets, query); +} + +async function showAsset(asset) { + stampScan(asset); + renderAssetCard(asset); + showView('asset'); + initActions(asset); + initTicketHistory(asset); + setActiveAsset(asset.id); + resetIdleTimer(); +} + +function stampScan(asset) { + if (window._currentUser?.role === 'client') return; + updateAsset(asset.id, { + properties: { + 'Last Scan Date': new Date().toISOString().split('T')[0], + 'Last Action': asset.properties?.['Last Action'] ?? 'Scanned', + }, + }).catch(() => {}); +} + +// ── Search results picker ───────────────────────────────────────────────────── + +function showSearchResults(assets, query) { + const container = document.getElementById('search-results-container'); + container.innerHTML = ` +
+

Multiple assets match "${esc(query)}" — select one:

+
+ ${assets.map(a => ` +
+
+
${esc(a.name)}
+
+ ${esc(a.asset_type ?? '')} + ${a.serial ? ` · SN: ${esc(a.serial)}` : ''} + ${a.customer?.name ? ` · ${esc(a.customer.name)}` : ''} +
+
+ +
+ `).join('')} +
+
`; + + container.querySelectorAll('.search-result-item').forEach(el => { + el.addEventListener('click', async () => { + showView('loading'); + try { + const asset = await getAsset(parseInt(el.dataset.assetId, 10)); + await showAsset(asset); + } catch (err) { + showToast('Failed to load asset: ' + err.message, 'error'); + showView('search-results'); + } + }); + }); + + showView('search-results'); +} + +// ── Error card HTML ─────────────────────────────────────────────────────────── + +function errorCardHTML(title, message) { + return ` +
+
+

${esc(title)}

+

${esc(message)}

+ +
`; +} + +// ── Idle ────────────────────────────────────────────────────────────────────── + +function handleIdle() { + if (!scanModeActive) return; // don't hijack the view if scan mode is off + // Close label center overlay if it happens to be open + const lcOverlay = document.getElementById('label-center-overlay'); + if (lcOverlay && !lcOverlay.hidden) closeLabelCenter(); + setActiveMode('scan'); + showView('idle'); + focusScanInput(); +} + +// ── Mode switching ──────────────────────────────────────────────────────────── + +function setActiveMode(mode) { + document.querySelectorAll('.mode-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mode === mode); + }); +} + +// ── User menu ───────────────────────────────────────────────────────────────── + +async function initUserMenu() { + try { + const res = await fetch('/auth/me', { credentials: 'same-origin' }); + if (!res.ok) { + window.location.href = '/login.html'; + return; + } + const { user } = await res.json(); + + const nameEl = document.getElementById('user-name'); + const roleEl = document.getElementById('user-role'); + if (nameEl) nameEl.textContent = user.name; + if (roleEl) { + roleEl.textContent = user.role; + roleEl.dataset.role = user.role; + } + + // Show admin portal link for admin and above + if (['admin', 'superduperadmin'].includes(user.role)) { + document.getElementById('menu-admin-link')?.removeAttribute('hidden'); + } + + // Store on window for use by other modules if needed + window._currentUser = user; + + if (user.role === 'client') { + document.body.classList.add('role-client'); + const scanInput = document.getElementById('scan-input'); + if (scanInput) { + scanInput.placeholder = 'Search by name, serial, or assigned user…'; + scanInput.removeAttribute('inputmode'); + } + // Queue the dashboard render — showView('quick-view') happens after + // DOMContentLoaded finishes its localStorage restores (which call showView) + document.getElementById('btn-quick-view')?.classList.add('active'); + const container = document.getElementById('quick-view-container'); + if (container) renderClientDashboard(container, user, { + onFilterSelect: ({ lifecycle, possession }) => { + setFiltersAndRender({ lifecycle, possession }); + }, + }); + } + } catch { + window.location.href = '/login.html'; + } +} + +// ── App menu toggle ──────────────────────────────────────────────────────────── + +function initAppMenu() { + const toggle = document.getElementById('btn-menu-toggle'); + const menu = document.getElementById('app-menu'); + if (!toggle || !menu) return; + + // Open / close + toggle.addEventListener('click', e => { + e.stopPropagation(); + const opening = menu.hidden; + menu.hidden = !opening; + toggle.setAttribute('aria-expanded', String(opening)); + }); + + // Close on outside click + document.addEventListener('click', e => { + if (!menu.hidden && !menu.contains(e.target) && e.target !== toggle && !toggle.contains(e.target)) { + menu.hidden = true; + toggle.setAttribute('aria-expanded', 'false'); + } + }); + + // Close on Escape + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !menu.hidden) { + menu.hidden = true; + toggle.setAttribute('aria-expanded', 'false'); + } + }); + + // Close menu when any item is clicked, except toggles (leave open so state is visible) + menu.addEventListener('click', e => { + const item = e.target.closest('.app-menu-item'); + if (item && item.id !== 'btn-timer-toggle' && item.id !== 'btn-scan-mode-toggle') { + menu.hidden = true; + toggle.setAttribute('aria-expanded', 'false'); + } + }); +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +// ── Sidebar resize ─────────────────────────────────────────────────────────── +(function initSidebarResize() { + const sidebar = document.getElementById('asset-sidebar'); + const handle = document.getElementById('sidebar-resize-handle'); + if (!sidebar || !handle) return; + + const MIN_W = 160; + const MAX_W = 600; + + const saved = parseInt(localStorage.getItem('sidebar_width'), 10); + if (saved >= MIN_W && saved <= MAX_W) sidebar.style.width = saved + 'px'; + + handle.addEventListener('mousedown', e => { + e.preventDefault(); + handle.classList.add('dragging'); + document.body.classList.add('sidebar-resizing'); + + const appBody = document.getElementById('app-body'); + + const onMove = e2 => { + const rect = appBody.getBoundingClientRect(); + const newW = Math.min(MAX_W, Math.max(MIN_W, e2.clientX - rect.left)); + sidebar.style.width = newW + 'px'; + }; + const onUp = () => { + handle.classList.remove('dragging'); + document.body.classList.remove('sidebar-resizing'); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + localStorage.setItem('sidebar_width', Math.round(sidebar.getBoundingClientRect().width)); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); +})(); + +document.addEventListener('DOMContentLoaded', async () => { + // Verify session and populate user menu before rendering app + await initUserMenu(); + initAppMenu(); + + // Camera scanner — pass handleScan as the barcode callback. + // onCameraStop resets the toggle UI whenever the overlay closes (via X or after a scan). + initCameraScanner(handleScan, () => { + resumeScanner(); + document.getElementById('btn-scan-mode-toggle')?.classList.remove('active'); + localStorage.setItem('scanMode', 'usb'); + }); + + // Logout + document.getElementById('btn-logout')?.addEventListener('click', async () => { + await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' }); + window.location.href = '/login.html'; + }); + + // ── Restore persisted state ────────────────────────────────────────────── + + // Scan mode (clients always get quick-view; scan mode is staff-only) + if (window._currentUser?.role === 'client') { + showView('quick-view'); + } else if (localStorage.getItem('appMode') === 'scan') { + scanModeActive = true; + setActiveMode('scan'); + showView('idle'); + focusScanInput(); + } else { + showView('neutral'); + } + + // Timer + const savedTimer = localStorage.getItem('timerDuration'); + const timerMs = savedTimer && savedTimer !== 'off' ? Number(savedTimer) : null; + setTimerDuration(timerMs); + _updateTimerButton(timerMs); + + // Camera scan mode — restore button UI only (cannot auto-start camera without a user gesture) + if (localStorage.getItem('scanMode') === 'camera') { + document.getElementById('btn-scan-mode-toggle')?.classList.add('active'); + } + + // ── Mode nav ───────────────────────────────────────────────────────────── + + document.getElementById('btn-scan-mode')?.addEventListener('click', () => { + if (scanModeActive) { + setScanMode(false); + showView('neutral'); + cancelIdleTimer(); + } else { + setScanMode(true); + showView('idle'); + cancelIdleTimer(); + focusScanInput(); + } + }); + + document.getElementById('btn-label-center')?.addEventListener('click', () => { + // handled by labelCenter.js initLabelCenter() — just close the menu + const menu = document.getElementById('app-menu'); + const toggle = document.getElementById('btn-menu-toggle'); + if (menu) { menu.hidden = true; toggle?.setAttribute('aria-expanded', 'false'); } + }); + + // ── Timer toggle ───────────────────────────────────────────────────────── + + document.getElementById('btn-timer-toggle')?.addEventListener('click', () => { + const cur = getTimerDuration(); + const idx = TIMER_STEPS.indexOf(cur); + const next = TIMER_STEPS[(idx + 1) % TIMER_STEPS.length]; + setTimerDuration(next); + _updateTimerButton(next); + localStorage.setItem('timerDuration', next ?? 'off'); + }); + + // ── Camera scan mode toggle ─────────────────────────────────────────────── + + document.getElementById('btn-scan-mode-toggle')?.addEventListener('click', async () => { + const menu = document.getElementById('app-menu'); + const toggle = document.getElementById('btn-menu-toggle'); + if (isCameraActive()) { + // Switch back to USB + await stopCamera(); // triggers onCameraStop which resets UI + } else { + // Switch to camera — close menu, pause USB wedge, open camera overlay + menu.hidden = true; + toggle.setAttribute('aria-expanded', 'false'); + document.getElementById('btn-scan-mode-toggle')?.classList.add('active'); + localStorage.setItem('scanMode', 'camera'); + pauseScanner(); + await startCamera(); + } + }); + + // Release camera tracks if the page is hidden/unloaded while overlay is open + window.addEventListener('pagehide', () => { + if (isCameraActive()) stopCamera(); + }); + + // ── Scan input focus — show idle hint; blur — back to neutral if off ────── + + const scanInput = document.getElementById('scan-input'); + let _blurTimer; + + scanInput?.addEventListener('focus', () => { + clearTimeout(_blurTimer); + if (window._currentUser?.role !== 'client' && + document.getElementById('view-neutral')?.classList.contains('active')) { + showView('idle'); + } + }); + + scanInput?.addEventListener('blur', () => { + if (!scanModeActive) { + _blurTimer = setTimeout(() => { + if (document.getElementById('view-idle')?.classList.contains('active')) { + showView('neutral'); + } + }, 150); + } + }); + + // ── Error retry + asset card close ─────────────────────────────────────── + + document.addEventListener('click', e => { + if (e.target.id === 'err-retry-search') { + showView('idle'); + if (scanModeActive) focusScanInput(); + } + if (e.target.closest('#btn-close-asset')) { + closeAssetView(); + } + }); + + // ── Quick View button (client role only) ────────────────────────────────── + + document.getElementById('btn-quick-view')?.addEventListener('click', () => { + const btn = document.getElementById('btn-quick-view'); + const isOpen = btn?.classList.contains('active'); + if (isOpen) { + btn?.classList.remove('active'); + showView('neutral'); + } else { + btn?.classList.add('active'); + const container = document.getElementById('quick-view-container'); + if (container) renderClientDashboard(container, window._currentUser, { + onFilterSelect: ({ lifecycle, possession }) => { + setFiltersAndRender({ lifecycle, possession }); + }, + }); + showView('quick-view'); + } + }); + + // ── Init modules ────────────────────────────────────────────────────────── + + initScanner({ onScan: handleScan, onIdle: handleIdle, canFocus: () => scanModeActive }); + + initSearchAutocomplete({ onLocalSelect: showAsset, onRemoteSearch: handleScan }); + setKeyInterceptor(handleAutocompleteKey); + + initAssetBrowser({ + onAssetSelect: (asset) => { showAsset(asset); }, + onAssetClose: () => { closeAssetView(); }, + }); + + // ── Home button — returns to the role-appropriate landing view ──────────── + document.getElementById('btn-home')?.addEventListener('click', () => { + const home = window._currentUser?.role === 'client' ? 'quick-view' : 'neutral'; + showView(home); + }); + + initLabelCenter(); + refreshBadge(); + + // ── Server-restart detection ────────────────────────────────────────────── + // Poll /api/ping every 30s. If the bootId changes (or the server was down + // and came back), reload so clients pick up any new static assets. + let _bootId = null; + let _serverWasDown = false; + + async function _pollServer() { + try { + const res = await fetch('/api/ping', { credentials: 'same-origin' }); + if (!res.ok) { _serverWasDown = true; return; } + const { bootId } = await res.json(); + if (_bootId === null) { + _bootId = bootId; // baseline on first successful poll + } else if (bootId !== _bootId || _serverWasDown) { + _showRestartCountdown(); + } + _serverWasDown = false; + } catch { + _serverWasDown = true; + } + } + + _pollServer(); + setInterval(_pollServer, 30_000); +}); + +function _showRestartCountdown() { + // Only show once + if (document.getElementById('restart-countdown-backdrop')) return; + + const backdrop = document.createElement('div'); + backdrop.id = 'restart-countdown-backdrop'; + backdrop.style.cssText = [ + 'position:fixed', 'inset:0', 'z-index:9999', + 'background:rgba(0,0,0,.55)', + 'display:flex', 'align-items:center', 'justify-content:center', + 'animation:fadeIn .2s ease', + ].join(';'); + + const card = document.createElement('div'); + card.style.cssText = [ + 'background:#fff', 'border-radius:12px', + 'max-width:380px', 'width:calc(100vw - 40px)', + 'overflow:hidden', 'box-shadow:0 8px 32px rgba(0,0,0,.25)', + ].join(';'); + + let seconds = 5; + card.innerHTML = ` +
+ +
+
Asset Browser
+
deRenzy Business Technologies
+
+
+
+
+ + + + +
+
Server Restarted
+
Reloading in 5 second(s)…
+ +
`; + + backdrop.appendChild(card); + document.body.appendChild(backdrop); + + const interval = setInterval(() => { + seconds--; + const el = document.getElementById('rc-seconds'); + if (el) el.textContent = seconds; + if (seconds <= 0) { clearInterval(interval); window.location.reload(); } + }, 1000); + + document.getElementById('rc-reload-now')?.addEventListener('click', () => { + clearInterval(interval); + window.location.reload(); + }); +} + +function esc(s) { + return String(s ?? '').replace(/&/g,'&').replace(//g,'>'); +} diff --git a/public/assets/apple-touch-icon.png b/public/assets/apple-touch-icon.png new file mode 100755 index 0000000..faceea2 Binary files /dev/null and b/public/assets/apple-touch-icon.png differ diff --git a/public/assets/favicon-192.png b/public/assets/favicon-192.png new file mode 100755 index 0000000..1f2e2c9 Binary files /dev/null and b/public/assets/favicon-192.png differ diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico new file mode 100755 index 0000000..e009fcb Binary files /dev/null and b/public/assets/favicon.ico differ diff --git a/public/assets/logo-full.png b/public/assets/logo-full.png new file mode 100755 index 0000000..880dda6 Binary files /dev/null and b/public/assets/logo-full.png differ diff --git a/public/assets/logo-swirl.png b/public/assets/logo-swirl.png new file mode 100755 index 0000000..8313bec Binary files /dev/null and b/public/assets/logo-swirl.png differ diff --git a/public/assets/logo-text.png b/public/assets/logo-text.png new file mode 100755 index 0000000..0c15b61 Binary files /dev/null and b/public/assets/logo-text.png differ diff --git a/public/config.js b/public/config.js new file mode 100755 index 0000000..42459f3 --- /dev/null +++ b/public/config.js @@ -0,0 +1,15 @@ +// Runtime config — API key is injected server-side by nginx proxy, not stored here. +const CONFIG = { + syncro: { + subdomain: 'derenzybt', + baseUrl: 'https://derenzybt.syncromsp.com', + apiBase: '/syncro-api', // nginx reverse-proxies this to Syncro + }, + app: { + idleTimeout: 180000, // ms before returning to scan mode (3 minutes) + defaultCustomerId: 33332476, + defaultCustomerName: 'Prime Home Health', + }, +}; + +export default CONFIG; diff --git a/public/index.html b/public/index.html new file mode 100755 index 0000000..f2e3dc8 --- /dev/null +++ b/public/index.html @@ -0,0 +1,480 @@ + + + + + + Asset Browser — deRenzy BT + + + + + + + + + + + + + + + +
+ +
+ + + +
+ + deRenzy Business Technologies +
+
+ +
+
+ + + + + + + + + + + + + +
+
+ +
+ + + + + + +
+ + + +
+ +
+ +
+ + +
+ + + + + + + + +
+ + +
+ + +
+
+
+
+ + + + + + + + + + +
+

Ready to Scan

+

Aim your scanner at an asset label, or type an asset ID above

+
+
+ + +
+
+
+

Looking up asset…

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ +
+ +
+ + +
+ + + + + + + + + + diff --git a/public/login.html b/public/login.html new file mode 100755 index 0000000..ce3e098 --- /dev/null +++ b/public/login.html @@ -0,0 +1,66 @@ + + + + + + Sign In — deRenzy BT Asset Browser + + + + + + + + + + + + + diff --git a/public/login.js b/public/login.js new file mode 100755 index 0000000..d9f3d21 --- /dev/null +++ b/public/login.js @@ -0,0 +1,69 @@ +(function () { + 'use strict'; + + document.getElementById('login-year').textContent = new Date().getFullYear(); + + const form = document.getElementById('login-form'); + const errorBox = document.getElementById('login-error'); + const btn = document.getElementById('login-btn'); + + // If already authenticated, skip to app + fetch('/auth/me', { credentials: 'same-origin' }) + .then(r => { if (r.ok) window.location.replace('/'); }) + .catch(() => { /* not logged in, stay here */ }); + + // Show expired-session message + const params = new URLSearchParams(location.search); + if (params.get('expired') === '1') { + showError('Your session expired. Please sign in again.'); + } + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + hideError(); + + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('password').value; + + if (!username || !password) { + showError('Username and password are required.'); + return; + } + + btn.disabled = true; + btn.textContent = 'Signing in\u2026'; + + try { + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ username, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + showError(data.error || 'Login failed.'); + return; + } + + const dest = params.get('next') || '/'; + window.location.replace(dest); + } catch { + showError('Network error. Please try again.'); + } finally { + btn.disabled = false; + btn.textContent = 'Sign In'; + } + }); + + function showError(msg) { + errorBox.textContent = msg; + errorBox.hidden = false; + } + + function hideError() { + errorBox.hidden = true; + } +})(); diff --git a/public/modules/actions.js b/public/modules/actions.js new file mode 100755 index 0000000..5712acf --- /dev/null +++ b/public/modules/actions.js @@ -0,0 +1,482 @@ +// actions.js — wires up all action buttons on the asset card +import { updateAsset, getContacts } from '../api/syncro.js'; +import { showToast } from './toast.js'; +import { resetIdleTimer, focusScanInput } from './scanner.js'; +import { renderAssetCard } from './assetCard.js'; +import { updateCachedAsset, getCustomerContacts } from './assetBrowser.js'; +import { initTicketHistory } from './ticketHistory.js'; +import { addToQueueAndOpen } from './labelCenter.js'; +import { addToQueue } from './labelCenter.js'; + +let _asset = null; +let _contacts = null; +let _pendingAction = null; // 'change-owner' | 'sign-out' + +export function initActions(asset) { + _asset = asset; + _contacts = null; + _pendingAction = null; + + // Possession toggle + document.getElementById('action-toggle-possession')?.addEventListener('click', handleTogglePossession); + // Lifecycle dropdown toggle + document.getElementById('action-lifecycle')?.addEventListener('click', handleLifecycleClick); + // Change owner + document.getElementById('action-change-owner')?.addEventListener('click', () => openContactPanel('change-owner')); + // Remove user + document.getElementById('action-remove-user')?.addEventListener('click', handleRemoveUser); + // Sign out + document.getElementById('action-sign-out')?.addEventListener('click', () => openContactPanel('sign-out')); + // Print label + document.getElementById('action-print-label')?.addEventListener('click', () => addToQueueAndOpen(_asset)); + // Add to Label Center queue + document.getElementById('action-add-to-queue')?.addEventListener('click', () => addToQueue(_asset)); + // Lifecycle dropdown options (action section) + document.querySelectorAll('.lifecycle-option').forEach(btn => { + btn.addEventListener('click', () => handleSetLifecycle(btn.dataset.stage)); + }); + // Status badge dropdowns (status section) + document.getElementById('status-possession-btn')?.addEventListener('click', _handleStatusPossessionClick); + document.getElementById('status-lifecycle-btn')?.addEventListener('click', _handleStatusLifecycleClick); + document.querySelectorAll('.status-dropdown-option[data-possession]').forEach(btn => { + btn.addEventListener('click', () => _handleSetPossession(btn.dataset.possession)); + }); + document.querySelectorAll('#status-lifecycle-dropdown .status-dropdown-option[data-stage]').forEach(btn => { + btn.addEventListener('click', () => _handleSetLifecycleFromStatus(btn.dataset.stage)); + }); + // Close dropdowns when clicking outside + document.addEventListener('click', _handleOutsideClick, { capture: true }); + // Contact panel + document.getElementById('contact-cancel')?.addEventListener('click', closeContactPanel); + document.getElementById('contact-search')?.addEventListener('input', filterContacts); + // Infrastructure + document.getElementById('action-infrastructure')?.addEventListener('click', _openInfraPanel); + document.getElementById('infra-cancel-btn')?.addEventListener('click', _closeInfraPanel); + document.getElementById('infra-confirm-btn')?.addEventListener('click', _handleConfirmInfra); + document.getElementById('infra-tag-input')?.addEventListener('keydown', e => { + if (e.key === 'Enter') document.getElementById('infra-location-input')?.focus(); + }); + document.getElementById('infra-location-input')?.addEventListener('keydown', e => { + if (e.key === 'Enter') _handleConfirmInfra(); + }); +} + +// ── Toggle Possession ───────────────────────────────────────────────────────── + +async function handleTogglePossession() { + const btn = document.getElementById('action-toggle-possession'); + const current = _asset?.properties?.['Possession Status']; + const next = current === 'In IT Possession' ? 'Deployed' : 'In IT Possession'; + const actionDesc = `Possession toggled to ${next}`; + + setButtonLoading(btn, true); + try { + const newHistory = appendHistory(_asset.properties?.['Asset History'], actionDesc); + await updateAsset(_asset.id, { + properties: { + 'Possession Status': next, + 'Last Scan Date': today(), + 'Last Action': actionDesc, + 'Asset History': newHistory, + }, + }, _asset.customer_id); + _asset.properties = { ...(_asset.properties ?? {}), 'Possession Status': next, 'Last Scan Date': today(), 'Last Action': actionDesc, 'Asset History': newHistory }; + refreshCard(); + showToast(`Possession set to: ${next}`, 'success'); + } catch (err) { + showToast('Failed: ' + err.message, 'error'); + } finally { + resetIdleTimer(); + } +} + +// ── Lifecycle Stage ─────────────────────────────────────────────────────────── + +function handleLifecycleClick(e) { + e.stopPropagation(); + const btn = document.getElementById('action-lifecycle'); + const dropdown = document.getElementById('lifecycle-dropdown'); + const isOpen = dropdown?.classList.contains('open'); + _closeLifecycleDropdown(); + if (!isOpen) { + dropdown?.classList.add('open'); + btn?.classList.add('open'); + } +} + +function _closeLifecycleDropdown() { + document.getElementById('lifecycle-dropdown')?.classList.remove('open'); + document.getElementById('action-lifecycle')?.classList.remove('open'); +} + +function _handleOutsideClick(e) { + const wrap = document.querySelector('.lc-wrap'); + if (wrap && !wrap.contains(e.target)) { + _closeLifecycleDropdown(); + } + if (!e.target.closest('.status-badge-wrap')) { + _closeAllStatusDropdowns(); + } +} + +// ── Status Badge Dropdowns ──────────────────────────────────────────────────── + +function _handleStatusPossessionClick(e) { + e.stopPropagation(); + const btn = document.getElementById('status-possession-btn'); + const dropdown = document.getElementById('status-possession-dropdown'); + const isOpen = dropdown?.classList.contains('open'); + _closeAllStatusDropdowns(); + if (!isOpen) { dropdown?.classList.add('open'); btn?.classList.add('open'); } +} + +function _handleStatusLifecycleClick(e) { + e.stopPropagation(); + const btn = document.getElementById('status-lifecycle-btn'); + const dropdown = document.getElementById('status-lifecycle-dropdown'); + const isOpen = dropdown?.classList.contains('open'); + _closeAllStatusDropdowns(); + if (!isOpen) { dropdown?.classList.add('open'); btn?.classList.add('open'); } +} + +function _closePossessionStatusDropdown() { + document.getElementById('status-possession-dropdown')?.classList.remove('open'); + document.getElementById('status-possession-btn')?.classList.remove('open'); +} + +function _closeLifecycleStatusDropdown() { + document.getElementById('status-lifecycle-dropdown')?.classList.remove('open'); + document.getElementById('status-lifecycle-btn')?.classList.remove('open'); +} + +function _closeAllStatusDropdowns() { + _closePossessionStatusDropdown(); + _closeLifecycleStatusDropdown(); +} + +async function _handleSetPossession(next) { + _closePossessionStatusDropdown(); + const actionDesc = `Possession toggled to ${next}`; + try { + const newHistory = appendHistory(_asset.properties?.['Asset History'], actionDesc); + await updateAsset(_asset.id, { + properties: { + 'Possession Status': next, + 'Last Scan Date': today(), + 'Last Action': actionDesc, + 'Asset History': newHistory, + }, + }, _asset.customer_id); + _asset.properties = { ...(_asset.properties ?? {}), 'Possession Status': next, 'Last Scan Date': today(), 'Last Action': actionDesc, 'Asset History': newHistory }; + refreshCard(); + showToast(`Possession set to: ${next}`, 'success'); + } catch (err) { + showToast('Failed: ' + err.message, 'error'); + } finally { + resetIdleTimer(); + } +} + +async function _handleSetLifecycleFromStatus(stage) { + _closeLifecycleStatusDropdown(); + const actionDesc = `Lifecycle moved to ${stage}`; + try { + const newHistory = appendHistory(_asset.properties?.['Asset History'], actionDesc); + await updateAsset(_asset.id, { + properties: { + 'Lifecycle Stage': stage, + 'Last Scan Date': today(), + 'Last Action': actionDesc, + 'Asset History': newHistory, + }, + }, _asset.customer_id); + _asset.properties = { ...(_asset.properties ?? {}), 'Lifecycle Stage': stage, 'Last Scan Date': today(), 'Last Action': actionDesc, 'Asset History': newHistory }; + refreshCard(); + showToast(`Lifecycle stage: ${stage}`, 'success'); + } catch (err) { + showToast('Failed: ' + err.message, 'error'); + } finally { + resetIdleTimer(); + } +} + +async function handleSetLifecycle(stage) { + _closeLifecycleDropdown(); + const actionDesc = `Lifecycle moved to ${stage}`; + + try { + const newHistory = appendHistory(_asset.properties?.['Asset History'], actionDesc); + await updateAsset(_asset.id, { + properties: { + 'Lifecycle Stage': stage, + 'Last Scan Date': today(), + 'Last Action': actionDesc, + 'Asset History': newHistory, + }, + }, _asset.customer_id); + _asset.properties = { ...(_asset.properties ?? {}), 'Lifecycle Stage': stage, 'Last Scan Date': today(), 'Last Action': actionDesc, 'Asset History': newHistory }; + refreshCard(); + showToast(`Lifecycle stage: ${stage}`, 'success'); + } catch (err) { + showToast('Failed: ' + err.message, 'error'); + } finally { + resetIdleTimer(); + } +} + +// ── Remove User ─────────────────────────────────────────────────────────────── + +async function handleRemoveUser() { + const actionDesc = `User removed`; + const newHistory = appendHistory(_asset.properties?.['Asset History'], actionDesc); + try { + await updateAsset(_asset.id, { + contact_id: null, + properties: { + 'Last Scan Date': today(), + 'Last Action': actionDesc, + 'Asset History': newHistory, + }, + }, _asset.customer_id); + _asset.contact_id = null; + _asset.contact = null; + _asset.contact_fullname = null; + _asset.properties = { ...(_asset.properties ?? {}), 'Last Scan Date': today(), 'Last Action': actionDesc, 'Asset History': newHistory }; + refreshCard(); + showToast('User removed', 'success'); + } catch (err) { + showToast('Failed: ' + err.message, 'error'); + } finally { + resetIdleTimer(); + } +} + +// ── Contact Panel ───────────────────────────────────────────────────────────── + +async function openContactPanel(action) { + _pendingAction = action; + const panel = document.getElementById('contact-panel'); + const titleEl = document.getElementById('contact-panel-title'); + const listEl = document.getElementById('contact-list'); + const searchEl = document.getElementById('contact-search'); + + titleEl.textContent = action === 'sign-out' ? 'Sign Out — Select Employee' : 'Change User — Select Contact'; + searchEl.value = ''; + panel.classList.add('visible'); + panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + // Close lifecycle dropdown if open + _closeLifecycleDropdown(); + + if (_contacts) { + renderContactList(listEl, _contacts); + return; + } + + // Use the browser-side contact cache if warm (avoids spinner on repeat opens) + const cached = getCustomerContacts(_asset.customer_id); + if (cached) { + _contacts = cached; + renderContactList(listEl, _contacts); + return; + } + + listEl.innerHTML = `
${spinner()} Loading contacts…
`; + + try { + _contacts = await getContacts(_asset.customer_id); + _contacts.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + renderContactList(listEl, _contacts); + } catch (err) { + listEl.innerHTML = `
Failed to load: ${esc(err.message)}
`; + } +} + +function closeContactPanel() { + document.getElementById('contact-panel')?.classList.remove('visible'); + _pendingAction = null; + focusScanInput(); +} + +// ── Infrastructure ──────────────────────────────────────────────────────────── + +function _openInfraPanel() { + const panel = document.getElementById('infra-panel'); + const titleEl = document.getElementById('infra-panel-title'); + const isInfra = _asset.properties?.['Infrastructure'] === 'Yes'; + + if (titleEl) titleEl.textContent = isInfra ? 'Manage Infrastructure' : 'Set Infrastructure'; + const tagInput = document.getElementById('infra-tag-input'); + if (tagInput) tagInput.value = _asset.properties?.['Tag'] ?? ''; + const locInput = document.getElementById('infra-location-input'); + if (locInput) locInput.value = _asset.properties?.['Location'] ?? ''; + + panel?.classList.add('visible'); + panel?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + _closeLifecycleDropdown(); + tagInput?.focus(); +} + +function _closeInfraPanel() { + document.getElementById('infra-panel')?.classList.remove('visible'); + focusScanInput(); +} + +async function _handleConfirmInfra() { + const tag = document.getElementById('infra-tag-input')?.value.trim(); + const location = document.getElementById('infra-location-input')?.value.trim(); + if (!tag) { + document.getElementById('infra-tag-input')?.focus(); + return; + } + _closeInfraPanel(); + + const actionDesc = `Marked as Infrastructure: ${tag}${location ? ` — ${location}` : ''}`; + const newHistory = appendHistory(_asset.properties?.['Asset History'], actionDesc); + + try { + await updateAsset(_asset.id, { + contact_id: null, + properties: { + 'Infrastructure': 'Yes', + 'Tag': tag, + 'Location': location ?? '', + 'Last Scan Date': today(), + 'Last Action': actionDesc, + 'Asset History': newHistory, + }, + }, _asset.customer_id); + _asset.contact_id = null; + _asset.contact = null; + _asset.contact_fullname = null; + _asset.properties = { + ...(_asset.properties ?? {}), + 'Infrastructure': 'Yes', + 'Tag': tag, + 'Location': location ?? '', + 'Last Scan Date': today(), + 'Last Action': actionDesc, + 'Asset History': newHistory, + }; + refreshCard(); + showToast(`Infrastructure: ${tag}${location ? ` — ${location}` : ''}`, 'success'); + } catch (err) { + showToast('Failed: ' + err.message, 'error'); + } finally { + resetIdleTimer(); + } +} + +function filterContacts() { + if (!_contacts) return; + const q = document.getElementById('contact-search')?.value.toLowerCase() ?? ''; + const filtered = q ? _contacts.filter(c => c.name?.toLowerCase().includes(q) || c.email?.toLowerCase().includes(q)) : _contacts; + renderContactList(document.getElementById('contact-list'), filtered); +} + +function renderContactList(listEl, contacts) { + if (!contacts.length) { + listEl.innerHTML = `
No contacts found.
`; + return; + } + listEl.innerHTML = contacts.map(c => ` +
+
+
${esc(c.name)}
+ ${c.email ? `` : ''} +
+ +
+ `).join(''); + + listEl.querySelectorAll('.contact-item').forEach(item => { + item.addEventListener('click', () => handleContactSelected( + parseInt(item.dataset.contactId), + item.dataset.contactName + )); + }); +} + +async function handleContactSelected(contactId, contactName) { + closeContactPanel(); + + const isSignOut = _pendingAction === 'sign-out'; + const actionDesc = isSignOut ? `Signed out to ${contactName}` : `Owner changed to ${contactName}`; + const newHistory = appendHistory(_asset.properties?.['Asset History'], actionDesc); + + const updatePayload = { + contact_id: contactId, + properties: { + 'Infrastructure': '', // clear if previously infrastructure + 'Tag': '', + 'Location': '', + 'Last Scan Date': today(), + 'Last Action': actionDesc, + 'Asset History': newHistory, + }, + }; + + if (isSignOut) { + updatePayload.properties['Possession Status'] = 'Deployed'; + } + + try { + await updateAsset(_asset.id, updatePayload, _asset.customer_id); + // Update local asset state + _asset.contact_id = contactId; + _asset.contact = { id: contactId, name: contactName }; + _asset.contact_fullname = contactName; + _asset.properties = { + ...(_asset.properties ?? {}), + ...updatePayload.properties, + }; + refreshCard(); + showToast(actionDesc, 'success'); + } catch (err) { + showToast('Failed: ' + err.message, 'error'); + } finally { + resetIdleTimer(); + } +} + +// ── Refresh card in place ───────────────────────────────────────────────────── + +function refreshCard() { + renderAssetCard(_asset); + initActions(_asset); + initTicketHistory(_asset); + updateCachedAsset(_asset); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function setButtonLoading(btn, loading) { + if (!btn) return; + btn.classList.toggle('loading', loading); +} + +function today() { + return new Date().toISOString().split('T')[0]; +} + +function appendHistory(current, actionDesc) { + const now = new Date(); + const stamp = now.getFullYear() + '-' + + String(now.getMonth() + 1).padStart(2, '0') + '-' + + String(now.getDate()).padStart(2, '0') + ' ' + + String(now.getHours()).padStart(2, '0') + ':' + + String(now.getMinutes()).padStart(2, '0'); + const newEntry = `[${stamp}] — ${actionDesc}`; + const existing = (current ?? '').trim(); + const lines = existing ? existing.split('\n').filter(Boolean) : []; + lines.unshift(newEntry); + return lines.slice(0, 100).join('\n'); +} + +function esc(s) { + return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function spinner() { + return ``; +} diff --git a/public/modules/assetBrowser.js b/public/modules/assetBrowser.js new file mode 100755 index 0000000..52cc85c --- /dev/null +++ b/public/modules/assetBrowser.js @@ -0,0 +1,1105 @@ +// assetBrowser.js — asset tree sidebar (clients → assets) with search + filters + +import { getCustomers, getCustomerAssets, getContacts } from '../api/syncro.js'; +import { normalizeUsername, usernameFuzzyMatch, usernameFirstNameMatch, usernameNameInitialMatch, usernameInitialLastNameMatch } from './usernameUtils.js'; + +// ── localStorage keys ───────────────────────────────────────────────────────── + +const LS_EXPANDED = 'assetBrowser_expanded'; // JSON number[] +const LS_ASSET_CACHE = 'assetBrowser_assetCache'; // JSON { [customerId]: asset[] } +const LS_CACHE_TS = 'assetBrowser_assetCacheTs'; // ms timestamp +const LS_BADGE_VIS = 'assetBrowser_badgeVis'; // JSON { possession, user, lifecycle, infra } +const LS_CONTACT_CACHE = 'assetBrowser_contactCache'; // JSON { [customerId]: contact[] } +const LS_SORT_PREFS = 'assetBrowser_sortPrefs'; // JSON { clients, assets } +const LS_DISPLAY_PREFS = 'assetBrowser_displayPrefs'; // JSON { showCount, showBillable, hideEmpty } +const LS_REMEMBER_MENU = 'assetBrowser_rememberMenu'; // 'true'|'false', default true +const LS_REMEMBER_FILTERS = 'assetBrowser_rememberFilters'; // 'true'|'false', default false +const LS_FILTER_STATE = 'assetBrowser_filterState'; // JSON saved filter state + +const POLL_INTERVAL_MS = 5 * 60_000; // re-fetch all assets every 5 min + +// ── Module state ────────────────────────────────────────────────────────────── + +let _onAssetSelect = null; +let _onAssetClose = null; +let _customers = []; +let _assetCache = new Map(); // customerId → asset[] +let _contactCache = new Map(); // customerId → contact[] +let _expandedIds = new Set(); // customer IDs whose subtrees are open +let _activeAssetId = null; +let _filterText = ''; +let _filterTimer = null; +let _preSearchExpandedIds = null; // snapshot saved when search begins +let _badgeVis = { possession: true, user: true, lifecycle: true, infra: true }; +let _sortPrefs = { clients: 'alpha', assets: 'default' }; +let _displayPrefs = { showCount: true, showBillable: false, hideEmpty: 'none' }; + +// ── Filter state ────────────────────────────────────────────────────────────── + +const _filters = { + lifecycle: new Set(), // empty = show all lifecycle stages + possession: null, // null | 'IT' | 'Deployed' + infra: null, // null | true | false +}; +let _filterPanelOpen = false; + +// ── DOM refs (set in initAssetBrowser) ─────────────────────────────────────── + +let _sidebar = null; +let _tree = null; +let _searchInput = null; +let _filterBtn = null; +let _filterPanel = null; + +// ── Public API ──────────────────────────────────────────────────────────────── + +export function initAssetBrowser({ onAssetSelect, onAssetClose }) { + _onAssetSelect = onAssetSelect; + _onAssetClose = onAssetClose ?? null; + + _sidebar = document.getElementById('asset-sidebar'); + _tree = document.getElementById('sidebar-tree'); + _searchInput = document.getElementById('sidebar-search'); + _filterBtn = document.getElementById('sidebar-filter-btn'); + _filterPanel = document.getElementById('sidebar-filter-panel'); + const _searchClearBtn = document.getElementById('sidebar-search-clear'); + + if (!_sidebar || !_tree) return; + + // Restore persisted state (expanded customers + asset cache) before loading + _restoreState(); + _syncFilterChips(); // update chip UI to match any restored filter state + + // Wire manual refresh button + document.getElementById('sidebar-refresh')?.addEventListener('click', _manualRefresh); + + // Wire menu button + document.getElementById('sidebar-menu-btn')?.addEventListener('click', e => { + e.stopPropagation(); + const isOpen = document.getElementById('sidebar-menu-panel')?.classList.contains('open'); + _openSidePanel(isOpen ? null : 'menu'); + }); + + // Wire all menu panel controls + _initMenuPanel(); + + // Close panels when clicking outside the sidebar header + document.addEventListener('click', e => { + if (!e.target.closest('.sidebar-header') && + !e.target.closest('.sidebar-filter-panel') && + !e.target.closest('.sidebar-menu-panel')) { + _openSidePanel(null); + } + }); + + // Wire search filter (debounced) + clear button + _searchInput?.addEventListener('input', () => { + if (_searchClearBtn) _searchClearBtn.hidden = !_searchInput.value.trim(); + clearTimeout(_filterTimer); + _filterTimer = setTimeout(() => { + const newFilter = _searchInput.value.trim().toLowerCase(); + if (newFilter && _preSearchExpandedIds === null) { + // Starting a search — snapshot current expansion state + _preSearchExpandedIds = new Set(_expandedIds); + } else if (!newFilter && _preSearchExpandedIds !== null) { + // Search cleared — restore pre-search expansion state + _expandedIds = new Set(_preSearchExpandedIds); + _preSearchExpandedIds = null; + } + _filterText = newFilter; + _renderTree(); + }, 200); + }); + + _searchClearBtn?.addEventListener('click', () => { + _searchInput.value = ''; + _searchClearBtn.hidden = true; + clearTimeout(_filterTimer); + if (_preSearchExpandedIds !== null) { + _expandedIds = new Set(_preSearchExpandedIds); + _preSearchExpandedIds = null; + } + _filterText = ''; + _renderTree(); + _searchInput.focus(); + }); + + // Wire filter button + panel + _filterBtn?.addEventListener('click', () => { + _openSidePanel(_filterPanelOpen ? null : 'filter'); + }); + + _filterPanel?.addEventListener('click', _handleFilterClick); + document.getElementById('sf-clear-btn')?.addEventListener('click', _clearFilters); + + // Wire "remember filters" checkbox + const filterRememberCb = document.getElementById('filter-remember'); + if (filterRememberCb) { + filterRememberCb.checked = localStorage.getItem(LS_REMEMBER_FILTERS) === 'true'; + filterRememberCb.addEventListener('change', () => { + localStorage.setItem(LS_REMEMBER_FILTERS, String(filterRememberCb.checked)); + if (filterRememberCb.checked) { + _saveFilterState(); // save current filters immediately when enabling + } else { + localStorage.removeItem(LS_FILTER_STATE); // clear saved state when disabling + } + }); + } + + // Event delegation for tree clicks + _tree.addEventListener('click', _handleTreeClick); + + // Show loading state then fetch customers + _tree.innerHTML = _loadingHTML('Loading clients…'); + _loadCustomers(); +} + +export function updateCachedAsset(asset) { + for (const [customerId, assets] of _assetCache) { + const idx = assets.findIndex(a => a.id === asset.id); + if (idx !== -1) { + assets[idx] = asset; + if (_expandedIds.has(customerId)) _renderAssetList(customerId); + break; + } + } +} + +export function getCustomerContactNames(customerId) { + const assets = _assetCache.get(customerId) ?? []; + const names = new Set(); + for (const a of assets) { + const name = a.contact_fullname ?? a.contact?.name; + if (name) names.add(name); + } + return [...names]; +} + +// Returns null when not yet loaded (callers can distinguish "loading" from "empty") +export function getCustomerContacts(customerId) { + return _contactCache.has(customerId) ? _contactCache.get(customerId) : null; +} + +export function searchLocal(query) { + const q = query.trim().toLowerCase(); + if (q.length < 2) return []; + + const customerMap = new Map(_customers.map(c => [c.id, c.business_name ?? c.name ?? ''])); + const results = []; + + for (const [customerId, assets] of _assetCache) { + for (const asset of assets) { + const serial = asset.asset_serial ?? asset.serial ?? asset.serial_number ?? ''; + const lastUser = asset.properties?.kabuto_information?.last_user ?? ''; + const contact = asset.contact_fullname ?? asset.contact?.name ?? ''; + if ( + asset.name?.toLowerCase().includes(q) || + serial.toLowerCase().includes(q) || + contact.toLowerCase().includes(q) || + lastUser.toLowerCase().includes(q) || + String(asset.id) === q + ) { + results.push({ asset, serial, contact, customerName: customerMap.get(customerId) ?? '' }); + if (results.length >= 8) return results; + } + } + } + return results; +} + +export function setActiveAsset(assetId) { + _activeAssetId = assetId; + + if (assetId === null) { + _tree.querySelectorAll('.sb-asset').forEach(el => el.classList.remove('active')); + return; + } + + // Find which customer owns this asset and auto-expand if needed + for (const [customerId, assets] of _assetCache) { + const found = assets.find(a => a.id === assetId); + if (found && !_expandedIds.has(customerId)) { + _expandedIds.add(customerId); + _saveExpandedState(); + } + } + + // Update highlight in DOM + _tree.querySelectorAll('.sb-asset').forEach(el => { + el.classList.toggle('active', Number(el.dataset.assetId) === assetId); + }); + + // If the customer row needs to be expanded in the DOM but isn't yet rendered, re-render + const activeRow = _tree.querySelector(`.sb-asset[data-asset-id="${assetId}"]`); + if (!activeRow && _customers.length > 0) { + _renderTree(); + } +} + +// Programmatically set filters and re-render the tree (used by Quick View click-through) +export function setFiltersAndRender({ lifecycle = [], possession = null } = {}) { + _filters.lifecycle = new Set(lifecycle); + _filters.possession = possession; + // Auto-expand any customer that has at least one matching asset + for (const [customerId, assets] of _assetCache) { + if (assets.some(a => _assetMatchesFilters(a))) { + _expandedIds.add(customerId); + } + } + _renderTree(); +} + +// ── Internal: data loading ──────────────────────────────────────────────────── + +async function _loadCustomers() { + try { + _customers = await getCustomers(); + // Sort alphabetically + _customers.sort((a, b) => (a.business_name ?? a.name ?? '').localeCompare(b.business_name ?? b.name ?? '')); + // Auto-expand when there's only one customer (e.g. client role) + if (_customers.length === 1) { + _expandedIds.add(_customers[0].id); + } + _renderTree(); + // Re-expand any customers that were open before the page loaded — + // if already in cache they render instantly, otherwise lazy-load with spinner + for (const id of _expandedIds) { + if (!_assetCache.has(id)) { + _loadCustomerAssets(id); + } + } + // Background: fetch all asset lists silently, then contacts, then start polling + _preloadAllAssets().then(() => { + _preloadAllContacts(); // fire-and-forget + _startPolling(); + }); + } catch (err) { + _tree.innerHTML = `
Failed to load clients
`; + console.error('[assetBrowser] getCustomers error:', err); + } +} + +async function _loadCustomerAssets(customerId, silent = false) { + if (!silent) { + // Show spinner only when the user explicitly expands a customer + const list = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); + if (list) { + list.innerHTML = `
Loading…
`; + list.classList.add('visible'); + } + } + + try { + const assets = await getCustomerAssets(customerId); + _assetCache.set(customerId, assets); + if (!silent) { + _renderAssetList(customerId); + } + _updateCustomerCount(customerId); + } catch (err) { + if (!silent) { + const list = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); + if (list) { + list.innerHTML = `
Failed to load assets
`; + } + } + console.error(`[assetBrowser] getCustomerAssets(${customerId}) error:`, err); + } +} + +// ── Internal: background preload + polling ──────────────────────────────────── + +async function _preloadAllAssets() { + // Fetch every customer's assets silently in parallel + await Promise.allSettled(_customers.map(c => _loadCustomerAssets(c.id, true))); + _saveAssetCache(); + // Refresh any expanded customer lists with fresh data + for (const customerId of _expandedIds) { + if (_assetCache.has(customerId)) _renderAssetList(customerId); + } +} + +async function _preloadAllContacts() { + await Promise.allSettled(_customers.map(async c => { + try { + const contacts = await getContacts(c.id); + contacts.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + _contactCache.set(c.id, contacts); + } catch (_) {} + })); + _saveContactCache(); +} + +function _saveContactCache() { + try { + const obj = {}; + for (const [id, contacts] of _contactCache) obj[id] = contacts; + localStorage.setItem(LS_CONTACT_CACHE, JSON.stringify(obj)); + } catch (_) {} +} + +function _startPolling() { + setInterval(_preloadAllAssets, POLL_INTERVAL_MS); +} + +function _manualRefresh() { + const btn = document.getElementById('sidebar-refresh'); + if (btn) { btn.classList.add('spinning'); btn.disabled = true; } + + // Tell the server to wipe its cache, then clear our own localStorage caches + fetch('/api/cache/refresh', { method: 'POST', credentials: 'same-origin' }) + .catch(() => {}) // best-effort — proceed regardless + .finally(() => { + _assetCache.clear(); + _contactCache.clear(); + localStorage.removeItem(LS_ASSET_CACHE); + localStorage.removeItem(LS_CACHE_TS); + localStorage.removeItem(LS_CONTACT_CACHE); + Promise.all([_preloadAllAssets(), _preloadAllContacts()]).finally(() => { + if (btn) { btn.classList.remove('spinning'); btn.disabled = false; } + }); + }); +} + +function _saveAssetCache() { + try { + const obj = {}; + for (const [id, assets] of _assetCache) obj[id] = assets; + localStorage.setItem(LS_ASSET_CACHE, JSON.stringify(obj)); + localStorage.setItem(LS_CACHE_TS, Date.now().toString()); + } catch (_) { /* storage full — ignore */ } +} + +// ── Internal: rendering ─────────────────────────────────────────────────────── + +function _renderTree() { + if (!_customers.length) { + _tree.innerHTML = `
No clients found
`; + return; + } + + const filter = _filterText; + + const hasFilter = filter || _filters.lifecycle.size > 0 || _filters.possession !== null || _filters.infra !== null; + + const visible = _customers.filter(c => { + // hideEmpty filtering (skip when a search/filter is active or assets not loaded yet) + if (_displayPrefs.hideEmpty !== 'none' && !hasFilter && _assetCache.has(c.id)) { + const assets = _assetCache.get(c.id); + if (_displayPrefs.hideEmpty === 'zero-assets' && assets.length === 0) return false; + if (_displayPrefs.hideEmpty === 'zero-billable' && _billableCount(assets) === 0) return false; + } + if (!hasFilter) return true; + const name = (c.business_name ?? c.name ?? '').toLowerCase(); + if (filter && name.includes(filter) && _filters.lifecycle.size === 0 && _filters.possession === null && _filters.infra === null) return true; + // Show customer only if at least one of its cached assets passes all filters + const assets = _assetCache.get(c.id) ?? []; + return assets.some(a => _assetMatchesSearch(a, filter) && _assetMatchesFilters(a)); + }); + + const rows = _sortedCustomers(visible) + .map(c => _customerRowHTML(c)) + .join(''); + + _tree.innerHTML = rows || `
No matches
`; + + // When a search is active, auto-expand every customer that has matching assets + if (_filterText) { + for (const [customerId, assets] of _assetCache) { + if (assets.some(a => _assetMatchesSearch(a, _filterText) && _assetMatchesFilters(a))) { + _expandedIds.add(customerId); + } + } + } + + // Re-render asset lists for expanded customers + for (const customerId of _expandedIds) { + if (_assetCache.has(customerId)) { + _renderAssetList(customerId); + } + // Mark customer row and asset list as expanded/visible + const customerEl = _tree.querySelector(`.sb-customer[data-customer-id="${customerId}"]`); + const listEl = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); + if (customerEl) customerEl.classList.add('expanded'); + if (listEl) listEl.classList.add('visible'); + } + + // Re-apply active asset highlight + if (_activeAssetId) { + const el = _tree.querySelector(`.sb-asset[data-asset-id="${_activeAssetId}"]`); + if (el) el.classList.add('active'); + } +} + +function _customerRowHTML(customer) { + const id = customer.id; + const name = customer.business_name ?? customer.name ?? `Customer ${id}`; + const isExpanded = _expandedIds.has(id); + + let countBadge = ''; + if (_assetCache.has(id)) { + const assets = _assetCache.get(id); + if (_displayPrefs.showCount) countBadge += `${_filteredAssetCount(assets)}`; + if (_displayPrefs.showBillable) countBadge += `${_filteredBillableCount(assets)}`; + } + + // Building block row + const row = ` +
+ + + + + + + + ${_esc(name)} + ${countBadge} +
+
+ ${isExpanded && !_assetCache.has(id) ? `
Loading…
` : ''} +
`; + + return row; +} + +function _renderAssetList(customerId) { + const list = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); + if (!list) return; + + const assets = _assetCache.get(customerId) ?? []; + const filter = _filterText; + + const hasActiveFilter = filter || _filters.lifecycle.size > 0 || _filters.possession !== null || _filters.infra !== null; + let filtered = hasActiveFilter + ? assets.filter(a => _assetMatchesSearch(a, filter) && _assetMatchesFilters(a)) + : assets.slice(); + + if (!filtered.length) { + list.innerHTML = `
${hasActiveFilter ? 'No matches' : 'No assets'}
`; + return; + } + + _sortAssets(filtered); + + list.innerHTML = filtered.map(a => { + const isActive = a.id === _activeAssetId; + const assetType = _assetDisplayType(a); + const badges = _assetBadgesHTML(a); + return ` +
+ ${_esc(a.name ?? `Asset ${a.id}`)} +
+ ${badges}${assetType ? `${_esc(assetType)}` : ''} +
+
`; + }).join(''); +} + +// ── Billable helper ─────────────────────────────────────────────────────────── + +const _DEAD_STAGES = new Set(['For Parts', 'Decommissioned', 'Disposed of']); + +function _billableCount(assets) { + return assets.filter(a => !_DEAD_STAGES.has(a.properties?.['Lifecycle Stage'])).length; +} + +// Returns count of assets passing the currently active filters (falls back to total when no filter) +function _filteredAssetCount(assets) { + const hasFilter = _filterText || _filters.lifecycle.size > 0 || _filters.possession !== null || _filters.infra !== null; + if (!hasFilter) return assets.length; + return assets.filter(a => _assetMatchesSearch(a, _filterText) && _assetMatchesFilters(a)).length; +} + +function _filteredBillableCount(assets) { + const hasFilter = _filterText || _filters.lifecycle.size > 0 || _filters.possession !== null || _filters.infra !== null; + if (!hasFilter) return _billableCount(assets); + return assets.filter(a => + _assetMatchesSearch(a, _filterText) && + _assetMatchesFilters(a) && + !_DEAD_STAGES.has(a.properties?.['Lifecycle Stage']) + ).length; +} + +// ── Sort + badge helpers ─────────────────────────────────────────────────────── + +const _POSSESSION_ORDER = { + 'In IT Possession': 0, + 'Deployed': 1, + 'In User Possession': 1, // legacy value +}; + +const _LIFECYCLE_ORDER = { + 'Active': 0, + 'Inventory': 1, + 'Pre-Deployment': 2, + 'For Repair': 3, + 'For Upgrade': 4, + 'For Parts': 5, + 'Decommissioned': 6, + 'Disposed of': 7, +}; + +const _LIFECYCLE_SHORT = { + 'Pre-Deployment': 'Pre-Deploy', + 'Active': 'Active', + 'Inventory': 'Inventory', + 'For Repair': 'Repair', + 'For Upgrade': 'Upgrade', + 'For Parts': 'Parts', + 'Decommissioned': 'Decomm.', + 'Disposed of': 'Disposed', +}; + +const _LIFECYCLE_CSS = { + 'Pre-Deployment': 'sb-lc-predeployment', + 'Active': 'sb-lc-active', + 'Inventory': 'sb-lc-inventory', + 'For Repair': 'sb-lc-repair', + 'For Upgrade': 'sb-lc-upgrade', + 'For Parts': 'sb-lc-parts', + 'Decommissioned': 'sb-lc-decommissioned', + 'Disposed of': 'sb-lc-disposed', +}; + +function _sortAssets(arr) { + if (_sortPrefs.assets === 'alpha') { + arr.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + return; + } + if (_sortPrefs.assets === 'user') { + arr.sort((a, b) => { + const ua = (a.contact_fullname ?? a.contact?.name ?? '').toLowerCase(); + const ub = (b.contact_fullname ?? b.contact?.name ?? '').toLowerCase(); + if (!ua && ub) return 1; + if (ua && !ub) return -1; + if (ua !== ub) return ua.localeCompare(ub); + return (a.name ?? '').localeCompare(b.name ?? ''); + }); + return; + } + if (_sortPrefs.assets === 'last-sync') { + arr.sort((a, b) => { + const ta = a.properties?.kabuto_information?.last_synced_at; + const tb = b.properties?.kabuto_information?.last_synced_at; + if (!ta && !tb) return (a.name ?? '').localeCompare(b.name ?? ''); + if (!ta) return 1; + if (!tb) return -1; + return new Date(tb) - new Date(ta); // most recent first + }); + return; + } + // default: possession → lifecycle → name + arr.sort((a, b) => { + const pa = _POSSESSION_ORDER[a.properties?.['Possession Status']] ?? 2; + const pb = _POSSESSION_ORDER[b.properties?.['Possession Status']] ?? 2; + if (pa !== pb) return pa - pb; + const la = _LIFECYCLE_ORDER[a.properties?.['Lifecycle Stage']] ?? 7; + const lb = _LIFECYCLE_ORDER[b.properties?.['Lifecycle Stage']] ?? 7; + if (la !== lb) return la - lb; + return (a.name ?? '').localeCompare(b.name ?? ''); + }); +} + +function _sortedCustomers(arr) { + if (_sortPrefs.clients === 'alpha') return arr; + const copy = arr.slice(); + if (_sortPrefs.clients === 'most-assets') { + copy.sort((a, b) => { + const d = (_assetCache.get(b.id)?.length ?? 0) - (_assetCache.get(a.id)?.length ?? 0); + return d !== 0 ? d : (a.business_name ?? a.name ?? '').localeCompare(b.business_name ?? b.name ?? ''); + }); + } else if (_sortPrefs.clients === 'most-billable') { + copy.sort((a, b) => { + const d = _billableCount(_assetCache.get(b.id) ?? []) - _billableCount(_assetCache.get(a.id) ?? []); + return d !== 0 ? d : (a.business_name ?? a.name ?? '').localeCompare(b.business_name ?? b.name ?? ''); + }); + } else if (_sortPrefs.clients === 'most-users') { + copy.sort((a, b) => { + const ua = new Set((_assetCache.get(a.id) ?? []).map(x => x.contact_fullname ?? x.contact?.name).filter(Boolean)).size; + const ub = new Set((_assetCache.get(b.id) ?? []).map(x => x.contact_fullname ?? x.contact?.name).filter(Boolean)).size; + return ub !== ua ? ub - ua : (a.business_name ?? a.name ?? '').localeCompare(b.business_name ?? b.name ?? ''); + }); + } + return copy; +} + +function _assetBadgesHTML(asset) { + const possession = asset.properties?.['Possession Status']; + const lifecycle = asset.properties?.['Lifecycle Stage']; + const isInfra = asset.properties?.['Infrastructure'] === 'Yes'; + const rawLastUser = asset.properties?.kabuto_information?.last_user ?? ''; + const lastUser = normalizeUsername(rawLastUser); + const assignedUser = asset.contact_fullname ?? null; + const allContactNames = getCustomerContactNames(asset.customer_id); + const sameUser = !!(lastUser && assignedUser && ( + usernameFuzzyMatch(rawLastUser, assignedUser) || + usernameFirstNameMatch(rawLastUser, assignedUser, allContactNames) || + usernameNameInitialMatch(rawLastUser, assignedUser, allContactNames) || + usernameInitialLastNameMatch(rawLastUser, assignedUser, allContactNames) + )); + + let html = ''; + + if (_badgeVis.infra && isInfra) { + html += `Infra`; + } + + if (_badgeVis.possession) { + if (possession === 'In IT Possession') { + html += `IT`; + } else if (possession === 'Deployed' || possession === 'In User Possession') { + html += `Deployed`; + } + } + + if (_badgeVis.user && !isInfra) { + if (sameUser) { + // Show contact name (properly formatted from Syncro) with tooltip for long names + html += `${_esc(assignedUser)}`; + } else { + if (lastUser) html += `${_esc(lastUser)}`; + if (assignedUser) html += `${_esc(assignedUser)}`; + } + } + + if (_badgeVis.lifecycle && lifecycle && _LIFECYCLE_SHORT[lifecycle]) { + const cls = _LIFECYCLE_CSS[lifecycle] ?? 'sb-lc-unknown'; + const label = _LIFECYCLE_SHORT[lifecycle]; + html += `${label}`; + } + + return html; +} + +function _assetDisplayType(asset) { + // Use form_factor (set by Kabuto/RMM agent) first; fall back to asset_type + // but skip generic "Syncro Device" placeholder + const formFactor = asset.properties?.form_factor + ?? asset.properties?.kabuto_information?.general?.form_factor; + if (formFactor) return formFactor; + const type = asset.asset_type ?? ''; + return type === 'Syncro Device' ? '' : type; +} + +function _updateCustomerCount(customerId) { + const row = _tree.querySelector(`.sb-customer[data-customer-id="${customerId}"]`); + if (!row) return; + const assets = _assetCache.get(customerId) ?? []; + + // Total count badge + if (_displayPrefs.showCount) { + let b = row.querySelector('.sb-customer-count'); + if (!b) { b = document.createElement('span'); b.className = 'sb-customer-count'; row.appendChild(b); } + b.textContent = _filteredAssetCount(assets); + } else { + row.querySelector('.sb-customer-count')?.remove(); + } + + // Billable count badge + if (_displayPrefs.showBillable) { + let b = row.querySelector('.sb-customer-billable'); + if (!b) { b = document.createElement('span'); b.className = 'sb-customer-billable'; row.appendChild(b); } + b.textContent = _filteredBillableCount(assets); + } else { + row.querySelector('.sb-customer-billable')?.remove(); + } +} + +// ── Internal: interaction ───────────────────────────────────────────────────── + +function _handleTreeClick(e) { + const customerRow = e.target.closest('.sb-customer'); + const assetRow = e.target.closest('.sb-asset'); + + if (customerRow) { + _toggleCustomer(Number(customerRow.dataset.customerId)); + } else if (assetRow) { + const assetId = Number(assetRow.dataset.assetId); + const customerId = Number(assetRow.dataset.customerId); + const asset = _assetCache.get(customerId)?.find(a => a.id === assetId); + if (asset) { + if (asset.id === _activeAssetId && _onAssetClose) { + _onAssetClose(); + } else if (_onAssetSelect) { + _onAssetSelect(asset); + } + } + } +} + +function _toggleCustomer(customerId) { + if (_expandedIds.has(customerId)) { + _expandedIds.delete(customerId); + } else { + _expandedIds.add(customerId); + } + _saveExpandedState(); + + // Toggle expanded class on customer row + const customerEl = _tree.querySelector(`.sb-customer[data-customer-id="${customerId}"]`); + const listEl = _tree.querySelector(`.sb-asset-list[data-customer-id="${customerId}"]`); + + if (_expandedIds.has(customerId)) { + customerEl?.classList.add('expanded'); + listEl?.classList.add('visible'); + if (_assetCache.has(customerId)) { + // Already cached (preload or prior expand) — just render the list + _renderAssetList(customerId); + } else { + // Not yet fetched — load with spinner + _loadCustomerAssets(customerId); + } + } else { + customerEl?.classList.remove('expanded'); + listEl?.classList.remove('visible'); + } +} + +// ── Internal: panel open/close ──────────────────────────────────────────────── + +function _openSidePanel(which) { + // which: 'filter' | 'menu' | null (close all) + const filterPanel = _filterPanel; + const menuPanel = document.getElementById('sidebar-menu-panel'); + const menuBtn = document.getElementById('sidebar-menu-btn'); + + const openFilter = which === 'filter'; + const openMenu = which === 'menu'; + + filterPanel?.classList.toggle('open', openFilter); + _filterBtn?.classList.toggle('active', openFilter); + _filterPanelOpen = openFilter; + + menuPanel?.classList.toggle('open', openMenu); + menuBtn?.classList.toggle('active', openMenu); + + // Add .ready after the panel open animation completes so subsection body + // transitions don't fire spuriously while the outer panel is animating. + if (menuPanel) { + menuPanel.classList.remove('ready'); + if (openMenu) { + // 230ms = panel max-height transition duration (0.22s) + small buffer + setTimeout(() => menuPanel.classList.add('ready'), 230); + } + } +} + +// ── Internal: filter handlers ───────────────────────────────────────────────── + +function _handleFilterClick(e) { + const chip = e.target.closest('.sf-chip'); + if (!chip) return; + + const section = chip.closest('[data-filter-type]'); + const type = section?.dataset.filterType; + + if (type === 'lifecycle') { + const val = chip.dataset.value; + if (_filters.lifecycle.has(val)) { + _filters.lifecycle.delete(val); + chip.classList.remove('active'); + } else { + _filters.lifecycle.add(val); + chip.classList.add('active'); + } + } else if (type === 'possession') { + const val = chip.dataset.value; + _filters.possession = val === '' ? null : val; + section.querySelectorAll('.sf-chip').forEach(c => + c.classList.toggle('active', c.dataset.value === val) + ); + } else if (type === 'infra') { + const val = chip.dataset.value; + _filters.infra = val === '' ? null : val === 'true'; + section.querySelectorAll('.sf-chip').forEach(c => + c.classList.toggle('active', c.dataset.value === val) + ); + } + + _saveFilterState(); + _updateFilterBadge(); + _renderTree(); +} + +function _clearFilters() { + _filters.lifecycle.clear(); + _filters.possession = null; + _filters.infra = null; + + if (_filterPanel) { + // Reset lifecycle: clear all active chips + _filterPanel.querySelectorAll('[data-filter-type="lifecycle"] .sf-chip').forEach(c => + c.classList.remove('active') + ); + // Reset single-select sections: re-activate "All" chip + _filterPanel.querySelectorAll('[data-filter-type="possession"] .sf-chip, [data-filter-type="infra"] .sf-chip').forEach(c => + c.classList.toggle('active', c.dataset.value === '') + ); + } + + _saveFilterState(); + _updateFilterBadge(); + _renderTree(); +} + +function _syncFilterChips() { + if (!_filterPanel) return; + _filterPanel.querySelectorAll('[data-filter-type="lifecycle"] .sf-chip').forEach(c => + c.classList.toggle('active', _filters.lifecycle.has(c.dataset.value)) + ); + const possStr = _filters.possession === null ? '' : _filters.possession; + _filterPanel.querySelectorAll('[data-filter-type="possession"] .sf-chip').forEach(c => + c.classList.toggle('active', c.dataset.value === possStr) + ); + const infraStr = _filters.infra === null ? '' : String(_filters.infra); + _filterPanel.querySelectorAll('[data-filter-type="infra"] .sf-chip').forEach(c => + c.classList.toggle('active', c.dataset.value === infraStr) + ); + _updateFilterBadge(); +} + +function _updateFilterBadge() { + const count = _filters.lifecycle.size + + (_filters.possession !== null ? 1 : 0) + + (_filters.infra !== null ? 1 : 0); + const badge = document.getElementById('sidebar-filter-badge'); + if (badge) { + badge.textContent = count; + badge.hidden = count === 0; + } +} + +// ── Internal: search + filter predicates ────────────────────────────────────── + +function _assetMatchesSearch(asset, filter) { + if (!filter) return true; + const serial = asset.asset_serial ?? asset.serial ?? asset.serial_number ?? ''; + const lastUser = asset.properties?.kabuto_information?.last_user ?? ''; + const contact = asset.contact_fullname ?? asset.contact?.name ?? ''; + return ( + (asset.name ?? '').toLowerCase().includes(filter) || + serial.toLowerCase().includes(filter) || + contact.toLowerCase().includes(filter) || + lastUser.toLowerCase().includes(filter) || + String(asset.id) === filter + ); +} + +function _assetMatchesFilters(asset) { + if (_filters.lifecycle.size > 0) { + const lc = asset.properties?.['Lifecycle Stage'] ?? ''; + if (!_filters.lifecycle.has(lc)) return false; + } + if (_filters.possession !== null) { + const poss = asset.properties?.['Possession Status'] ?? ''; + if (_filters.possession === 'IT' && poss !== 'In IT Possession') return false; + if (_filters.possession === 'Deployed' && poss !== 'Deployed' && poss !== 'In User Possession') return false; + } + if (_filters.infra !== null) { + const isInfra = asset.properties?.['Infrastructure'] === 'Yes'; + if (_filters.infra !== isInfra) return false; + } + return true; +} + +// ── Internal: persistence ───────────────────────────────────────────────────── + +function _restoreState() { + // Expanded customer IDs + try { + const saved = localStorage.getItem(LS_EXPANDED); + if (saved) { + const ids = JSON.parse(saved); + if (Array.isArray(ids)) _expandedIds = new Set(ids); + } + } catch (_) { /* ignore corrupt localStorage */ } + + // Badge vis, sort, display prefs — only if "remember menu" is on (default: true) + if (localStorage.getItem(LS_REMEMBER_MENU) !== 'false') { + try { + const raw = localStorage.getItem(LS_BADGE_VIS); + if (raw) _badgeVis = { ..._badgeVis, ...JSON.parse(raw) }; + } catch (_) {} + try { const r = localStorage.getItem(LS_SORT_PREFS); if (r) _sortPrefs = { ..._sortPrefs, ...JSON.parse(r) }; } catch (_) {} + try { const r = localStorage.getItem(LS_DISPLAY_PREFS); if (r) _displayPrefs = { ..._displayPrefs, ...JSON.parse(r) }; } catch (_) {} + } + + // Asset cache — populate immediately so tree renders with counts + assets on first paint + try { + const raw = localStorage.getItem(LS_ASSET_CACHE); + if (raw) { + const data = JSON.parse(raw); + for (const [idStr, assets] of Object.entries(data)) { + _assetCache.set(Number(idStr), assets); + } + } + } catch (_) { /* ignore corrupt localStorage */ } + + // Contact cache + try { + const raw = localStorage.getItem(LS_CONTACT_CACHE); + if (raw) { + const data = JSON.parse(raw); + for (const [idStr, contacts] of Object.entries(data)) { + _contactCache.set(Number(idStr), contacts); + } + } + } catch (_) {} + + // Filter state — only if "remember filters" is on (default: false) + if (localStorage.getItem(LS_REMEMBER_FILTERS) === 'true') { + try { + const raw = localStorage.getItem(LS_FILTER_STATE); + if (raw) { + const saved = JSON.parse(raw); + if (Array.isArray(saved.lifecycle)) saved.lifecycle.forEach(v => _filters.lifecycle.add(v)); + if (saved.possession !== undefined) _filters.possession = saved.possession; + if (saved.infra !== undefined) _filters.infra = saved.infra; + } + } catch (_) {} + } +} + +function _saveExpandedState() { + localStorage.setItem(LS_EXPANDED, JSON.stringify([..._expandedIds])); +} + +function _saveBadgeVis() { if (localStorage.getItem(LS_REMEMBER_MENU) !== 'false') localStorage.setItem(LS_BADGE_VIS, JSON.stringify(_badgeVis)); } +function _saveSortPrefs() { if (localStorage.getItem(LS_REMEMBER_MENU) !== 'false') localStorage.setItem(LS_SORT_PREFS, JSON.stringify(_sortPrefs)); } +function _saveDisplayPrefs() { if (localStorage.getItem(LS_REMEMBER_MENU) !== 'false') localStorage.setItem(LS_DISPLAY_PREFS, JSON.stringify(_displayPrefs)); } + +function _saveFilterState() { + if (localStorage.getItem(LS_REMEMBER_FILTERS) !== 'true') return; + try { + localStorage.setItem(LS_FILTER_STATE, JSON.stringify({ + lifecycle: [..._filters.lifecycle], + possession: _filters.possession, + infra: _filters.infra, + })); + } catch (_) {} +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _esc(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function _loadingHTML(msg) { + return `
${_esc(msg)}
`; +} + +// ── Menu panel wiring ───────────────────────────────────────────────────────── + +function _initMenuPanel() { + // ── Sub-section collapse/expand ────────────────────────────────────────── + document.querySelectorAll('.sidebar-menu-subsection-header').forEach(header => { + header.addEventListener('click', () => { + header.closest('.sidebar-menu-subsection')?.classList.toggle('open'); + }); + }); + + // ── Badge visibility checkboxes ────────────────────────────────────────── + ['possession', 'user', 'lifecycle', 'infra'].forEach(key => { + const cb = document.getElementById(`badge-vis-${key}`); + if (!cb) return; + cb.checked = _badgeVis[key]; + cb.addEventListener('change', () => { + _badgeVis[key] = cb.checked; + _saveBadgeVis(); + for (const customerId of _expandedIds) { + if (_assetCache.has(customerId)) _renderAssetList(customerId); + } + }); + }); + + // ── Display: show count ────────────────────────────────────────────────── + const showCountCb = document.getElementById('disp-show-count'); + if (showCountCb) { + showCountCb.checked = _displayPrefs.showCount; + showCountCb.addEventListener('change', () => { + _displayPrefs.showCount = showCountCb.checked; + _saveDisplayPrefs(); + _renderTree(); + }); + } + + // ── Display: show billable ─────────────────────────────────────────────── + const showBillableCb = document.getElementById('disp-show-billable'); + if (showBillableCb) { + showBillableCb.checked = _displayPrefs.showBillable; + showBillableCb.addEventListener('change', () => { + _displayPrefs.showBillable = showBillableCb.checked; + _saveDisplayPrefs(); + _renderTree(); + }); + } + + // ── Display: hide empty ────────────────────────────────────────────────── + const hideEmptySel = document.getElementById('disp-hide-empty'); + if (hideEmptySel) { + hideEmptySel.value = _displayPrefs.hideEmpty; + hideEmptySel.addEventListener('change', () => { + _displayPrefs.hideEmpty = hideEmptySel.value; + _saveDisplayPrefs(); + _renderTree(); + }); + } + + // ── Remember menu settings ─────────────────────────────────────────────── + const rememberMenuCb = document.getElementById('menu-remember'); + if (rememberMenuCb) { + rememberMenuCb.checked = localStorage.getItem(LS_REMEMBER_MENU) !== 'false'; + rememberMenuCb.addEventListener('change', () => { + localStorage.setItem(LS_REMEMBER_MENU, String(rememberMenuCb.checked)); + if (rememberMenuCb.checked) { + // Save current state immediately so it'll be there on next reload + _saveBadgeVis(); + _saveSortPrefs(); + _saveDisplayPrefs(); + } else { + // Clear saved state so next reload uses defaults + localStorage.removeItem(LS_BADGE_VIS); + localStorage.removeItem(LS_SORT_PREFS); + localStorage.removeItem(LS_DISPLAY_PREFS); + } + }); + } + + // ── Sort chips ─────────────────────────────────────────────────────────── + document.querySelectorAll('[data-menu-sort]').forEach(chip => { + const sortType = chip.dataset.menuSort; // 'clients' or 'assets' + const value = chip.dataset.value; + + // Set initial active state + const currentVal = sortType === 'clients' ? _sortPrefs.clients : _sortPrefs.assets; + chip.classList.toggle('active', value === currentVal); + + chip.addEventListener('click', () => { + if (sortType === 'clients') { + _sortPrefs.clients = value; + _saveSortPrefs(); + // Sync .active on sibling chips + document.querySelectorAll('[data-menu-sort="clients"]').forEach(c => + c.classList.toggle('active', c.dataset.value === value) + ); + _renderTree(); + } else if (sortType === 'assets') { + _sortPrefs.assets = value; + _saveSortPrefs(); + document.querySelectorAll('[data-menu-sort="assets"]').forEach(c => + c.classList.toggle('active', c.dataset.value === value) + ); + // Re-render all currently expanded asset lists + for (const customerId of _expandedIds) { + if (_assetCache.has(customerId)) _renderAssetList(customerId); + } + } + }); + }); +} diff --git a/public/modules/assetCard.js b/public/modules/assetCard.js new file mode 100755 index 0000000..5a54fd8 --- /dev/null +++ b/public/modules/assetCard.js @@ -0,0 +1,391 @@ +// assetCard.js — renders the asset card into #asset-card-container +import CONFIG from '../config.js'; +import { normalizeUsername, usernameFuzzyMatch, usernameFirstNameMatch, usernameNameInitialMatch } from './usernameUtils.js'; +import { getCustomerContactNames } from './assetBrowser.js'; + +const { subdomain, baseUrl } = CONFIG.syncro; + +// ── Public ─────────────────────────────────────────────────────────────────── + +export function renderAssetCard(asset) { + const container = document.getElementById('asset-card-container'); + container.innerHTML = buildCardHTML(asset); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function prop(asset, key) { + return asset?.properties?.[key] ?? null; +} + +function buildCardHTML(a) { + const possessionStatus = prop(a, 'Possession Status'); + const lifecycleStage = prop(a, 'Lifecycle Stage'); + const lastScanDate = prop(a, 'Last Scan Date'); + const lastAction = prop(a, 'Last Action'); + const assetHistory = prop(a, 'Asset History'); + + const infraTag = prop(a, 'Tag'); + const infraLocation = prop(a, 'Location'); + const isInfra = prop(a, 'Infrastructure') === 'Yes'; + const contactName = a.contact_fullname ?? a.contact?.name ?? null; + const rawLastUser = a.properties?.kabuto_information?.last_user ?? ''; + const lastUser = normalizeUsername(rawLastUser); + const allContactNames = getCustomerContactNames(a.customer_id); + const sameUser = !!(lastUser && contactName && ( + usernameFuzzyMatch(rawLastUser, contactName) || + usernameFirstNameMatch(rawLastUser, contactName, allContactNames) || + usernameNameInitialMatch(rawLastUser, contactName, allContactNames) + )); + const contactEmail = a.contact?.email ?? null; + const customerName = a.customer?.business_name ?? a.customer?.business_then_name ?? a.customer?.name ?? '—'; + const serialNumber = a.asset_serial ?? a.serial ?? a.serial_number ?? '—'; + const assetType = a.properties?.form_factor + ?? a.properties?.kabuto_information?.general?.form_factor + ?? a.asset_type ?? 'Device'; + + const syncroUrl = `${baseUrl}/customer_assets/${a.id}`; + + return ` +
+ + +
+
+
${esc(a.name)}
+
+ ${esc(assetType)} + ${esc(customerName)} +
+
+
+
Asset ID
+
#${a.id}
+ + ${iconExternal()} Open in Syncro + +
+ +
+ + +
+ + +
+
+
Possession
+ ${possessionBadge(possessionStatus)} +
+
+
Lifecycle
+ ${lifecycleBadge(lifecycleStage)} +
+ ${(!isInfra && lastUser) ? ` +
+
Last Login
+
${esc(sameUser ? contactName : lastUser)}
+
` : ''} +
+
Last Sync
+
${a.properties?.kabuto_information?.last_synced_at ? formatDate(a.properties.kabuto_information.last_synced_at) : 'Never'}
+
+
+ + +
+
+
Serial Number
+
${esc(serialNumber)}
+
+ ${infraLocation ? ` +
+
Location
+
${esc(infraLocation)}
+
` : ''} + ${!isInfra ? ` +
+
Assigned User
+
${ + contactName + ? `${esc(contactName)}${contactEmail ? `
${esc(contactEmail)}` : ''}` + : 'Unassigned' + }
+
` : ''} +
+
Asset Type
+
${esc(assetType)}
+
+
+
Customer
+
${esc(customerName)}
+
+ ${infraTag ? ` +
+
Tags
+
+
${infraTag.split(',').map(t => t.trim()).filter(Boolean).map(t => `${esc(t)}`).join('')}
+
+
` : ''} +
+ + +
+
+
Last Scan
+
${lastScanDate ? formatDate(lastScanDate) : 'Not set'}
+
+
+
Last Action
+
${lastAction ? esc(lastAction) : 'None recorded'}
+
+ ${a.warranty_expires_at ? ` +
+
Warranty Expires
+
${formatDate(a.warranty_expires_at)}
+
` : ''} +
+ + +
+
Actions
+
+ +
+ +
+ ${['Pre-Deployment','Inventory','Active','For Repair','For Upgrade','For Parts','Decommissioned','Disposed of'].map(stage => { + const dotCls = 'lc-dot-' + stage.toLowerCase().replace(/\s+/g,'-').replace(/[^a-z0-9-]/g,''); + const isCurrent = lifecycleStage === stage; + return ``; + }).join('')} +
+
+ + ${contactName ? `` : ''} + + + + +
+ + +
+
Select Contact
+ +
+
${iconSpinner()} Loading contacts…
+
+
+ +
+
+ + +
+
${isInfra ? 'Manage Infrastructure' : 'Set Infrastructure'}
+ + +
+ + +
+
+
+ + +
+ +
+
${iconSpinner()} Loading tickets…
+
+
+ + +
+ +
+ ${buildHistoryHTML(assetHistory)} +
+
+ +
+
`; +} + +// ── Badge builders ──────────────────────────────────────────────────────────── + +function possessionBadge(status) { + const cls = !status ? 'badge-unknown' + : status === 'In IT Possession' ? 'badge-it-possession' + : (status === 'Deployed' || status === 'In User Possession') ? 'badge-user-possession' + : 'badge-unknown'; + const labelInner = !status ? 'Unknown' + : status === 'In IT Possession' ? `${iconCheck()} In IT Possession` + : (status === 'Deployed' || status === 'In User Possession') ? `${iconUser()} Deployed` + : esc(status); + const chevron = ``; + const options = ['In IT Possession', 'Deployed'].map(opt => { + const isCurrent = status === opt || (opt === 'Deployed' && status === 'In User Possession'); + return ``; + }).join(''); + return ` +
+ +
${options}
+
`; +} + +function lifecycleBadge(stage) { + const map = { + 'Pre-Deployment':'badge-pre-deployment', + 'Inventory': 'badge-inventory', + 'Active': 'badge-active', + 'For Repair': 'badge-for-repair', + 'For Upgrade': 'badge-for-upgrade', + 'Decommissioned':'badge-decommissioned', + 'For Parts': 'badge-for-parts', + 'Disposed of': 'badge-disposed-of', + }; + const cls = !stage ? 'badge-unknown' : (map[stage] ?? 'badge-unknown'); + const chevron = ``; + const stages = ['Pre-Deployment','Inventory','Active','For Repair','For Upgrade','For Parts','Decommissioned','Disposed of']; + const options = stages.map(s => { + const dotCls = 'lc-dot-' + s.toLowerCase().replace(/\s+/g,'-').replace(/[^a-z0-9-]/g,''); + const isCurrent = stage === s; + return ``; + }).join(''); + return ` +
+ +
${options}
+
`; +} + +function buildHistoryHTML(rawValue) { + if (!rawValue || !rawValue.trim()) { + return `
No history recorded yet.
`; + } + const entries = rawValue.trim().split('\n').filter(Boolean); + return entries.map(line => { + // Expected format: [YYYY-MM-DD HH:MM] — description + const match = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\] — (.+)$/); + if (match) { + return `
+
+
+
${esc(match[2])}
+
${esc(match[1])}
+
+
`; + } + return `
+
+
${esc(line)}
+
`; + }).join(''); +} + +// ── Tiny inline icons (SVG) ─────────────────────────────────────────────────── + +function iconExternal() { + return ``; +} +function iconSwap() { + return ``; +} +function iconStages() { + return ``; +} +function iconPerson() { + return ``; +} +function iconSignOut() { + return ``; +} +function iconPrint() { + return ``; +} +function iconQueuePlus() { + return ``; +} +function iconCheck() { + return ``; +} +function iconUser() { + return ``; +} +function iconUserRemove() { + return ``; +} +function iconServer() { + return ``; +} +function iconSpinner() { + return ``; +} + +// ── Utils ───────────────────────────────────────────────────────────────────── + +function esc(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function formatDate(dateStr) { + if (!dateStr) return '—'; + try { + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric' + }); + } catch { + return dateStr; + } +} diff --git a/public/modules/cameraScanner.js b/public/modules/cameraScanner.js new file mode 100755 index 0000000..5a8eceb --- /dev/null +++ b/public/modules/cameraScanner.js @@ -0,0 +1,172 @@ +// cameraScanner.js — camera-based barcode scanning via BarcodeDetector. +// BarcodeDetector is not natively supported on Windows; a WASM polyfill is loaded +// on first use from jsDelivr (which correctly serves the .wasm binary alongside the JS). + +let _detector = null; +let _stream = null; +let _interval = null; +let _running = false; +let _onScan = null; +let _onStop = null; + +const INTERVAL_MS = 175; // WASM detection takes 50-120ms; 175ms avoids main-thread congestion +const FORMATS = ['qr_code', 'code_128', 'code_39', 'ean_13', 'ean_8', 'upc_a', 'data_matrix', 'pdf417']; + +// ── Public API ──────────────────────────────────────────────────────────────── + +export function init(onScan, onStop) { + _onScan = onScan; + _onStop = onStop; + document.getElementById('btn-camera-close')?.addEventListener('click', stop); +} + +export function isActive() { + return _running; +} + +export async function start() { + if (_running) return; + _showOverlay(); + _setStatus('Initializing…'); + try { + await _initDetector(); + _stream = await _acquireCamera(); + const video = document.getElementById('camera-video'); + video.srcObject = _stream; + await video.play(); + _setStatus('Scanning…'); + _running = true; + _startLoop(video); + } catch (err) { + _handleError(err); + } +} + +export async function stop() { + _running = false; + clearInterval(_interval); + _interval = null; + _releaseCamera(); + _hideOverlay(); + _onStop?.(); +} + +// ── Detector init (lazy, singleton) ────────────────────────────────────────── + +async function _initDetector() { + if (_detector) return; + + _setStatus('Loading barcode scanner…'); + + // Try native BarcodeDetector first (works on Android/macOS, NOT Windows) + if ('BarcodeDetector' in window) { + try { + const supported = await BarcodeDetector.getSupportedFormats(); + if (supported.length > 0) { + _detector = new BarcodeDetector({ formats: FORMATS.filter(f => supported.includes(f)) }); + return; + } + } catch { + // Fall through to polyfill + } + } + + // WASM polyfill via jsDelivr — jsDelivr correctly serves the .wasm binary + // alongside the JS module (esm.sh does not host .wasm files) + const { BarcodeDetector: Poly } = await import( + 'https://cdn.jsdelivr.net/npm/barcode-detector@3/+esm' + ); + _detector = new Poly({ formats: FORMATS }); +} + +// ── Camera acquisition ──────────────────────────────────────────────────────── + +async function _acquireCamera() { + const devices = await navigator.mediaDevices.enumerateDevices(); + const inputs = devices.filter(d => d.kind === 'videoinput'); + + if (!inputs.length) { + const err = new Error('No camera found on this device.'); + err.name = 'NoCameraError'; + throw err; + } + + // Surface Pro: enumerateDevices labels are only populated after first permission grant. + // On first run, labels are empty strings → facingMode fallback is used. + // On subsequent runs, labels like "Microsoft Camera Rear" are available. + const rear = inputs.find(d => /rear|back/i.test(d.label)); + + const constraints = rear + ? { video: { deviceId: { exact: rear.deviceId }, width: { ideal: 1280 }, height: { ideal: 720 } } } + : { video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } } }; + + return navigator.mediaDevices.getUserMedia(constraints); +} + +// ── Scan loop ───────────────────────────────────────────────────────────────── + +function _startLoop(video) { + _interval = setInterval(async () => { + if (!_running || video.readyState < video.HAVE_ENOUGH_DATA) return; + try { + const hits = await _detector.detect(video); + if (hits.length) { + const value = hits[0].rawValue; + // Flash detected state briefly so the user sees the scanner found something + const overlay = document.getElementById('camera-overlay'); + overlay?.classList.add('detected'); + _setStatus('Barcode found!'); + await new Promise(r => setTimeout(r, 350)); + await stop(); // Close overlay then fire scan (matches USB wedge UX) + _onScan?.(value); + } + } catch { + // Silently skip frames where detect() throws (video not ready, stream ended, etc.) + } + }, INTERVAL_MS); +} + +// ── Error handling ──────────────────────────────────────────────────────────── + +function _handleError(err) { + _running = false; + clearInterval(_interval); + _interval = null; + _releaseCamera(); + + const msg = + err.name === 'NotAllowedError' ? 'Camera permission denied. Allow camera access and try again.' : + err.name === 'NoCameraError' ? 'No camera found on this device.' : + err.name === 'NotFoundError' ? 'Camera not found. Is it connected?' : + err.name === 'NotReadableError' ? 'Camera is in use by another application.' : + `Scanner error: ${err.message}`; + + _setStatus(msg, true); + // Keep overlay visible on error so the user can read the message; X button still works +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _releaseCamera() { + _stream?.getTracks().forEach(t => t.stop()); + _stream = null; + const video = document.getElementById('camera-video'); + if (video) video.srcObject = null; +} + +function _showOverlay() { + document.getElementById('camera-overlay').hidden = false; +} + +function _hideOverlay() { + const el = document.getElementById('camera-overlay'); + el.classList.remove('detected'); + el.hidden = true; +} + +function _setStatus(msg, isError = false) { + const el = document.getElementById('camera-status'); + if (!el) return; + el.textContent = msg; + el.classList.toggle('error', isError); +} diff --git a/public/modules/clientDashboard.js b/public/modules/clientDashboard.js new file mode 100755 index 0000000..b1dee3c --- /dev/null +++ b/public/modules/clientDashboard.js @@ -0,0 +1,135 @@ +// clientDashboard.js — Quick View summary tables for client role users. +// Shows device fleet at a glance: two tables split by possession status. +import { getCustomerAssets } from '../api/syncro.js'; + +// Lifecycle stages shown per table (only columns with data will render) +const IT_POSSESSION_STAGES = ['Inventory', 'Pre-Deployment', 'For Repair', 'For Upgrade', 'For Parts', 'Decommissioned', 'Disposed of']; +const DEPLOYED_STAGES = ['Active', 'For Repair', 'For Upgrade', 'Decommissioned', 'Disposed of']; + +function getPossessionGroup(asset) { + const p = asset.properties?.['Possession Status']; + if (p === 'In IT Possession') return 'it'; + if (p === 'Deployed' || p === 'In User Possession') return 'deployed'; + return 'it'; // default unmapped assets to IT bucket +} + +function getLifecycle(asset) { + return asset.properties?.['Lifecycle Stage'] ?? 'Unknown'; +} + +function getDeviceType(asset) { + return asset.asset_type || 'Other'; +} + +// Build a count matrix: { [deviceType]: { [lifecycleStage]: count } } +function buildMatrix(assets) { + const matrix = {}; + for (const asset of assets) { + const type = getDeviceType(asset); + const stage = getLifecycle(asset); + if (!matrix[type]) matrix[type] = {}; + matrix[type][stage] = (matrix[type][stage] ?? 0) + 1; + } + return matrix; +} + +// Only include columns that have at least one non-zero value +function activeColumns(matrix, stageCandidates) { + return stageCandidates.filter(stage => + Object.values(matrix).some(row => (row[stage] ?? 0) > 0) + ); +} + +function esc(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>'); +} + +function renderTable({ title, assets, stageOrder, possession, onFilterSelect }) { + if (!assets.length) { + return `

${esc(title)}

No devices in this group.

`; + } + + const matrix = buildMatrix(assets); + const cols = activeColumns(matrix, stageOrder); + const types = Object.keys(matrix).sort(); + + // Column totals + const colTotals = {}; + for (const col of cols) colTotals[col] = 0; + for (const type of types) { + for (const col of cols) { + colTotals[col] += matrix[type][col] ?? 0; + } + } + const grandTotal = Object.values(colTotals).reduce((a, b) => a + b, 0); + + const headerCells = cols.map(c => `${esc(c)}`).join(''); + const totalCells = cols.map(c => { + const n = colTotals[c]; + return `${n > 0 ? n : ''}`; + }).join(''); + + const rows = types.map(type => { + const rowTotal = cols.reduce((s, c) => s + (matrix[type][c] ?? 0), 0); + const cells = cols.map(stage => { + const n = matrix[type][stage] ?? 0; + if (n === 0) return ``; + const payload = encodeURIComponent(JSON.stringify({ lifecycle: [stage], possession })); + return `${n}`; + }).join(''); + return `${esc(type)}${cells}${rowTotal}`; + }).join(''); + + return ` +
+
+

${esc(title)}

+ ${grandTotal} device${grandTotal !== 1 ? 's' : ''} +
+
+ + ${headerCells} + ${rows} + ${totalCells} +
Device TypeTotal
Total${grandTotal}
+
+
`; +} + +export async function renderClientDashboard(container, user, { onFilterSelect } = {}) { + container.innerHTML = '
Loading…
'; + + if (!user?.syncro_customer_id) { + container.innerHTML = '
No company assigned to your account. Contact your administrator.
'; + return; + } + + let assets; + try { + assets = await getCustomerAssets(user.syncro_customer_id); + } catch (err) { + container.innerHTML = `
Failed to load assets: ${esc(err.message)}
`; + return; + } + + const itAssets = assets.filter(a => getPossessionGroup(a) === 'it'); + const deployedAssets = assets.filter(a => getPossessionGroup(a) === 'deployed'); + + const html = + renderTable({ title: 'In IT Possession', assets: itAssets, stageOrder: IT_POSSESSION_STAGES, possession: 'IT', onFilterSelect }) + + renderTable({ title: 'Out in the Field', assets: deployedAssets, stageOrder: DEPLOYED_STAGES, possession: 'Deployed', onFilterSelect }); + + container.innerHTML = html; + + // Wire up click-through filtering + if (onFilterSelect) { + container.querySelectorAll('.qv-cell-link').forEach(el => { + el.addEventListener('click', () => { + try { + const { lifecycle, possession } = JSON.parse(decodeURIComponent(el.dataset.filter)); + onFilterSelect({ lifecycle, possession }); + } catch { /* ignore malformed data */ } + }); + }); + } +} diff --git a/public/modules/labelCenter.js b/public/modules/labelCenter.js new file mode 100755 index 0000000..e40f15c --- /dev/null +++ b/public/modules/labelCenter.js @@ -0,0 +1,1618 @@ +// labelCenter.js — Label Center overlay: cross-device label queue, sheet assignment, and batch print. + +import { buildLabelHTML, renderBarcode, formatPhone } from './labelGen.js'; +import { showToast } from './toast.js'; +import { searchLocal } from './assetBrowser.js'; +import CONFIG from '../config.js'; + +// ── Sheet type configurations ───────────────────────────────────────────────── + +const SHEET_CONFIGS = { + OL875LP: { + label: 'OL875LP \u2014 2.625\u2033 \xd7 1\u2033 (30-up)', + labelW: 2.625, + labelH: 1.0, + cols: 3, + rows: 10, + pageSize: 30, + colOffsets: [0.1875, 2.9375, 5.6875], + rowOffsets: [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5], + }, + OL25LP: { + label: 'OL25LP \u2014 1.75\u2033 \xd7 0.5\u2033 (80-up)', + labelW: 1.75, + labelH: 0.5, + cols: 4, + rows: 20, + pageSize: 80, + colOffsets: [0.328125, 2.359375, 4.390625, 6.421875], + rowOffsets: Array.from({ length: 20 }, (_, i) => 0.5 + i * 0.5), + }, +}; + +let _sheetTypeKey = localStorage.getItem('lc_sheet_type') || 'OL875LP'; +function activeSheet() { return SHEET_CONFIGS[_sheetTypeKey] ?? SHEET_CONFIGS.OL875LP; } + +// ── Module state ────────────────────────────────────────────────────────────── + +function posStart(page) { return (page - 1) * activeSheet().pageSize + 1; } +function posEnd(page) { return page * activeSheet().pageSize; } + +let _queue = []; // Array — synced from server +let _pickerTargetPos = null; // which grid position the picker is open for +let _pageCount = 1; + +function _loadPageCount() { + const maxPos = Math.max(0, + ..._queue.map(i => i.sheet_position ?? 0), + ...[..._usedPositions], + ); + const pagesNeeded = maxPos > 0 ? Math.ceil(maxPos / activeSheet().pageSize) : 1; + const stored = parseInt(localStorage.getItem('lc_page_count') || '1', 10); + _pageCount = Math.max(pagesNeeded, stored, 1); +} + +function _savePageCount() { + localStorage.setItem('lc_page_count', String(_pageCount)); +} + +let _usedPositions = new Set( + JSON.parse(localStorage.getItem('lc_used_positions') || '[]') +); + +function _saveUsedPositions() { + localStorage.setItem('lc_used_positions', JSON.stringify([..._usedPositions])); +} + +// ── Derived helpers ─────────────────────────────────────────────────────────── + +function assignedItems() { return _queue.filter(i => i.sheet_position != null); } +function unassignedItems() { return _queue.filter(i => i.sheet_position == null); } +function itemAtPos(pos) { return _queue.find(i => i.sheet_position === pos) ?? null; } + +// ── Public API ──────────────────────────────────────────────────────────────── + +export function initLabelCenter() { + document.getElementById('btn-label-center')?.addEventListener('click', openLabelCenter); + document.getElementById('lc-close-btn')?.addEventListener('click', closeLabelCenter); + document.getElementById('lc-clear-all-btn')?.addEventListener('click', handleClearAll); + document.getElementById('lc-autofill-btn')?.addEventListener('click', handleAutoFill); + document.getElementById('lc-reset-all-btn')?.addEventListener('click', () => handleResetSheet(false)); + document.getElementById('lc-print-btn')?.addEventListener('click', handlePrintAll); + document.getElementById('lc-picker-cancel')?.addEventListener('click', closePicker); + + document.getElementById('lc-sheet-type')?.addEventListener('change', async e => { + const newKey = e.target.value; + if (newKey === _sheetTypeKey) return; + const hasAssigned = _queue.some(i => i.sheet_position != null); + if (hasAssigned) { + const confirmed = await _showSheetChangeConfirm(newKey); + if (!confirmed) { + e.target.value = _sheetTypeKey; // revert + return; + } + await handleResetSheet(true); + } + _sheetTypeKey = newKey; + localStorage.setItem('lc_sheet_type', newKey); + _usedPositions.clear(); + _saveUsedPositions(); + _pageCount = 1; + _savePageCount(); + renderAllPages(); + }); + + // Close picker on outside click + document.addEventListener('click', e => { + const picker = document.getElementById('lc-picker'); + if (picker && !picker.hidden && !picker.contains(e.target)) { + closePicker(); + } + }, { capture: true }); + + // Close overlay on Escape + document.addEventListener('keydown', e => { + const overlay = document.getElementById('label-center-overlay'); + if (e.key === 'Escape' && overlay && !overlay.hidden) { + const picker = document.getElementById('lc-picker'); + if (picker && !picker.hidden) { + closePicker(); + } else { + closeLabelCenter(); + } + } + }); +} + +export async function addToQueue(asset) { + const customerName = asset.customer?.business_name + ?? asset.customer?.business_then_name + ?? asset.customer?.name + ?? '—'; + const phone = asset.contact?.phone ?? asset.customer?.phone ?? null; + + try { + const res = await fetch('/api/label-queue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + asset_id: asset.id, + asset_name: asset.name, + asset_serial: asset.asset_serial ?? asset.serial ?? null, + customer_name: customerName, + customer_phone: phone, + }), + }); + if (!res.ok) { + const { error } = await res.json().catch(() => ({})); + showToast(error ?? 'Failed to add to queue', 'error'); + return; + } + const { queue } = await res.json(); + _queue = queue; + updateBadgeDOM(queue.length); + + const btn = document.getElementById('action-add-to-queue'); + if (btn) { + btn.classList.add('added'); + btn.innerHTML = `${iconQueuePlus()} Added ✓`; + setTimeout(() => { + btn.classList.remove('added'); + btn.innerHTML = `${iconQueuePlus()} Add to Sheet`; + }, 1800); + } + + showToast(`${esc(asset.name)} added to Label Center`, 'success'); + } catch (err) { + showToast('Failed to add to queue: ' + err.message, 'error'); + } +} + +export async function addToQueueAndOpen(asset) { + await addToQueue(asset); + openLabelCenter(); +} + +export async function refreshBadge() { + try { + const res = await fetch('/api/label-queue', { credentials: 'same-origin' }); + if (!res.ok) return; + const { queue } = await res.json(); + _queue = queue; + updateBadgeDOM(queue.length); + } catch { /* ignore — badge stays hidden */ } +} + +// ── Overlay lifecycle ───────────────────────────────────────────────────────── + +async function openLabelCenter() { + const overlay = document.getElementById('label-center-overlay'); + if (!overlay) return; + overlay.hidden = false; + + const sheetSelect = document.getElementById('lc-sheet-type'); + if (sheetSelect) sheetSelect.value = _sheetTypeKey; + + try { + const res = await fetch('/api/label-queue', { credentials: 'same-origin' }); + if (!res.ok) throw new Error('Failed to load queue'); + const { queue } = await res.json(); + _queue = queue; + updateBadgeDOM(queue.length); + } catch (err) { + showToast('Could not load label queue: ' + err.message, 'error'); + _queue = []; + } + + renderQueuePanel(); + renderAllPages(); +} + +export function closeLabelCenter() { + const overlay = document.getElementById('label-center-overlay'); + if (overlay) overlay.hidden = true; + closePicker(); +} + +// ── Queue panel ─────────────────────────────────────────────────────────────── + +function renderQueuePanel() { + const list = document.getElementById('lc-queue-list'); + const countEl = document.getElementById('lc-queue-count'); + const printBtn = document.getElementById('lc-print-btn'); + + if (countEl) { + countEl.textContent = `${_queue.length} item${_queue.length !== 1 ? 's' : ''} queued`; + } + if (printBtn) { + printBtn.disabled = assignedItems().length === 0; + } + + if (!list) return; + + if (!_queue.length) { + list.innerHTML = `
No labels queued. Tap "Add to Sheet" on any asset card to get started.
`; + return; + } + + list.innerHTML = _queue.map(item => { + const pos = item.sheet_position; + return ` +
+
+
${esc(item.asset_name)}
+
${esc(item.customer_name)}
+ ${pos != null ? `
Position ${pos}
` : ''} +
+ +
`; + }).join(''); + + list.querySelectorAll('[data-remove-id]').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + handleRemoveItem(parseInt(btn.dataset.removeId, 10)); + }); + }); +} + +// ── Sheet grid (multi-page) ──────────────────────────────────────────────── + +function renderAllPages() { + const container = document.getElementById('lc-pages-container'); + if (!container) return; + + _loadPageCount(); + + let html = ''; + for (let p = 1; p <= _pageCount; p++) { + const start = posStart(p); + const end = posEnd(p); + const hasItems = _queue.some(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); + html += ` +
+
+ Page ${p}pos. ${start}–${end} +
+ + + + +
+
+
+
`; + } + + html += ` +`; + + container.innerHTML = html; + + for (let p = 1; p <= _pageCount; p++) { + renderPageGrid(p); + } + + container.querySelectorAll('.lc-page-autofill-btn').forEach(btn => { + btn.addEventListener('click', () => handleAutoFillPage(+btn.dataset.page)); + }); + container.querySelectorAll('.lc-page-print-btn').forEach(btn => { + btn.addEventListener('click', () => handlePrintPage(+btn.dataset.page)); + }); + container.querySelectorAll('.lc-page-reset-btn').forEach(btn => { + btn.addEventListener('click', () => handleResetPage(+btn.dataset.page)); + }); + container.querySelectorAll('.lc-page-remove-btn').forEach(btn => { + btn.addEventListener('click', () => handleRemovePage(+btn.dataset.page)); + }); + document.getElementById('lc-add-page-btn')?.addEventListener('click', () => { + _pageCount++; + _savePageCount(); + renderAllPages(); + }); +} + +function renderPageGrid(page) { + const grid = document.getElementById(`lc-page-grid-${page}`); + if (!grid) return; + + grid.style.gridTemplateColumns = `repeat(${activeSheet().cols}, 1fr)`; + + const start = posStart(page); + const end = posEnd(page); + let html = ''; + const barcodeQueue = []; + + for (let pos = start; pos <= end; pos++) { + const item = itemAtPos(pos); + const localPos = pos - start + 1; + + if (item) { + let labelHTML; + let svgIdForSn = null, serial = null; + + if (_sheetTypeKey === 'OL25LP') { + const sn = item.asset_serial ?? ''; + if (sn) { + const bcPreviewId = `lc-cell-bc-${item.asset_id}-${pos}`; + svgIdForSn = bcPreviewId; + serial = sn; + labelHTML = `
+ +
${esc(sn)}
+
`; + } else { + labelHTML = `
${esc(item.asset_name)}
`; + } + } else { + const assetObj = { + id: item.asset_id, + name: item.asset_name, + asset_serial: item.asset_serial ?? null, + custom_line: item.custom_line ?? null, + customer: { + business_name: item.customer_name, + phone: item.customer_phone ?? null, + }, + }; + const prefix = `lc-cell-${item.asset_id}-${pos}`; + ({ html: labelHTML, svgIdForSn, serial } = buildLabelHTML(assetObj, prefix)); + } + if (svgIdForSn && serial) barcodeQueue.push({ svgIdForSn, serial }); + + html += ` +
+
${labelHTML}
+
${localPos}
+
+ + +
+
`; + } else if (_usedPositions.has(pos)) { + html += ` +
+
${localPos}
+ Used +
`; + } else { + html += ` +
+
${localPos}
+
`; + } + } + + grid.innerHTML = html; + barcodeQueue.forEach(({ svgIdForSn, serial }) => renderBarcode(svgIdForSn, serial)); + + grid.querySelectorAll('.lc-cell').forEach(cell => { + cell.addEventListener('click', () => handleCellClick(parseInt(cell.dataset.pos, 10))); + cell.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleCellClick(parseInt(cell.dataset.pos, 10)); + } + }); + }); + + grid.querySelectorAll('.lc-cell-action--delete').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const pos = parseInt(btn.closest('.lc-cell').dataset.pos, 10); + const item = itemAtPos(pos); + if (item) unassignPosition(item.asset_id); + }); + }); + + grid.querySelectorAll('.lc-cell-action--edit').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const pos = parseInt(btn.closest('.lc-cell').dataset.pos, 10); + const item = itemAtPos(pos); + if (item) _showEditLabelForm(pos, item); + }); + }); +} + +function handleCellClick(pos) { + const item = itemAtPos(pos); + if (item) { + return; // handled by action buttons on hover + } else if (_usedPositions.has(pos)) { + // Toggle: un-mark as used + _usedPositions.delete(pos); + _saveUsedPositions(); + renderAllPages(); + } else { + openPicker(pos); + } +} + +// ── Assignment picker ───────────────────────────────────────────────────────── + +function openPicker(pos) { + _pickerTargetPos = pos; + const picker = document.getElementById('lc-picker'); + const posLabel = document.getElementById('lc-picker-pos-label'); + const pickerList = document.getElementById('lc-picker-list'); + if (!picker || !pickerList) return; + + if (posLabel) posLabel.textContent = pos; + + const unassigned = unassignedItems(); + + const itemsHTML = unassigned.length + ? unassigned.map(item => ` +`).join('') + : `
No unassigned labels
`; + + pickerList.innerHTML = itemsHTML + ` +`; + + pickerList.querySelectorAll('[data-pick-id]').forEach(btn => { + btn.addEventListener('click', () => { + assignPosition(parseInt(btn.dataset.pickId, 10), _pickerTargetPos); + closePicker(); + }); + }); + + document.getElementById('lc-picker-mark-used-btn')?.addEventListener('click', () => { + _usedPositions.add(_pickerTargetPos); + _saveUsedPositions(); + closePicker(); + renderAllPages(); + }); + + document.getElementById('lc-picker-custom-label-btn')?.addEventListener('click', () => { + const targetPos = _pickerTargetPos; + closePicker(); + _showCustomLabelForm(targetPos); + }); + + // Position the picker near the clicked cell + const cell = document.querySelector(`.lc-cell[data-pos="${pos}"]`); + if (cell) { + const rect = cell.getBoundingClientRect(); + const pickerW = 290; + const pickerH = 480; + let left = rect.left + rect.width / 2 - pickerW / 2; + let top = rect.bottom + 8; + + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); + top = Math.max(8, Math.min(top, window.innerHeight - pickerH - 8)); + + picker.style.left = left + 'px'; + picker.style.top = top + 'px'; + } + + picker.hidden = false; + _setupPickerSearch(pos); +} + +function _setupPickerSearch(pos) { + const input = document.getElementById('lc-picker-search'); + const results = document.getElementById('lc-picker-search-results'); + if (!input || !results) return; + + input.value = ''; + results.hidden = true; + results.innerHTML = ''; + + let _timer = null; + let _activeIdx = -1; + let _items = []; + let _remoteReq = 0; // cancel stale remote results + + function _render(items) { + _items = items; + _activeIdx = -1; + if (!items.length) { results.hidden = true; return; } + + results.innerHTML = items.map((item, i) => { + const meta = [item.customerName, item.serial].filter(Boolean).join(' · '); + return ``; + }).join(''); + results.hidden = false; + + results.querySelectorAll('[data-idx]').forEach(btn => { + btn.addEventListener('click', () => _pick(_items[+btn.dataset.idx])); + }); + } + + function _updateActive() { + results.querySelectorAll('.lc-picker-search-result').forEach((btn, i) => { + btn.classList.toggle('active', i === _activeIdx); + if (i === _activeIdx) btn.scrollIntoView({ block: 'nearest' }); + }); + } + + async function _search(q) { + const req = ++_remoteReq; + + // Immediate local results + const local = searchLocal(q).map(r => ({ ...r })); + _render(local); + + // Show spinner row while remote is in flight + if (local.length === 0) { + results.innerHTML = `
Searching…
`; + results.hidden = false; + } + + try { + const res = await fetch(`/api/asset/search?query=${encodeURIComponent(q)}`); + if (req !== _remoteReq) return; // stale + if (!res.ok) return; + const data = await res.json(); + const localIds = new Set(local.map(r => r.asset?.id)); + const remote = (data.assets ?? []).map(a => ({ + asset: a, + serial: a.asset_serial ?? a.serial ?? null, + customerName: a.customer?.business_name ?? a.customer?.business_then_name ?? a.customer?.name ?? null, + })).filter(r => !localIds.has(r.asset?.id)); + _render([...local, ...remote]); + } catch { /* leave local results */ } + } + + function _pick(item) { + if (!item?.asset) return; + input.value = ''; + results.hidden = true; + addAndAssign(item.asset, item.customerName, pos); + closePicker(); + } + + input.addEventListener('input', () => { + clearTimeout(_timer); + const q = input.value.trim(); + if (q.length < 2) { results.hidden = true; _items = []; return; } + _timer = setTimeout(() => _search(q), 220); + }); + + input.addEventListener('keydown', e => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + _activeIdx = Math.min(_activeIdx + 1, _items.length - 1); + _updateActive(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + _activeIdx = Math.max(_activeIdx - 1, 0); + _updateActive(); + } else if (e.key === 'Enter' && _activeIdx >= 0) { + e.preventDefault(); + _pick(_items[_activeIdx]); + } else if (e.key === 'Escape') { + e.stopPropagation(); + closePicker(); + } + }); + + // Focus search on open + setTimeout(() => input.focus(), 40); +} + +function _showCustomLabelForm(pos) { + // Remove any existing form + document.getElementById('lc-custom-label-backdrop')?.remove(); + + const backdrop = document.createElement('div'); + backdrop.id = 'lc-custom-label-backdrop'; + backdrop.className = 'lc-print-dialog-backdrop'; + + backdrop.innerHTML = ` +`; + + document.body.appendChild(backdrop); + + const deviceInput = document.getElementById('clf-device'); + const ownerInput = document.getElementById('clf-owner'); + const phoneInput = document.getElementById('clf-phone'); + const serialInput = document.getElementById('clf-serial'); + const customInput = document.getElementById('clf-custom'); + const deviceErr = document.getElementById('clf-device-err'); + const ownerErr = document.getElementById('clf-owner-err'); + + setTimeout(() => deviceInput?.focus(), 40); + + backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }); + document.getElementById('clf-cancel-btn')?.addEventListener('click', () => backdrop.remove()); + + document.getElementById('clf-submit-btn')?.addEventListener('click', async () => { + const deviceName = deviceInput.value.trim(); + const ownerName = ownerInput.value.trim(); + const phone = phoneInput.value.trim() || null; + const serial = serialInput.value.trim() || null; + const customLine = customInput.value.trim() || null; + + let valid = true; + if (!deviceName) { deviceErr.hidden = false; valid = false; } else { deviceErr.hidden = true; } + if (!ownerName) { ownerErr.hidden = false; valid = false; } else { ownerErr.hidden = true; } + if (!valid) return; + + const fakeAsset = { + id: -Date.now(), + name: deviceName, + asset_serial: serial, + custom_line: customLine, + customer: { business_name: ownerName, phone }, + }; + + backdrop.remove(); + await addAndAssign(fakeAsset, ownerName, pos); + }); +} + +async function updateLabelFields(assetId, { name, serial, customerName, phone, customLine }) { + const res = await fetch(`/api/label-queue/${assetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ asset_name: name, asset_serial: serial ?? null, customer_name: customerName, customer_phone: phone ?? null, custom_line: customLine ?? null }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? 'Update failed'); + _queue = data.queue; + renderAllPages(); +} + +function _showEditLabelForm(pos, item) { + document.getElementById('lc-edit-label-backdrop')?.remove(); + + const backdrop = document.createElement('div'); + backdrop.id = 'lc-edit-label-backdrop'; + backdrop.className = 'lc-print-dialog-backdrop'; + + backdrop.innerHTML = ` +`; + + document.body.appendChild(backdrop); + + const deviceInput = document.getElementById('elf-device'); + const ownerInput = document.getElementById('elf-owner'); + const phoneInput = document.getElementById('elf-phone'); + const serialInput = document.getElementById('elf-serial'); + const customInput = document.getElementById('elf-custom'); + const deviceErr = document.getElementById('elf-device-err'); + const ownerErr = document.getElementById('elf-owner-err'); + + setTimeout(() => deviceInput?.focus(), 40); + + backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }); + document.getElementById('elf-cancel-btn')?.addEventListener('click', () => backdrop.remove()); + + document.getElementById('elf-submit-btn')?.addEventListener('click', async () => { + const deviceName = deviceInput.value.trim(); + const ownerName = ownerInput.value.trim(); + const phone = phoneInput.value.trim() || null; + const serial = serialInput.value.trim() || null; + const customLine = customInput.value.trim() || null; + + let valid = true; + if (!deviceName) { deviceErr.hidden = false; valid = false; } else { deviceErr.hidden = true; } + if (!ownerName) { ownerErr.hidden = false; valid = false; } else { ownerErr.hidden = true; } + if (!valid) return; + + backdrop.remove(); + await updateLabelFields(item.asset_id, { name: deviceName, serial, customerName: ownerName, phone, customLine }); + }); +} + +function closePicker() { + _pickerTargetPos = null; + const picker = document.getElementById('lc-picker'); + if (picker) picker.hidden = true; + const input = document.getElementById('lc-picker-search'); + const results = document.getElementById('lc-picker-search-results'); + if (input) input.value = ''; + if (results) { results.hidden = true; results.innerHTML = ''; } +} + +// ── API mutations (all re-render after response) ────────────────────────────── + +// Add an asset directly to the queue and immediately assign it to a sheet position. +// Used by the picker's inline search — bypasses the normal "add to queue" flow. +async function addAndAssign(asset, customerName, pos) { + const name = customerName ?? asset.customer?.business_name ?? asset.customer?.business_then_name ?? asset.customer?.name ?? '—'; + const phone = asset.contact?.phone ?? asset.customer?.phone ?? null; + + try { + // 1. Add (or update) the queue entry + const addRes = await fetch('/api/label-queue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + asset_id: asset.id, + asset_name: asset.name, + asset_serial: asset.asset_serial ?? asset.serial ?? null, + customer_name: name, + customer_phone: phone, + custom_line: asset.custom_line ?? null, + }), + }); + if (!addRes.ok) { + const { error } = await addRes.json().catch(() => ({})); + showToast(error ?? 'Failed to add asset', 'error'); + return; + } + const { queue: q1 } = await addRes.json(); + _queue = q1; + + // 2. Assign to the target position + const patchRes = await fetch(`/api/label-queue/${asset.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sheet_position: pos }), + }); + if (!patchRes.ok) { + const { error } = await patchRes.json().catch(() => ({})); + showToast(error ?? 'Failed to assign position', 'error'); + return; + } + const { queue: q2 } = await patchRes.json(); + _queue = q2; + updateBadgeDOM(_queue.length); + renderQueuePanel(); + renderAllPages(); + } catch (err) { + showToast('Error: ' + err.message, 'error'); + } +} + +async function assignPosition(assetId, pos) { + try { + const res = await fetch(`/api/label-queue/${assetId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sheet_position: pos }), + }); + if (!res.ok) { + const { error } = await res.json().catch(() => ({})); + showToast(error ?? 'Failed to assign position', 'error'); + return; + } + const { queue } = await res.json(); + _queue = queue; + renderQueuePanel(); + renderAllPages(); + } catch (err) { + showToast('Error assigning position: ' + err.message, 'error'); + } +} + +async function unassignPosition(assetId) { + try { + const res = await fetch(`/api/label-queue/${assetId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sheet_position: null }), + }); + if (!res.ok) { + showToast('Failed to unassign position', 'error'); + return; + } + const { queue } = await res.json(); + _queue = queue; + renderQueuePanel(); + renderAllPages(); + } catch (err) { + showToast('Error unassigning position: ' + err.message, 'error'); + } +} + +async function handleRemoveItem(assetId) { + try { + const res = await fetch(`/api/label-queue/${assetId}`, { method: 'DELETE' }); + if (!res.ok) { + showToast('Failed to remove item', 'error'); + return; + } + const { queue } = await res.json(); + _queue = queue; + updateBadgeDOM(queue.length); + renderQueuePanel(); + renderAllPages(); + } catch (err) { + showToast('Error removing item: ' + err.message, 'error'); + } +} + +async function handleClearAll() { + if (!_queue.length) return; + try { + const res = await fetch('/api/label-queue', { method: 'DELETE' }); + if (!res.ok) { showToast('Failed to clear queue', 'error'); return; } + const { queue } = await res.json(); + _queue = queue; + updateBadgeDOM(0); + closePicker(); + renderQueuePanel(); + renderAllPages(); + } catch (err) { + showToast('Error clearing queue: ' + err.message, 'error'); + } +} + +// ── Per-page auto-fill ──────────────────────────────────────────────────────── + +async function handleAutoFillPage(page) { + const unassigned = unassignedItems(); + if (!unassigned.length) { showToast('No unassigned labels to auto-fill', 'info'); return; } + + const start = posStart(page); + const end = posEnd(page); + const emptyPositions = []; + for (let pos = start; pos <= end && emptyPositions.length < unassigned.length; pos++) { + if (!itemAtPos(pos) && !_usedPositions.has(pos)) emptyPositions.push(pos); + } + + if (!emptyPositions.length) { showToast(`Page ${page} is full`, 'info'); return; } + + try { + const results = await Promise.all(emptyPositions.map((pos, i) => { + const item = unassigned[i]; + if (!item) return Promise.resolve(null); + return fetch(`/api/label-queue/${item.asset_id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sheet_position: pos }), + }).then(r => r.json()); + })); + const last = results.filter(Boolean).pop(); + if (last?.queue) { _queue = last.queue; } + else { const r = await fetch('/api/label-queue'); const { queue } = await r.json(); _queue = queue; } + renderQueuePanel(); + renderAllPages(); + } catch (err) { + showToast('Auto-fill error: ' + err.message, 'error'); + } +} + +// ── Remove page ─────────────────────────────────────────────────────────────── + +async function handleRemovePage(page) { + if (_pageCount <= 1) { showToast('Cannot remove the only page', 'info'); return; } + const start = posStart(page); + const end = posEnd(page); + const pageItems = _queue.filter(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); + + if (pageItems.length === 0) { + await _doRemovePage(page, 'unassign'); + } else { + _showRemovePageDialog(page, pageItems); + } +} + +function _showRemovePageDialog(page, pageItems) { + const n = pageItems.length; + const backdrop = document.createElement('div'); + backdrop.className = 'lc-print-dialog-backdrop'; + backdrop.innerHTML = ` +`; + document.body.appendChild(backdrop); + const dismiss = () => backdrop.remove(); + backdrop.addEventListener('click', e => { if (e.target === backdrop) dismiss(); }); + document.getElementById('lc-rp-move')?.addEventListener('click', async () => { dismiss(); await _doRemovePage(page, 'move'); }); + document.getElementById('lc-rp-unassign')?.addEventListener('click', async () => { dismiss(); await _doRemovePage(page, 'unassign'); }); + document.getElementById('lc-rp-cancel')?.addEventListener('click', dismiss); +} + +async function _doRemovePage(page, action) { + const start = posStart(page); + const end = posEnd(page); + const pageItems = _queue.filter(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); + + // Record the local positions (1-30) of each displaced item so we can try to preserve them + const localPosMap = new Map(pageItems.map(i => [i.asset_id, i.sheet_position - start + 1])); + + try { + // Step 1: unassign all items on this page + if (pageItems.length > 0) { + await Promise.all(pageItems.map(item => + fetch(`/api/label-queue/${item.asset_id}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sheet_position: null }), + }) + )); + } + + // Step 2: shift items on pages above this one down by activeSheet().pageSize (sequential, ascending) + const r0 = await fetch('/api/label-queue'); + const { queue: q0 } = await r0.json(); + const aboveItems = q0 + .filter(i => i.sheet_position != null && i.sheet_position > end) + .sort((a, b) => a.sheet_position - b.sheet_position); + for (const item of aboveItems) { + await fetch(`/api/label-queue/${item.asset_id}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sheet_position: item.sheet_position - activeSheet().pageSize }), + }); + } + + // Step 3: shift used positions in localStorage + const newUsed = new Set(); + for (const pos of _usedPositions) { + if (pos < start) newUsed.add(pos); + else if (pos > end) newUsed.add(pos - activeSheet().pageSize); + } + _usedPositions = newUsed; + _saveUsedPositions(); + + // Step 4: decrement page count + _pageCount--; + _savePageCount(); + + // Step 5: re-fetch current queue state + const r1 = await fetch('/api/label-queue'); + const { queue: q1 } = await r1.json(); + _queue = q1; + + // Step 6: if 'move', place displaced items preserving local position where possible + if (action === 'move' && pageItems.length > 0) { + const unassigned = _queue.filter(i => i.sheet_position == null); + const displaced = pageItems.map(orig => unassigned.find(u => u.asset_id === orig.asset_id)).filter(Boolean); + + if (displaced.length > 0) { + const maxPos = _pageCount * activeSheet().pageSize; + const taken = () => new Set(_queue.map(i => i.sheet_position).filter(Boolean)); + + for (const item of displaced) { + const localPos = localPosMap.get(item.asset_id); // 1-30 within original page + let targetPos = null; + + // Try each remaining page at the same local slot + for (let p = 1; p <= _pageCount; p++) { + const candidate = posStart(p) + localPos - 1; + if (candidate <= posEnd(p) && !taken().has(candidate) && !_usedPositions.has(candidate)) { + targetPos = candidate; + break; + } + } + + // Fallback: first available position anywhere + if (!targetPos) { + for (let pos = 1; pos <= maxPos; pos++) { + if (!taken().has(pos) && !_usedPositions.has(pos)) { targetPos = pos; break; } + } + } + + if (targetPos) { + const res = await fetch(`/api/label-queue/${item.asset_id}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sheet_position: targetPos }), + }); + const data = await res.json(); + if (data?.queue) _queue = data.queue; // keep queue fresh for taken() calls + } + } + + const r2 = await fetch('/api/label-queue'); + const { queue: q2 } = await r2.json(); + _queue = q2; + } + } + + renderQueuePanel(); + renderAllPages(); + showToast(`Page ${page} removed`, 'success'); + } catch (err) { + showToast('Error removing page: ' + err.message, 'error'); + } +} + +async function handleAutoFill() { + const unassigned = unassignedItems(); + if (!unassigned.length) { + showToast('No unassigned labels to auto-fill', 'info'); + return; + } + + // Find empty positions across all pages in order, skipping used ones + const maxPos = _pageCount * activeSheet().pageSize; + const emptyPositions = []; + for (let pos = 1; pos <= maxPos && emptyPositions.length < unassigned.length; pos++) { + if (!itemAtPos(pos) && !_usedPositions.has(pos)) emptyPositions.push(pos); + } + + if (!emptyPositions.length) { + showToast('Sheet is full', 'info'); + return; + } + + // Send all PATCH requests, then apply the last response state + try { + const promises = emptyPositions.map((pos, i) => { + const item = unassigned[i]; + if (!item) return Promise.resolve(null); + return fetch(`/api/label-queue/${item.asset_id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sheet_position: pos }), + }).then(r => r.json()); + }); + + const results = await Promise.all(promises); + // Use the last successful queue state + const last = results.filter(Boolean).pop(); + if (last?.queue) { + _queue = last.queue; + } else { + // Fallback: re-fetch + const r = await fetch('/api/label-queue'); + const { queue } = await r.json(); + _queue = queue; + } + renderQueuePanel(); + renderAllPages(); + } catch (err) { + showToast('Auto-fill error: ' + err.message, 'error'); + } +} + +async function handleResetPage(page) { + const start = posStart(page); + const end = posEnd(page); + const assigned = _queue.filter(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); + if (!assigned.length) { showToast(`Page ${page} is already empty`, 'info'); return; } + + try { + const results = await Promise.all(assigned.map(item => + fetch(`/api/label-queue/${item.asset_id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sheet_position: null }), + }).then(r => r.json()) + )); + const last = results.filter(Boolean).pop(); + if (last?.queue) { + _queue = last.queue; + } else { + const r = await fetch('/api/label-queue'); + const { queue } = await r.json(); + _queue = queue; + } + renderQueuePanel(); + renderAllPages(); + showToast(`Page ${page} reset`, 'success'); + } catch (err) { + showToast('Error resetting page: ' + err.message, 'error'); + } +} + +async function handleResetSheet(silent = false) { + const assigned = assignedItems(); + if (!assigned.length) { + if (!silent) showToast('Sheet is already empty', 'info'); + return; + } + if (!silent && !confirm('Reset the sheet? All label positions will be cleared. Labels will stay in the queue.')) return; + + try { + const res = await fetch('/api/label-queue', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reset_sheet_only: true }), + }); + if (!res.ok) { showToast('Failed to reset sheet', 'error'); return; } + const { queue } = await res.json(); + _queue = queue; + _usedPositions.clear(); + _saveUsedPositions(); + _pageCount = 1; + _savePageCount(); + closePicker(); + renderQueuePanel(); + renderAllPages(); + showToast('Sheet reset — labels are still queued', 'success'); + } catch (err) { + showToast('Error resetting sheet: ' + err.message, 'error'); + } +} + +// ── Print sheet ─────────────────────────────────────────────────────────────── + +function _openPrintWindow(name, items) { + const win = window.open('', name, + 'width=960,height=800,menubar=no,toolbar=no,location=no,status=no,scrollbars=yes'); + if (!win) { alert('Popup blocked — please allow popups for this site.'); return null; } + win.document.write(items); + win.document.close(); + return win; +} + +function _watchPrintWindow(win, onClose) { + let count = 0; + const poll = setInterval(() => { + if (win.closed || ++count >= 180) { + clearInterval(poll); + if (win.closed) onClose(); + } + }, 1000); +} + +function handlePrintPage(page) { + const start = posStart(page); + const end = posEnd(page); + const assigned = _queue.filter(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); + if (!assigned.length) { showToast('No labels on this page', 'info'); return; } + + // Normalize positions to 1–30 within this page for the print layout + const normalized = assigned.map(i => ({ ...i, sheet_position: i.sheet_position - start + 1 })); + const win = _openPrintWindow(`lc_print_p${page}`, buildSheetHTMLMulti(normalized)); + if (win) _watchPrintWindow(win, () => _showPostPrintDialog(page)); +} + +function handlePrintAll() { + const assigned = assignedItems(); + if (!assigned.length) { showToast('No labels assigned to the sheet yet', 'info'); return; } + + // Build one HTML document with all pages, each as a separate .page div + const pageGroups = []; + for (let p = 1; p <= _pageCount; p++) { + const start = posStart(p); + const end = posEnd(p); + const items = assigned + .filter(i => i.sheet_position >= start && i.sheet_position <= end) + .map(i => ({ ...i, sheet_position: i.sheet_position - start + 1 })); + if (items.length) pageGroups.push({ page: p, items }); + } + + const win = _openPrintWindow('lc_print_all', buildAllPagesHTML(pageGroups)); + if (win) _watchPrintWindow(win, () => _showPostPrintDialog(null)); +} + +function _showSheetChangeConfirm(newKey) { + return new Promise(resolve => { + const newLabel = SHEET_CONFIGS[newKey]?.label ?? newKey; + const backdrop = document.createElement('div'); + backdrop.className = 'lc-print-dialog-backdrop'; + backdrop.innerHTML = ` +`; + document.body.appendChild(backdrop); + const dismiss = ok => { backdrop.remove(); resolve(ok); }; + backdrop.querySelector('#lc-sc-confirm').addEventListener('click', () => dismiss(true)); + backdrop.querySelector('#lc-sc-cancel').addEventListener('click', () => dismiss(false)); + backdrop.addEventListener('click', e => { if (e.target === backdrop) dismiss(false); }); + }); +} + +function _showPostPrintDialog(page) { + const isAll = page === null; + const title = isAll ? 'All pages printed?' : `Page ${page} printed?`; + const sheetLabel = isAll ? 'Clear all sheet layouts' : `Clear page ${page} layout`; + const sheetDesc = isAll + ? 'Remove all position assignments — labels stay in the queue' + : `Remove position assignments for page ${page} — labels stay in the queue`; + + const backdrop = document.createElement('div'); + backdrop.className = 'lc-print-dialog-backdrop'; + backdrop.innerHTML = ` +`; + + document.body.appendChild(backdrop); + const dismiss = () => backdrop.remove(); + backdrop.addEventListener('click', e => { if (e.target === backdrop) dismiss(); }); + + document.getElementById('lc-pd-nothing')?.addEventListener('click', dismiss); + + document.getElementById('lc-pd-clear-sheet')?.addEventListener('click', async () => { + dismiss(); + if (isAll) { + await handleResetSheet(true); + } else { + await handleResetPage(page); + } + }); + + document.getElementById('lc-pd-clear-queue')?.addEventListener('click', async () => { + dismiss(); + await handleClearAll(); + }); +} + +function buildAllPagesHTML(pageGroups) { + // Build the inner label HTML for each page group, then wrap in a single print document + // Each group's items are already normalized to positions 1–30 + + const allBarcodes = []; + const pageDivs = []; + + for (const { page, items } of pageGroups) { + const labels = _buildLabelData(items, page); + allBarcodes.push(...labels.filter(l => l.hasSn).map(l => [l.bcId, l.serial])); + const isFirst = pageDivs.length === 0; + pageDivs.push(`
\n${labels.map(l => l.slDiv).join('\n')}\n
`); + } + + return _buildPrintDocument(pageDivs.join('\n'), JSON.stringify(allBarcodes), + `${pageGroups.reduce((n, g) => n + g.items.length, 0)} label(s) across ${pageGroups.length} page(s)`); +} + +function buildSheetHTMLMulti(items) { + // items: normalized to positions 1–30 + const labels = _buildLabelData(items, 1); + const barcodeArray = JSON.stringify(labels.filter(l => l.hasSn).map(l => [l.bcId, l.serial])); + const pageDiv = `
\n${labels.map(l => l.slDiv).join('\n')}\n
`; + return _buildPrintDocument(pageDiv, barcodeArray, + `${items.length} label${items.length !== 1 ? 's' : ''}`); +} + +function _buildLabelData(items, pageNum) { + const sheet = activeSheet(); + return items.map(item => { + const colIdx = (item.sheet_position - 1) % sheet.cols; + const rowIdx = Math.floor((item.sheet_position - 1) / sheet.cols); + const leftIn = sheet.colOffsets[colIdx]; + const topIn = sheet.rowOffsets[rowIdx]; + const bcId = `sbc-${item.asset_id}-${pageNum}-${item.sheet_position}`; + + if (_sheetTypeKey === 'OL25LP') { + return _buildOL25LPLabelData(item, leftIn, topIn, bcId); + } + + const name = item.asset_name ?? 'Unknown Asset'; + const serial = item.asset_serial ?? ''; + const hasSn = serial.length > 0; + const customerName = item.customer_name ?? ''; + const customerPhone = formatPhone(item.customer_phone ?? ''); + const customLine = item.custom_line ?? ''; + + const snBarcode = hasSn + ? `
barcode
SN: ${esc(serial)}
` + : ''; + + const slDiv = `
+
+
+ + deRenzy Business Technologies +
+ (413) 739-4706 +
+
+
+
${esc(name)}
+ ${customerName ? `
${esc(customerName)}
` : ''} + ${customerPhone ? `
${esc(customerPhone)}
` : ''} + ${customLine ? `
${esc(customLine)}
` : ''} +
+ ${snBarcode} +
+
`; + + return { slDiv, hasSn, serial, bcId }; + }); +} + +function _buildOL25LPLabelData(item, leftIn, topIn, bcId) { + const serial = item.asset_serial ?? ''; + const hasSn = serial.length > 0; + + const slDiv = `
+
+ ${hasSn ? `` : ''} + ${hasSn ? `
${esc(serial)}
` : ''} +
+
`; + + return { slDiv, hasSn, serial, bcId }; +} + +function _buildPrintDocument(pagesHTML, barcodeArrayJSON, titleSuffix) { + return ` + + + + Label Sheet \u2014 ${titleSuffix} +