Initial commit — asset browser web app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
a558804026
49 changed files with 13976 additions and 0 deletions
9
.env.example
Executable file
9
.env.example
Executable file
|
|
@ -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
|
||||
4
.gitignore
vendored
Executable file
4
.gitignore
vendored
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
125
auth/admin.js
Executable file
125
auth/admin.js
Executable file
|
|
@ -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=<ms> — 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;
|
||||
110
auth/db.js
Executable file
110
auth/db.js
Executable file
|
|
@ -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 };
|
||||
33
auth/middleware.js
Executable file
33
auth/middleware.js
Executable file
|
|
@ -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 };
|
||||
62
auth/routes.js
Executable file
62
auth/routes.js
Executable file
|
|
@ -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;
|
||||
65
auth/sessionStore.js
Executable file
65
auth/sessionStore.js
Executable file
|
|
@ -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 };
|
||||
18
ecosystem.config.js
Executable file
18
ecosystem.config.js
Executable file
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
1354
package-lock.json
generated
Executable file
1354
package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load diff
21
package.json
Executable file
21
package.json
Executable file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
46
public/403.html
Executable file
46
public/403.html
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Access Denied — deRenzy BT Asset Browser</title>
|
||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/styles/variables.css">
|
||||
<link rel="stylesheet" href="/styles/login.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
|
||||
<div class="login-header">
|
||||
<img src="/assets/logo-swirl.png" alt="deRenzy BT" class="login-logo">
|
||||
<div>
|
||||
<div class="login-title">Asset Browser</div>
|
||||
<div class="login-sub">deRenzy Business Technologies</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center; padding: 1rem 0 0.5rem;">
|
||||
<div style="font-size:2.5rem; margin-bottom:0.5rem;">🚫</div>
|
||||
<div style="font-size:1.1rem; font-weight:600; color:var(--gray-900); margin-bottom:0.5rem;">Access Denied</div>
|
||||
<div style="font-size:0.9rem; color:var(--gray-500); margin-bottom:1.5rem;">
|
||||
You don't have permission to view that page.
|
||||
</div>
|
||||
<a href="/" style="
|
||||
display:inline-block;
|
||||
padding:0.55rem 1.25rem;
|
||||
background:var(--accent, #3b82f6);
|
||||
color:#fff;
|
||||
border-radius:6px;
|
||||
text-decoration:none;
|
||||
font-size:0.875rem;
|
||||
font-weight:500;
|
||||
">← Back to App</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
750
public/admin/admin.js
Executable file
750
public/admin/admin.js
Executable file
|
|
@ -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 = '<option value="">— None (internal user) —</option>';
|
||||
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 =
|
||||
`<tr><td colspan="7" class="empty-state" style="color:var(--red);">Failed to load users: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<option value="">All Companies</option>';
|
||||
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 = `<tr><td colspan="7" class="empty-state">${hasFilter ? 'No users match the current filters.' : 'No users found.'}</td></tr>`;
|
||||
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(`<tr class="group-row"><td colspan="7">${esc(labelOf(key))}</td></tr>`);
|
||||
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
|
||||
? `<button class="btn btn-danger btn-sm" data-action="toggle" data-id="${u.id}" data-activate="false" ${isSelf ? 'disabled title="Cannot deactivate your own account"' : ''}>Deactivate</button>`
|
||||
: `<button class="btn btn-ghost btn-sm" data-action="toggle" data-id="${u.id}" data-activate="true">Activate</button>`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="td-name">${esc(u.name)}${isSelf ? ' <span style="font-size:.72rem;color:var(--gray-400);font-weight:400;">(you)</span>' : ''}</td>
|
||||
<td class="td-username">${esc(u.username)}</td>
|
||||
<td style="color:var(--gray-600);font-size:.85rem;">${u.company ? esc(u.company) : '<span style="color:var(--gray-300);">—</span>'}</td>
|
||||
<td><span class="role-badge" data-role="${esc(u.role)}">${esc(u.role)}</span></td>
|
||||
<td><span class="status-badge ${activeClass}"><span class="status-dot"></span>${activeLabel}</span></td>
|
||||
<td style="color:var(--gray-500);font-size:.82rem;">${created}</td>
|
||||
<td class="td-actions">
|
||||
<button class="btn btn-ghost btn-sm" data-action="edit" data-id="${u.id}">Edit</button>
|
||||
${toggleBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ── 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} <strong>${esc(u.name)}</strong> (${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 = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin .7s linear infinite">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
1008
public/admin/index.html
Executable file
1008
public/admin/index.html
Executable file
File diff suppressed because it is too large
Load diff
72
public/api/syncro.js
Executable file
72
public/api/syncro.js
Executable file
|
|
@ -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 ?? [];
|
||||
}
|
||||
59
public/api/utils.js
Executable file
59
public/api/utils.js
Executable file
|
|
@ -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;
|
||||
}
|
||||
603
public/app.js
Executable file
603
public/app.js
Executable file
|
|
@ -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 = `
|
||||
<div class="search-results-wrap">
|
||||
<h3>Multiple assets match "${esc(query)}" — select one:</h3>
|
||||
<div class="search-result-list">
|
||||
${assets.map(a => `
|
||||
<div class="search-result-item" data-asset-id="${a.id}">
|
||||
<div>
|
||||
<div class="search-result-name">${esc(a.name)}</div>
|
||||
<div class="search-result-meta">
|
||||
${esc(a.asset_type ?? '')}
|
||||
${a.serial ? ` · SN: ${esc(a.serial)}` : ''}
|
||||
${a.customer?.name ? ` · ${esc(a.customer.name)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--gray-400)" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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 `
|
||||
<div class="error-card">
|
||||
<div class="error-icon">⚠</div>
|
||||
<h3>${esc(title)}</h3>
|
||||
<p>${esc(message)}</p>
|
||||
<button class="btn btn-ghost" id="err-retry-search">Try Manual Search</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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 = `
|
||||
<div style="background:#1a3565;color:#fff;padding:16px 20px;display:flex;align-items:center;gap:12px;border-bottom:3px solid #C4622A;">
|
||||
<img src="/assets/logo-swirl.png" style="width:36px;height:36px;border-radius:50%;background:#fff;padding:2px;flex-shrink:0;" alt="">
|
||||
<div>
|
||||
<div style="font-size:.95rem;font-weight:700;line-height:1.2;">Asset Browser</div>
|
||||
<div style="font-size:.65rem;color:rgba(255,255,255,.55);text-transform:uppercase;letter-spacing:.05em;margin-top:2px;">deRenzy Business Technologies</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:24px 28px;text-align:center;">
|
||||
<div style="margin-bottom:14px;">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#1a3565" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="font-size:1rem;font-weight:700;color:#1a3565;margin-bottom:6px;">Server Restarted</div>
|
||||
<div style="font-size:.88rem;color:#6b7280;margin-bottom:20px;">Reloading in <strong id="rc-seconds">5</strong> second(s)…</div>
|
||||
<button id="rc-reload-now" style="background:#C4622A;color:#fff;border:none;border-radius:6px;padding:8px 20px;font-size:.88rem;font-weight:600;cursor:pointer;font-family:inherit;">Reload now</button>
|
||||
</div>`;
|
||||
|
||||
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,'<').replace(/>/g,'>');
|
||||
}
|
||||
BIN
public/assets/apple-touch-icon.png
Executable file
BIN
public/assets/apple-touch-icon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
public/assets/favicon-192.png
Executable file
BIN
public/assets/favicon-192.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
public/assets/favicon.ico
Executable file
BIN
public/assets/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/assets/logo-full.png
Executable file
BIN
public/assets/logo-full.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
BIN
public/assets/logo-swirl.png
Executable file
BIN
public/assets/logo-swirl.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/assets/logo-text.png
Executable file
BIN
public/assets/logo-text.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
15
public/config.js
Executable file
15
public/config.js
Executable file
|
|
@ -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;
|
||||
480
public/index.html
Executable file
480
public/index.html
Executable file
|
|
@ -0,0 +1,480 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Asset Browser — deRenzy BT</title>
|
||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/assets/favicon-192.png" type="image/png" sizes="192x192">
|
||||
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/styles/variables.css">
|
||||
<link rel="stylesheet" href="/styles/main.css">
|
||||
<link rel="stylesheet" href="/styles/card.css">
|
||||
<link rel="stylesheet" href="/styles/sidebar.css">
|
||||
<link rel="stylesheet" href="/styles/label.css">
|
||||
<link rel="stylesheet" href="/styles/labelCenter.css">
|
||||
<!-- JsBarcode vendored for offline use -->
|
||||
<script src="/vendor/JsBarcode.all.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<header id="app-header">
|
||||
|
||||
<div class="header-left">
|
||||
<a href="https://derenzy.com" target="_blank" rel="noopener noreferrer" class="header-logo-link">
|
||||
<img src="/assets/logo-swirl.png" alt="deRenzy BT" class="header-logo">
|
||||
</a>
|
||||
<div class="header-title">
|
||||
<button type="button" class="title-main" id="btn-home">Asset Browser</button>
|
||||
<a href="https://derenzy.com" target="_blank" rel="noopener noreferrer" class="title-sub">deRenzy Business Technologies</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
<div class="scan-input-wrap">
|
||||
<span class="scan-input-icon">
|
||||
<!-- Barcode icon — shown for staff roles -->
|
||||
<svg class="icon-barcode" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 5v14M7 5v14M11 5v14M17 5v14M21 5v14"/>
|
||||
<path d="M3 5h2M3 19h2M19 5h2M19 19h2"/>
|
||||
</svg>
|
||||
<!-- Magnifying glass — shown for client role -->
|
||||
<svg class="icon-search" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="scan-input"
|
||||
placeholder="Search by name, serial, user… or scan a barcode"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
inputmode="numeric"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore
|
||||
data-bwignore
|
||||
data-form-type="other"
|
||||
>
|
||||
<button id="scan-clear" class="scan-clear-btn" hidden aria-label="Clear">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
|
||||
<nav class="mode-nav" aria-label="App mode">
|
||||
<button class="mode-btn" data-mode="scan" id="btn-scan-mode">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h7v7h-7z" fill="currentColor" opacity=".3"/>
|
||||
<path d="M14 14h7v7h-7z"/>
|
||||
</svg>
|
||||
Scan Mode
|
||||
</button>
|
||||
<button class="mode-btn" id="btn-quick-view">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="8" rx="1"/><rect x="14" y="3" width="7" height="8" rx="1"/>
|
||||
<rect x="3" y="13" width="7" height="8" rx="1"/><rect x="14" y="13" width="7" height="8" rx="1"/>
|
||||
</svg>
|
||||
Quick View
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<span class="header-btn-divider" aria-hidden="true"></span>
|
||||
|
||||
<!-- App menu -->
|
||||
<div class="app-menu-wrap">
|
||||
<button class="mode-btn" id="btn-menu-toggle" aria-haspopup="true" aria-expanded="false">
|
||||
Menu
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<span class="lc-menu-badge" id="lc-badge" hidden>0</span>
|
||||
</button>
|
||||
|
||||
<div id="app-menu" class="app-menu" hidden>
|
||||
|
||||
<!-- User info -->
|
||||
<div class="app-menu-user">
|
||||
<span class="user-name" id="user-name">…</span>
|
||||
<span class="user-role-badge" id="user-role"></span>
|
||||
</div>
|
||||
|
||||
<!-- Admin portal (shown only for admin+) -->
|
||||
<a href="/admin" class="app-menu-item" id="menu-admin-link" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Admin Portal
|
||||
</a>
|
||||
|
||||
<div class="app-menu-divider"></div>
|
||||
|
||||
<!-- Label Center — batch sheet printing -->
|
||||
<button class="app-menu-item" id="btn-label-center">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
Label Center
|
||||
</button>
|
||||
|
||||
<!-- Timer toggle -->
|
||||
<button class="app-menu-item" id="btn-timer-toggle">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span id="timer-toggle-label">Timer: ON</span>
|
||||
</button>
|
||||
|
||||
<!-- Scan mode toggle (USB keyboard wedge ↔ camera) -->
|
||||
<button class="app-menu-item" id="btn-scan-mode-toggle">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
Camera Scan
|
||||
</button>
|
||||
|
||||
<div class="app-menu-divider"></div>
|
||||
|
||||
<!-- Sign out -->
|
||||
<button class="app-menu-item app-menu-item-danger" id="btn-logout">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<!-- ── App body (sidebar + main) ── -->
|
||||
<div id="app-body">
|
||||
|
||||
<!-- ── Asset Browser Sidebar ── -->
|
||||
<aside id="asset-sidebar" class="sidebar" data-no-refocus>
|
||||
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Assets</span>
|
||||
<button id="sidebar-refresh" class="sidebar-refresh-btn" aria-label="Refresh assets" title="Refresh asset list">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="sidebar-filter-btn" class="sidebar-filter-btn" aria-label="Filter assets" title="Filter assets">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||
</svg>
|
||||
<span class="sidebar-filter-badge" id="sidebar-filter-badge" hidden>0</span>
|
||||
</button>
|
||||
<button id="sidebar-menu-btn" class="sidebar-menu-btn" aria-label="Display options" title="Display options">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/>
|
||||
<line x1="4" y1="12" x2="20" y2="12"/>
|
||||
<line x1="4" y1="18" x2="20" y2="18"/>
|
||||
<circle cx="9" cy="6" r="2.5" fill="currentColor" stroke="none"/>
|
||||
<circle cx="15" cy="12" r="2.5" fill="currentColor" stroke="none"/>
|
||||
<circle cx="9" cy="18" r="2.5" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-search">
|
||||
<input type="text" id="sidebar-search" placeholder="Search name, serial, user…" autocomplete="off">
|
||||
<button id="sidebar-search-clear" class="sidebar-search-clear" hidden aria-label="Clear search">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter panel — shown when filter button is active -->
|
||||
<div id="sidebar-filter-panel" class="sidebar-filter-panel">
|
||||
|
||||
<div class="sf-section" data-filter-type="lifecycle">
|
||||
<div class="sf-label">Lifecycle</div>
|
||||
<div class="sf-chips">
|
||||
<button class="sf-chip" data-value="Active">Active</button>
|
||||
<button class="sf-chip" data-value="Inventory">Inventory</button>
|
||||
<button class="sf-chip" data-value="Pre-Deployment">Pre-Deploy</button>
|
||||
<button class="sf-chip" data-value="For Repair">Repair</button>
|
||||
<button class="sf-chip" data-value="For Upgrade">Upgrade</button>
|
||||
<button class="sf-chip" data-value="For Parts">Parts</button>
|
||||
<button class="sf-chip" data-value="Decommissioned">Decomm.</button>
|
||||
<button class="sf-chip" data-value="Disposed of">Disposed</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sf-section" data-filter-type="possession">
|
||||
<div class="sf-label">Possession</div>
|
||||
<div class="sf-chips">
|
||||
<button class="sf-chip active" data-value="">All</button>
|
||||
<button class="sf-chip" data-value="IT">IT Possession</button>
|
||||
<button class="sf-chip" data-value="Deployed">Deployed</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sf-section" data-filter-type="infra">
|
||||
<div class="sf-label">Type</div>
|
||||
<div class="sf-chips">
|
||||
<button class="sf-chip active" data-value="">All</button>
|
||||
<button class="sf-chip" data-value="false">Devices</button>
|
||||
<button class="sf-chip" data-value="true">Infrastructure</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sf-footer">
|
||||
<label class="sf-remember-label">
|
||||
<input type="checkbox" id="filter-remember">
|
||||
<span>Remember</span>
|
||||
</label>
|
||||
<button id="sf-clear-btn" class="sf-clear-btn">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Display/sort options menu — shown when menu button is active -->
|
||||
<div id="sidebar-menu-panel" class="sidebar-menu-panel">
|
||||
|
||||
<!-- ▼ Badges -->
|
||||
<div class="sidebar-menu-subsection open">
|
||||
<button class="sidebar-menu-subsection-header" type="button">
|
||||
<svg class="subsection-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<span>Badges</span>
|
||||
</button>
|
||||
<div class="sidebar-menu-subsection-body">
|
||||
<div class="sidebar-menu-checkbox-row">
|
||||
<label class="sidebar-menu-item"><input type="checkbox" id="badge-vis-possession"><span>Possession</span></label>
|
||||
<label class="sidebar-menu-item"><input type="checkbox" id="badge-vis-user"><span>User</span></label>
|
||||
<label class="sidebar-menu-item"><input type="checkbox" id="badge-vis-lifecycle"><span>Lifecycle</span></label>
|
||||
<label class="sidebar-menu-item"><input type="checkbox" id="badge-vis-infra"><span>Infra</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ▼ Clients -->
|
||||
<div class="sidebar-menu-subsection open">
|
||||
<button class="sidebar-menu-subsection-header" type="button">
|
||||
<svg class="subsection-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<span>Clients</span>
|
||||
</button>
|
||||
<div class="sidebar-menu-subsection-body">
|
||||
<div class="sidebar-menu-checkbox-row">
|
||||
<label class="sidebar-menu-item"><input type="checkbox" id="disp-show-count"><span>Show count</span></label>
|
||||
<label class="sidebar-menu-item"><input type="checkbox" id="disp-show-billable"><span>Show billable</span></label>
|
||||
</div>
|
||||
<div class="sidebar-menu-row">
|
||||
<span class="sidebar-menu-row-label">Hide empty:</span>
|
||||
<select id="disp-hide-empty" class="sidebar-menu-select">
|
||||
<option value="none">Show all</option>
|
||||
<option value="zero-assets">No assets</option>
|
||||
<option value="zero-billable">No billable</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sidebar-menu-row"><span class="sidebar-menu-row-label">Sort:</span></div>
|
||||
<div class="sidebar-sort-chips">
|
||||
<button class="sidebar-sort-chip" type="button" data-menu-sort="clients" data-value="alpha">A–Z</button>
|
||||
<button class="sidebar-sort-chip" type="button" data-menu-sort="clients" data-value="most-assets">Most Assets</button>
|
||||
<button class="sidebar-sort-chip" type="button" data-menu-sort="clients" data-value="most-billable">Billable</button>
|
||||
<button class="sidebar-sort-chip" type="button" data-menu-sort="clients" data-value="most-users">Users</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ▼ Assets -->
|
||||
<div class="sidebar-menu-subsection open">
|
||||
<button class="sidebar-menu-subsection-header" type="button">
|
||||
<svg class="subsection-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<span>Assets</span>
|
||||
</button>
|
||||
<div class="sidebar-menu-subsection-body">
|
||||
<div class="sidebar-menu-row"><span class="sidebar-menu-row-label">Sort:</span></div>
|
||||
<div class="sidebar-sort-chips">
|
||||
<button class="sidebar-sort-chip" type="button" data-menu-sort="assets" data-value="default">Default</button>
|
||||
<button class="sidebar-sort-chip" type="button" data-menu-sort="assets" data-value="alpha">A–Z</button>
|
||||
<button class="sidebar-sort-chip" type="button" data-menu-sort="assets" data-value="user">By User</button>
|
||||
<button class="sidebar-sort-chip" type="button" data-menu-sort="assets" data-value="last-sync">Last Sync</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-menu-footer">
|
||||
<label class="sidebar-menu-item sidebar-menu-remember">
|
||||
<input type="checkbox" id="menu-remember">
|
||||
<span>Remember settings</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="sidebar-tree" class="sidebar-tree"></div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- ── Sidebar resize handle ── -->
|
||||
<div id="sidebar-resize-handle" class="sidebar-resize-handle" aria-hidden="true"></div>
|
||||
|
||||
<!-- ── Main ── -->
|
||||
<main id="app-main">
|
||||
|
||||
<!-- Neutral / no mode selected -->
|
||||
<section id="view-neutral" class="view active" aria-live="polite"></section>
|
||||
|
||||
<!-- Idle / Ready to Scan -->
|
||||
<section id="view-idle" class="view" aria-live="polite">
|
||||
<div class="idle-content">
|
||||
<div class="scan-indicator">
|
||||
<div class="scan-pulse"></div>
|
||||
<svg class="scan-icon-large" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<rect x="8" y="14" width="5" height="36" rx="1"/>
|
||||
<rect x="16" y="14" width="3" height="36" rx="1"/>
|
||||
<rect x="22" y="14" width="7" height="36" rx="1"/>
|
||||
<rect x="32" y="14" width="3" height="36" rx="1"/>
|
||||
<rect x="38" y="14" width="5" height="36" rx="1"/>
|
||||
<rect x="46" y="14" width="3" height="36" rx="1"/>
|
||||
<rect x="52" y="14" width="4" height="36" rx="1"/>
|
||||
<line x1="4" y1="32" x2="60" y2="32" stroke="#C4622A" stroke-width="2" stroke-dasharray="4 2" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Ready to Scan</h2>
|
||||
<p>Aim your scanner at an asset label, or type an asset ID above</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Loading -->
|
||||
<section id="view-loading" class="view" aria-live="polite">
|
||||
<div class="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<p>Looking up asset…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Asset Card -->
|
||||
<section id="view-asset" class="view" aria-live="polite">
|
||||
<div id="asset-card-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- Search Results -->
|
||||
<section id="view-search-results" class="view" aria-live="polite">
|
||||
<div id="search-results-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- Label Generation -->
|
||||
<section id="view-label" class="view" aria-live="polite">
|
||||
<div id="label-gen-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- Quick View Dashboard (client role) -->
|
||||
<section id="view-quick-view" class="view" aria-live="polite">
|
||||
<div id="quick-view-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- Error -->
|
||||
<section id="view-error" class="view" aria-live="assertive">
|
||||
<div id="error-container"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
</div><!-- /#app-body -->
|
||||
|
||||
<!-- Toast container -->
|
||||
<div id="toast-container" aria-live="polite"></div>
|
||||
|
||||
<!-- Label Center overlay -->
|
||||
<div id="label-center-overlay" class="lc-overlay" hidden aria-modal="true" role="dialog" aria-label="Label Center">
|
||||
|
||||
<div class="lc-header">
|
||||
<div class="lc-header-left">
|
||||
<svg class="lc-header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 6 2 18 2 18 9"/>
|
||||
<path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/>
|
||||
<rect x="6" y="14" width="12" height="8"/>
|
||||
</svg>
|
||||
<span class="lc-header-title">Label Center</span>
|
||||
<span class="lc-header-sub" id="lc-queue-count">0 items queued</span>
|
||||
</div>
|
||||
<div class="lc-header-actions">
|
||||
<button class="btn btn-accent" id="lc-print-btn" disabled>Print All</button>
|
||||
<button class="btn btn-ghost" id="lc-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lc-body">
|
||||
|
||||
<!-- Left: queue list -->
|
||||
<div class="lc-queue-panel">
|
||||
<div class="lc-queue-panel-header">
|
||||
<span class="lc-panel-title">Queued Labels</span>
|
||||
<button class="btn btn-ghost" id="lc-clear-all-btn" style="font-size:.78rem;padding:4px 10px">Clear All</button>
|
||||
</div>
|
||||
<div class="lc-queue-list" id="lc-queue-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right: sheet pages -->
|
||||
<div class="lc-sheet-panel">
|
||||
<div class="lc-sheet-panel-header">
|
||||
<select id="lc-sheet-type" class="lc-sheet-select">
|
||||
<option value="OL875LP">OL875LP — 2.625" × 1" (30-up)</option>
|
||||
<option value="OL25LP">OL25LP — 1.75" × 0.5" (80-up)</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost" id="lc-autofill-btn" style="font-size:.78rem;padding:4px 10px">Auto-fill all</button>
|
||||
<button class="btn btn-ghost btn-danger" id="lc-reset-all-btn" style="font-size:.78rem;padding:4px 10px">Reset all</button>
|
||||
</div>
|
||||
<div class="lc-sheet-scroll">
|
||||
<div class="lc-pages-container" id="lc-pages-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Assignment picker flyout (positioned by JS) -->
|
||||
<div class="lc-picker" id="lc-picker" hidden>
|
||||
<div class="lc-picker-title">Assign to Position <span id="lc-picker-pos-label"></span></div>
|
||||
|
||||
<!-- Asset search -->
|
||||
<div class="lc-picker-search-wrap">
|
||||
<svg class="lc-picker-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input type="text" id="lc-picker-search" class="lc-picker-search-input"
|
||||
placeholder="Search assets…" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div id="lc-picker-search-results" class="lc-picker-search-results" hidden></div>
|
||||
|
||||
<div class="lc-picker-section-label">Queued labels</div>
|
||||
<div class="lc-picker-list" id="lc-picker-list"></div>
|
||||
<button class="btn btn-ghost" id="lc-picker-cancel" style="width:100%;margin-top:8px;font-size:.8rem">Cancel</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Camera scan overlay -->
|
||||
<div id="camera-overlay" class="camera-overlay" hidden aria-modal="true" role="dialog" aria-label="Camera Scanner">
|
||||
<div class="camera-overlay-inner">
|
||||
<div class="camera-header">
|
||||
<span class="camera-title">Camera Scanner</span>
|
||||
<button class="camera-close-btn" id="btn-camera-close" aria-label="Close camera scanner">×</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<video id="camera-video" autoplay playsinline muted></video>
|
||||
<div class="camera-reticle" aria-hidden="true">
|
||||
<div class="camera-scan-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="camera-status" id="camera-status">Initializing camera…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
66
public/login.html
Executable file
66
public/login.html
Executable file
|
|
@ -0,0 +1,66 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign In — deRenzy BT Asset Browser</title>
|
||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/assets/favicon-192.png" type="image/png" sizes="192x192">
|
||||
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/styles/variables.css">
|
||||
<link rel="stylesheet" href="/styles/login.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
|
||||
<div class="login-header">
|
||||
<img src="/assets/logo-swirl.png" alt="deRenzy BT" class="login-logo">
|
||||
<div>
|
||||
<div class="login-title">Asset Browser</div>
|
||||
<div class="login-sub">deRenzy Business Technologies</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="login-form" autocomplete="off" novalidate>
|
||||
<div id="login-error" class="login-error" hidden></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn" id="login-btn">Sign In</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
© <span id="login-year"></span> deRenzy Business Technologies
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
69
public/login.js
Executable file
69
public/login.js
Executable file
|
|
@ -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;
|
||||
}
|
||||
})();
|
||||
482
public/modules/actions.js
Executable file
482
public/modules/actions.js
Executable file
|
|
@ -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 = `<div class="contact-loading">${spinner()} Loading contacts…</div>`;
|
||||
|
||||
try {
|
||||
_contacts = await getContacts(_asset.customer_id);
|
||||
_contacts.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
||||
renderContactList(listEl, _contacts);
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<div class="contact-empty" style="color:var(--red)">Failed to load: ${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<div class="contact-empty">No contacts found.</div>`;
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = contacts.map(c => `
|
||||
<div class="contact-item" data-contact-id="${c.id}" data-contact-name="${esc(c.name ?? '')}" data-no-refocus>
|
||||
<div>
|
||||
<div class="contact-item-name">${esc(c.name)}</div>
|
||||
${c.email ? `<div class="contact-item-email">${esc(c.email)}</div>` : ''}
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--gray-400)" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
`).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,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function spinner() {
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="animation:spin .7s linear infinite;display:inline-block;vertical-align:middle"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>`;
|
||||
}
|
||||
1105
public/modules/assetBrowser.js
Executable file
1105
public/modules/assetBrowser.js
Executable file
File diff suppressed because it is too large
Load diff
391
public/modules/assetCard.js
Executable file
391
public/modules/assetCard.js
Executable file
|
|
@ -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 `
|
||||
<div class="asset-card" data-asset-id="${a.id}" data-customer-id="${a.customer_id ?? ''}">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="asset-card-header">
|
||||
<div class="asset-card-title-block">
|
||||
<div class="asset-name">${esc(a.name)}</div>
|
||||
<div class="asset-type-row">
|
||||
<span class="asset-type-badge">${esc(assetType)}</span>
|
||||
<span class="asset-customer">${esc(customerName)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-header-id">
|
||||
<div class="asset-id-label">Asset ID</div>
|
||||
<div class="asset-id-value">#${a.id}</div>
|
||||
<a href="${syncroUrl}" target="_blank" class="open-syncro-link" data-no-refocus>
|
||||
${iconExternal()} Open in Syncro
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" class="asset-card-close" id="btn-close-asset" aria-label="Close" data-no-refocus>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="asset-card-body">
|
||||
|
||||
<!-- Status Badges -->
|
||||
<div class="status-section">
|
||||
<div class="status-group">
|
||||
<div class="status-label">Possession</div>
|
||||
${possessionBadge(possessionStatus)}
|
||||
</div>
|
||||
<div class="status-group">
|
||||
<div class="status-label">Lifecycle</div>
|
||||
${lifecycleBadge(lifecycleStage)}
|
||||
</div>
|
||||
${(!isInfra && lastUser) ? `
|
||||
<div class="status-group">
|
||||
<div class="status-label">Last Login</div>
|
||||
<div class="status-last-seen">${esc(sameUser ? contactName : lastUser)}</div>
|
||||
</div>` : ''}
|
||||
<div class="status-group">
|
||||
<div class="status-label">Last Sync</div>
|
||||
<div class="status-last-seen">${a.properties?.kabuto_information?.last_synced_at ? formatDate(a.properties.kabuto_information.last_synced_at) : '<span style="color:var(--gray-400);font-style:italic">Never</span>'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="info-item-label">Serial Number</div>
|
||||
<div class="info-item-value">${esc(serialNumber)}</div>
|
||||
</div>
|
||||
${infraLocation ? `
|
||||
<div class="info-item">
|
||||
<div class="info-item-label">Location</div>
|
||||
<div class="info-item-value">${esc(infraLocation)}</div>
|
||||
</div>` : ''}
|
||||
${!isInfra ? `
|
||||
<div class="info-item">
|
||||
<div class="info-item-label">Assigned User</div>
|
||||
<div class="info-item-value ${contactName ? '' : 'none'}">${
|
||||
contactName
|
||||
? `${esc(contactName)}${contactEmail ? `<br><small style="color:var(--gray-400);font-size:.78rem">${esc(contactEmail)}</small>` : ''}`
|
||||
: 'Unassigned'
|
||||
}</div>
|
||||
</div>` : ''}
|
||||
<div class="info-item">
|
||||
<div class="info-item-label">Asset Type</div>
|
||||
<div class="info-item-value">${esc(assetType)}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-label">Customer</div>
|
||||
<div class="info-item-value">${esc(customerName)}</div>
|
||||
</div>
|
||||
${infraTag ? `
|
||||
<div class="info-item info-item-full">
|
||||
<div class="info-item-label">Tags</div>
|
||||
<div class="info-item-value">
|
||||
<div class="asset-tags">${infraTag.split(',').map(t => t.trim()).filter(Boolean).map(t => `<span class="asset-tag-pill">${esc(t)}</span>`).join('')}</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Metadata row -->
|
||||
<div class="meta-section">
|
||||
<div class="meta-item">
|
||||
<div class="meta-item-label">Last Scan</div>
|
||||
<div class="meta-item-value">${lastScanDate ? formatDate(lastScanDate) : '<span style="color:var(--gray-400);font-style:italic">Not set</span>'}</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-item-label">Last Action</div>
|
||||
<div class="meta-item-value">${lastAction ? esc(lastAction) : '<span style="color:var(--gray-400);font-style:italic">None recorded</span>'}</div>
|
||||
</div>
|
||||
${a.warranty_expires_at ? `
|
||||
<div class="meta-item">
|
||||
<div class="meta-item-label">Warranty Expires</div>
|
||||
<div class="meta-item-value">${formatDate(a.warranty_expires_at)}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="action-section">
|
||||
<div class="action-section-title">Actions</div>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" id="action-toggle-possession" data-action="toggle-possession">
|
||||
${iconSwap()} Toggle Possession
|
||||
</button>
|
||||
<div class="lc-wrap">
|
||||
<button class="action-btn" id="action-lifecycle" data-action="lifecycle">
|
||||
${iconStages()}
|
||||
<span class="lc-btn-label">${lifecycleStage ? esc(lifecycleStage) : 'Lifecycle Stage'}</span>
|
||||
<svg class="lc-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="lifecycle-dropdown" id="lifecycle-dropdown">
|
||||
${['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 `<button class="lifecycle-option${isCurrent ? ' current' : ''}" data-stage="${stage}">
|
||||
<span class="lc-dot ${dotCls}"></span>
|
||||
<span>${esc(stage)}</span>
|
||||
${isCurrent ? `<svg class="lc-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>` : ''}
|
||||
</button>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-btn" id="action-change-owner" data-action="change-owner">
|
||||
${iconPerson()} Change User
|
||||
</button>
|
||||
${contactName ? `<button class="action-btn action-btn-remove" id="action-remove-user" data-action="remove-user">
|
||||
${iconUserRemove()} Remove User
|
||||
</button>` : ''}
|
||||
<button class="action-btn accent" id="action-sign-out" data-action="sign-out">
|
||||
${iconSignOut()} Sign Out Device
|
||||
</button>
|
||||
<button class="action-btn" id="action-print-label" data-action="print-label">
|
||||
${iconPrint()} Quick Print
|
||||
</button>
|
||||
<button class="action-btn lc-add-btn" id="action-add-to-queue" data-action="add-to-queue">
|
||||
${iconQueuePlus()} Add to Sheet
|
||||
</button>
|
||||
<button class="action-btn" id="action-infrastructure" data-action="infrastructure">
|
||||
${iconServer()} ${isInfra ? 'Manage Infrastructure' : 'Mark as Infrastructure'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contact panel — used for Change Owner & Sign Out -->
|
||||
<div class="contact-panel" id="contact-panel">
|
||||
<div class="contact-panel-title" id="contact-panel-title">Select Contact</div>
|
||||
<input type="text" class="contact-search" id="contact-search" placeholder="Filter by name..." autocomplete="off" data-no-refocus>
|
||||
<div class="contact-list" id="contact-list">
|
||||
<div class="contact-loading">${iconSpinner()} Loading contacts…</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;display:flex;gap:8px">
|
||||
<button class="btn btn-ghost" id="contact-cancel" style="font-size:.8rem;padding:6px 12px" data-no-refocus>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infrastructure panel -->
|
||||
<div class="contact-panel" id="infra-panel">
|
||||
<div class="contact-panel-title" id="infra-panel-title">${isInfra ? 'Manage Infrastructure' : 'Set Infrastructure'}</div>
|
||||
<input type="text" class="contact-search" id="infra-tag-input" placeholder="Tags — comma separated (e.g. Server, UPS, RDP)" autocomplete="off" data-no-refocus>
|
||||
<input type="text" class="contact-search" id="infra-location-input" placeholder="Location (e.g. Server Room, IT Closet…) — optional" autocomplete="off" data-no-refocus style="margin-top:6px">
|
||||
<div style="margin-top:8px;display:flex;gap:8px">
|
||||
<button class="btn btn-primary" id="infra-confirm-btn" style="font-size:.8rem;padding:6px 12px" data-no-refocus>Confirm</button>
|
||||
<button class="btn btn-ghost" id="infra-cancel-btn" style="font-size:.8rem;padding:6px 12px" data-no-refocus>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket History -->
|
||||
<div class="ticket-section">
|
||||
<button class="ticket-toggle" id="ticket-toggle">
|
||||
<span>Recent Tickets</span>
|
||||
<svg class="chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="ticket-list" id="ticket-list">
|
||||
<div class="contact-loading">${iconSpinner()} Loading tickets…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Asset History -->
|
||||
<div class="ticket-section history-section">
|
||||
<button class="ticket-toggle" id="history-toggle">
|
||||
<span>Asset History</span>
|
||||
<svg class="chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="ticket-list history-list" id="history-list">
|
||||
${buildHistoryHTML(assetHistory)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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 = `<svg class="status-badge-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>`;
|
||||
const options = ['In IT Possession', 'Deployed'].map(opt => {
|
||||
const isCurrent = status === opt || (opt === 'Deployed' && status === 'In User Possession');
|
||||
return `<button class="status-dropdown-option${isCurrent ? ' current' : ''}" data-possession="${opt}">${opt}</button>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="status-badge-wrap">
|
||||
<button class="badge ${cls} status-badge-btn" id="status-possession-btn" data-no-refocus>
|
||||
${labelInner}${chevron}
|
||||
</button>
|
||||
<div class="status-dropdown" id="status-possession-dropdown">${options}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<svg class="status-badge-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>`;
|
||||
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 `<button class="status-dropdown-option${isCurrent ? ' current' : ''}" data-stage="${s}">
|
||||
<span class="lc-dot ${dotCls}"></span><span>${esc(s)}</span>
|
||||
${isCurrent ? `<svg class="lc-check" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>` : ''}
|
||||
</button>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="status-badge-wrap">
|
||||
<button class="badge ${cls} status-badge-btn" id="status-lifecycle-btn" data-no-refocus>
|
||||
${stage ? esc(stage) : 'Not Set'}${chevron}
|
||||
</button>
|
||||
<div class="status-dropdown" id="status-lifecycle-dropdown">${options}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildHistoryHTML(rawValue) {
|
||||
if (!rawValue || !rawValue.trim()) {
|
||||
return `<div class="contact-empty" style="font-style:italic">No history recorded yet.</div>`;
|
||||
}
|
||||
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 `<div class="history-item">
|
||||
<div class="history-dot"></div>
|
||||
<div>
|
||||
<div class="history-desc">${esc(match[2])}</div>
|
||||
<div class="history-time">${esc(match[1])}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="history-item">
|
||||
<div class="history-dot"></div>
|
||||
<div class="history-desc">${esc(line)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Tiny inline icons (SVG) ───────────────────────────────────────────────────
|
||||
|
||||
function iconExternal() {
|
||||
return `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
|
||||
}
|
||||
function iconSwap() {
|
||||
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4"/><path d="M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>`;
|
||||
}
|
||||
function iconStages() {
|
||||
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>`;
|
||||
}
|
||||
function iconPerson() {
|
||||
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
||||
}
|
||||
function iconSignOut() {
|
||||
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`;
|
||||
}
|
||||
function iconPrint() {
|
||||
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>`;
|
||||
}
|
||||
function iconQueuePlus() {
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>`;
|
||||
}
|
||||
function iconCheck() {
|
||||
return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||
}
|
||||
function iconUser() {
|
||||
return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
||||
}
|
||||
function iconUserRemove() {
|
||||
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="23" y1="11" x2="17" y2="11"/></svg>`;
|
||||
}
|
||||
function iconServer() {
|
||||
return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1" fill="currentColor"/><circle cx="6" cy="18" r="1" fill="currentColor"/></svg>`;
|
||||
}
|
||||
function iconSpinner() {
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="animation:spin .7s linear infinite;display:inline-block"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>`;
|
||||
}
|
||||
|
||||
// ── Utils ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.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;
|
||||
}
|
||||
}
|
||||
172
public/modules/cameraScanner.js
Executable file
172
public/modules/cameraScanner.js
Executable file
|
|
@ -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);
|
||||
}
|
||||
135
public/modules/clientDashboard.js
Executable file
135
public/modules/clientDashboard.js
Executable file
|
|
@ -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, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function renderTable({ title, assets, stageOrder, possession, onFilterSelect }) {
|
||||
if (!assets.length) {
|
||||
return `<div class="qv-section"><h2>${esc(title)}</h2><p class="qv-empty">No devices in this group.</p></div>`;
|
||||
}
|
||||
|
||||
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 => `<th>${esc(c)}</th>`).join('');
|
||||
const totalCells = cols.map(c => {
|
||||
const n = colTotals[c];
|
||||
return `<td>${n > 0 ? n : '<span class="qv-cell-zero">—</span>'}</td>`;
|
||||
}).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 `<td><span class="qv-cell-zero">—</span></td>`;
|
||||
const payload = encodeURIComponent(JSON.stringify({ lifecycle: [stage], possession }));
|
||||
return `<td><span class="qv-cell-link" data-filter="${payload}">${n}</span></td>`;
|
||||
}).join('');
|
||||
return `<tr><td>${esc(type)}</td>${cells}<td>${rowTotal}</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="qv-section">
|
||||
<div class="qv-card-header">
|
||||
<h2>${esc(title)}</h2>
|
||||
<span class="qv-card-count">${grandTotal} device${grandTotal !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="qv-card-body">
|
||||
<table class="qv-table">
|
||||
<thead><tr><th>Device Type</th>${headerCells}<th>Total</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot><tr><td>Total</td>${totalCells}<td>${grandTotal}</td></tr></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function renderClientDashboard(container, user, { onFilterSelect } = {}) {
|
||||
container.innerHTML = '<div class="qv-loading">Loading…</div>';
|
||||
|
||||
if (!user?.syncro_customer_id) {
|
||||
container.innerHTML = '<div class="qv-empty">No company assigned to your account. Contact your administrator.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let assets;
|
||||
try {
|
||||
assets = await getCustomerAssets(user.syncro_customer_id);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="qv-empty">Failed to load assets: ${esc(err.message)}</div>`;
|
||||
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 */ }
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
1618
public/modules/labelCenter.js
Executable file
1618
public/modules/labelCenter.js
Executable file
File diff suppressed because it is too large
Load diff
81
public/modules/labelGen.js
Executable file
81
public/modules/labelGen.js
Executable file
|
|
@ -0,0 +1,81 @@
|
|||
// labelGen.js — generates a label preview (barcode rendered via JsBarcode to canvas)
|
||||
// and builds ZPL for Zebra printing.
|
||||
|
||||
export function formatPhone(raw) {
|
||||
const digits = String(raw ?? '').replace(/\D/g, '');
|
||||
if (digits.length === 10) {
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
if (digits.length === 11 && digits[0] === '1') {
|
||||
return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
|
||||
}
|
||||
return raw ?? '';
|
||||
}
|
||||
|
||||
// Returns { html, svgIdForId, svgIdForSn } — caller renders serial barcode via renderBarcode()
|
||||
export function buildLabelHTML(asset, prefix = 'lbl') {
|
||||
const assetName = asset.name ?? 'Unknown Asset';
|
||||
const serial = asset.asset_serial ?? asset.serial ?? asset.serial_number ?? '';
|
||||
const customerName = asset.customer?.business_name ?? asset.customer?.business_then_name ?? asset.customer?.name ?? '';
|
||||
const customerPhone = formatPhone(asset.customer?.phone ?? asset.customer?.mobile ?? '');
|
||||
const customLine = asset.custom_line ?? '';
|
||||
const logoSrc = '/assets/logo-swirl.png';
|
||||
const svgIdForSn = `${prefix}-sn`;
|
||||
|
||||
const html = `
|
||||
<div class="label-preview-wrap">
|
||||
<div class="label-preview" id="label-preview-box">
|
||||
<div class="label-header-row">
|
||||
<div class="label-header-left">
|
||||
<img src="${logoSrc}" alt="deRenzy BT" class="label-logo">
|
||||
<span class="label-company">deRenzy Business Technologies</span>
|
||||
</div>
|
||||
<span class="label-derenzy-phone">(413) 739-4706</span>
|
||||
</div>
|
||||
<div class="label-content-row">
|
||||
<div class="label-text-block">
|
||||
<div class="label-asset-name">${esc(assetName)}</div>
|
||||
${customerName ? `<div class="label-info-line">${esc(customerName)}</div>` : ''}
|
||||
${customerPhone ? `<div class="label-info-line">${esc(customerPhone)}</div>` : ''}
|
||||
${customLine ? `<div class="label-info-line label-custom-line">${esc(customLine)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${serial ? `<div class="label-barcode-area"><img id="${svgIdForSn}" class="label-barcode-img" alt="barcode"></div>` : ''}
|
||||
${serial ? `<div class="label-barcode-caption"><span>SN: ${esc(serial)}</span></div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return { html, svgIdForId: null, svgIdForSn: serial ? svgIdForSn : null, serial };
|
||||
}
|
||||
|
||||
export function renderBarcode(imgId, value) {
|
||||
// JsBarcode is loaded globally from /vendor/JsBarcode.all.min.js
|
||||
if (typeof JsBarcode === 'undefined') {
|
||||
console.error('JsBarcode not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Render to an off-screen canvas, then blast it into an <img> as a data URL.
|
||||
// <img object-fit:fill> stretches reliably to fill its container — no SVG
|
||||
// viewBox / preserveAspectRatio fighting required.
|
||||
const canvas = document.createElement('canvas');
|
||||
// JsBarcode sets canvas.width/height from (bar modules × width) and height option.
|
||||
// width:4 gives 4px per bar module → crisp source pixels before CSS scaling.
|
||||
JsBarcode(canvas, String(value), {
|
||||
format: 'CODE128',
|
||||
width: 4,
|
||||
height: 400,
|
||||
displayValue: false,
|
||||
margin: 0,
|
||||
});
|
||||
const img = document.getElementById(imgId);
|
||||
if (img) img.src = canvas.toDataURL('image/png');
|
||||
} catch (err) {
|
||||
console.warn('Barcode render error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
128
public/modules/scanner.js
Executable file
128
public/modules/scanner.js
Executable file
|
|
@ -0,0 +1,128 @@
|
|||
// scanner.js — manages the always-focused scan input in the header.
|
||||
// Barcode scanners act as USB HID keyboard wedges; they type fast and send Enter.
|
||||
|
||||
let _onScan = null;
|
||||
let _idleTimer = null;
|
||||
let _onIdle = null;
|
||||
let _input = null;
|
||||
let _clearBtn = null;
|
||||
let _timerDuration = null; // null = off, or ms duration
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
let _canFocus = () => true;
|
||||
let _paused = false;
|
||||
|
||||
let _keyInterceptor = null;
|
||||
export function setKeyInterceptor(fn) { _keyInterceptor = fn; }
|
||||
|
||||
export function pause() {
|
||||
_paused = true;
|
||||
_input?.blur();
|
||||
}
|
||||
|
||||
export function resume() {
|
||||
_paused = false;
|
||||
focusScanInput();
|
||||
}
|
||||
|
||||
export function initScanner({ onScan, onIdle, canFocus }) {
|
||||
_onScan = onScan;
|
||||
_onIdle = onIdle;
|
||||
if (canFocus) _canFocus = canFocus;
|
||||
_input = document.getElementById('scan-input');
|
||||
_clearBtn = document.getElementById('scan-clear');
|
||||
|
||||
_input.addEventListener('keydown', _handleKeydown);
|
||||
_input.addEventListener('input', _handleInput);
|
||||
|
||||
_clearBtn.addEventListener('click', () => {
|
||||
clearInput();
|
||||
focusScanInput();
|
||||
});
|
||||
|
||||
// Re-focus when clicking dead space — only when scan mode is active and no text is selected
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!_canFocus()) return;
|
||||
if (window.getSelection()?.toString()) return;
|
||||
const interactive = e.target.closest(
|
||||
'button, a, input, select, textarea, [data-no-refocus]'
|
||||
);
|
||||
if (!interactive) focusScanInput();
|
||||
});
|
||||
|
||||
// Activity detection — mouse/pointer/key resets idle timer while asset is shown
|
||||
document.addEventListener('mousemove', _onActivity, { passive: true });
|
||||
document.addEventListener('pointerdown', _onActivity, { passive: true });
|
||||
document.addEventListener('keydown', _onActivity, { passive: true });
|
||||
|
||||
focusScanInput();
|
||||
}
|
||||
|
||||
// ── Timer toggle ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function setTimerDuration(ms) {
|
||||
_timerDuration = ms ?? null;
|
||||
if (!_timerDuration) clearTimeout(_idleTimer);
|
||||
}
|
||||
|
||||
export function getTimerDuration() {
|
||||
return _timerDuration;
|
||||
}
|
||||
|
||||
// ── Activity ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function _onActivity() {
|
||||
const assetView = document.getElementById('view-asset');
|
||||
if (assetView?.classList.contains('active')) {
|
||||
resetIdleTimer();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scan input ────────────────────────────────────────────────────────────────
|
||||
|
||||
function _handleKeydown(e) {
|
||||
if (_paused) return;
|
||||
if (_keyInterceptor?.(e)) return;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const value = _input.value.trim();
|
||||
if (value) _fireScanned(value);
|
||||
}
|
||||
if (e.key === 'Escape') clearInput();
|
||||
}
|
||||
|
||||
function _handleInput() {
|
||||
_clearBtn.hidden = !_input.value.trim();
|
||||
}
|
||||
|
||||
function _fireScanned(value) {
|
||||
clearInput();
|
||||
resetIdleTimer();
|
||||
if (_onScan) _onScan(value);
|
||||
}
|
||||
|
||||
// ── Exports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function focusScanInput() {
|
||||
if (_input && document.activeElement !== _input) {
|
||||
_input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearInput() {
|
||||
if (_input) _input.value = '';
|
||||
if (_clearBtn) _clearBtn.hidden = true;
|
||||
}
|
||||
|
||||
export function resetIdleTimer() {
|
||||
if (!_timerDuration) return;
|
||||
clearTimeout(_idleTimer);
|
||||
_idleTimer = setTimeout(() => {
|
||||
if (_onIdle) _onIdle();
|
||||
}, _timerDuration);
|
||||
}
|
||||
|
||||
export function cancelIdleTimer() {
|
||||
clearTimeout(_idleTimer);
|
||||
}
|
||||
153
public/modules/searchAutocomplete.js
Executable file
153
public/modules/searchAutocomplete.js
Executable file
|
|
@ -0,0 +1,153 @@
|
|||
// searchAutocomplete.js — universal search dropdown for the scan/search input.
|
||||
import { searchLocal } from './assetBrowser.js';
|
||||
|
||||
let _onLocalSelect = null;
|
||||
let _onRemoteSearch = null;
|
||||
let _input = null;
|
||||
let _dropdown = null;
|
||||
let _items = []; // { type:'asset'|'syncro', asset?, serial?, contact?, customerName?, query? }
|
||||
let _activeIdx = -1;
|
||||
let _inputTimer = null;
|
||||
let _blurTimer = null;
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function initSearchAutocomplete({ onLocalSelect, onRemoteSearch }) {
|
||||
_onLocalSelect = onLocalSelect;
|
||||
_onRemoteSearch = onRemoteSearch;
|
||||
|
||||
_input = document.getElementById('scan-input');
|
||||
if (!_input) return;
|
||||
|
||||
// Append dropdown inside scan-input-wrap so it's positioned relative to it
|
||||
_dropdown = document.createElement('div');
|
||||
_dropdown.id = 'search-autocomplete';
|
||||
_dropdown.className = 'search-autocomplete';
|
||||
_dropdown.hidden = true;
|
||||
_input.closest('.scan-input-wrap').appendChild(_dropdown);
|
||||
|
||||
_input.addEventListener('input', _onInput);
|
||||
|
||||
_input.addEventListener('blur', () => {
|
||||
_blurTimer = setTimeout(_close, 150);
|
||||
});
|
||||
|
||||
_input.addEventListener('focus', () => {
|
||||
clearTimeout(_blurTimer);
|
||||
if (_input.value.trim().length >= 2) _onInput();
|
||||
});
|
||||
|
||||
// Prevent blur when clicking a dropdown item
|
||||
_dropdown.addEventListener('mousedown', e => e.preventDefault());
|
||||
|
||||
_dropdown.addEventListener('click', e => {
|
||||
const item = e.target.closest('.ac-item');
|
||||
if (item) _selectIdx(Number(item.dataset.idx));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Key handler — called by scanner's key interceptor ─────────────────────────
|
||||
|
||||
export function handleAutocompleteKey(e) {
|
||||
if (_dropdown.hidden) return false;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
_setActive(Math.min(_activeIdx + 1, _items.length - 1));
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
_setActive(Math.max(_activeIdx - 1, 0));
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (_activeIdx >= 0) {
|
||||
e.preventDefault();
|
||||
_selectIdx(_activeIdx);
|
||||
return true;
|
||||
}
|
||||
_close(); // close but let scanner handle Enter
|
||||
return false;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
_close();
|
||||
return true; // prevent scanner's clearInput
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function _onInput() {
|
||||
clearTimeout(_inputTimer);
|
||||
_inputTimer = setTimeout(() => {
|
||||
const query = _input.value.trim();
|
||||
if (query.length < 2) { _close(); return; }
|
||||
_renderDropdown(query);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function _renderDropdown(query) {
|
||||
const localResults = searchLocal(query);
|
||||
_items = [
|
||||
...localResults.map(r => ({ type: 'asset', ...r })),
|
||||
{ type: 'syncro', query },
|
||||
];
|
||||
|
||||
_activeIdx = -1;
|
||||
_dropdown.innerHTML = _items.map((item, i) => {
|
||||
if (item.type === 'syncro') {
|
||||
return `<div class="ac-item ac-item-syncro" data-idx="${i}">
|
||||
<svg class="ac-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<span>Search Syncro for <em>${_esc(item.query)}</em></span>
|
||||
</div>`;
|
||||
}
|
||||
const { asset, serial, contact, customerName } = item;
|
||||
const meta = [customerName, serial, contact].filter(Boolean).join(' · ');
|
||||
return `<div class="ac-item" data-idx="${i}">
|
||||
<svg class="ac-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
|
||||
</svg>
|
||||
<div class="ac-item-body">
|
||||
<div class="ac-item-name">${_esc(asset.name ?? `Asset ${asset.id}`)}</div>
|
||||
${meta ? `<div class="ac-item-meta">${_esc(meta)}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
_dropdown.hidden = false;
|
||||
}
|
||||
|
||||
function _setActive(idx) {
|
||||
_activeIdx = idx;
|
||||
_dropdown.querySelectorAll('.ac-item').forEach((el, i) => {
|
||||
el.classList.toggle('ac-active', i === idx);
|
||||
if (i === idx) el.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function _selectIdx(idx) {
|
||||
const item = _items[idx];
|
||||
if (!item) return;
|
||||
_close();
|
||||
_input.value = '';
|
||||
_input.dispatchEvent(new Event('input')); // sync clear-btn visibility
|
||||
if (item.type === 'asset') {
|
||||
_onLocalSelect?.(item.asset);
|
||||
} else {
|
||||
_onRemoteSearch?.(item.query);
|
||||
}
|
||||
}
|
||||
|
||||
function _close() {
|
||||
_dropdown.hidden = true;
|
||||
_activeIdx = -1;
|
||||
_items = [];
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
106
public/modules/ticketHistory.js
Executable file
106
public/modules/ticketHistory.js
Executable file
|
|
@ -0,0 +1,106 @@
|
|||
// ticketHistory.js — fetches recent tickets and renders them into the card.
|
||||
// Filters to tickets belonging to the asset's assigned contact.
|
||||
// Falls back to the 5 most recent customer tickets if no contact is assigned
|
||||
// or if the contact has no tickets.
|
||||
import { getTickets } from '../api/syncro.js';
|
||||
|
||||
let _loaded = false;
|
||||
let _open = false;
|
||||
|
||||
export function initTicketHistory(asset) {
|
||||
_loaded = false;
|
||||
_open = false;
|
||||
|
||||
const toggle = document.getElementById('ticket-toggle');
|
||||
const list = document.getElementById('ticket-list');
|
||||
if (!toggle || !list) return;
|
||||
|
||||
toggle.classList.remove('open');
|
||||
list.classList.remove('visible');
|
||||
list.innerHTML = `<div class="contact-loading">${spinnerHTML()} Loading tickets…</div>`;
|
||||
|
||||
toggle.onclick = async () => {
|
||||
_open = !_open;
|
||||
toggle.classList.toggle('open', _open);
|
||||
list.classList.toggle('visible', _open);
|
||||
|
||||
if (_open && !_loaded) {
|
||||
await loadTickets(list, asset.customer_id, asset.contact_id ?? null);
|
||||
_loaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Wire up asset history toggle (static content, no async load needed)
|
||||
const historyToggle = document.getElementById('history-toggle');
|
||||
const historyList = document.getElementById('history-list');
|
||||
if (historyToggle && historyList) {
|
||||
let historyOpen = false;
|
||||
historyToggle.classList.remove('open');
|
||||
historyList.classList.remove('visible');
|
||||
historyToggle.onclick = () => {
|
||||
historyOpen = !historyOpen;
|
||||
historyToggle.classList.toggle('open', historyOpen);
|
||||
historyList.classList.toggle('visible', historyOpen);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets(listEl, customerId, contactId) {
|
||||
listEl.innerHTML = `<div class="contact-loading">${spinnerHTML()} Loading tickets…</div>`;
|
||||
try {
|
||||
const all = await getTickets(customerId);
|
||||
|
||||
let tickets;
|
||||
let scopeLabel;
|
||||
|
||||
if (contactId) {
|
||||
const contactMatches = all.filter(t => t.contact_id === contactId);
|
||||
if (contactMatches.length > 0) {
|
||||
tickets = contactMatches.slice(0, 10);
|
||||
scopeLabel = null; // no label needed — these are contact-scoped
|
||||
} else {
|
||||
// Contact exists but has no tickets — fall back to recent customer tickets
|
||||
tickets = all.slice(0, 5);
|
||||
scopeLabel = 'No tickets for assigned contact — showing recent customer tickets';
|
||||
}
|
||||
} else {
|
||||
tickets = all.slice(0, 5);
|
||||
scopeLabel = 'No contact assigned — showing recent customer tickets';
|
||||
}
|
||||
|
||||
if (!tickets.length) {
|
||||
listEl.innerHTML = `<div class="contact-empty">No tickets found.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML =
|
||||
(scopeLabel ? `<div class="contact-empty" style="font-style:italic;margin-bottom:6px">${esc(scopeLabel)}</div>` : '') +
|
||||
tickets.map(t => ticketItemHTML(t)).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<div class="contact-empty" style="color:var(--red)">Failed to load tickets: ${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function ticketItemHTML(t) {
|
||||
const statusKey = (t.status ?? '').toLowerCase().replace(/\s+/g, '-');
|
||||
const date = t.created_at
|
||||
? new Date(t.created_at).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' })
|
||||
: '';
|
||||
const contactName = t.contact_fullname ? ` · ${esc(t.contact_fullname)}` : '';
|
||||
return `
|
||||
<div class="ticket-item status-${statusKey}">
|
||||
<div>
|
||||
<div class="ticket-subject">${esc(t.subject ?? t.problem_type ?? 'Ticket #' + (t.number ?? t.id))}</div>
|
||||
<div class="ticket-meta">#${t.number ?? t.id} · ${date}${contactName}</div>
|
||||
</div>
|
||||
<div class="ticket-status ${statusKey || 'default'}">${esc(t.status ?? 'Unknown')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function spinnerHTML() {
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="animation:spin .7s linear infinite;display:inline-block;vertical-align:middle"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>`;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
17
public/modules/toast.js
Executable file
17
public/modules/toast.js
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
// toast.js — shared toast notification utility
|
||||
|
||||
export function showToast(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const icons = { success: '✓', error: '✕', info: 'ℹ' };
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = `${icons[type] ?? 'ℹ'} ${message}`;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.cssText += 'opacity:0;transform:translateY(8px);transition:opacity .3s,transform .3s';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
188
public/modules/usernameUtils.js
Executable file
188
public/modules/usernameUtils.js
Executable file
|
|
@ -0,0 +1,188 @@
|
|||
// usernameUtils.js — username normalization and fuzzy matching
|
||||
|
||||
// Usernames containing this string are admin/system accounts — skip display entirely
|
||||
const ADMIN_PATTERN = /admin/i;
|
||||
|
||||
/**
|
||||
* Normalizes a raw Windows/AzureAD username for display.
|
||||
*
|
||||
* Returns null for:
|
||||
* - empty/missing values
|
||||
* - admin/system accounts (username contains "admin", e.g. deRAdmin, DAdmin)
|
||||
*
|
||||
* Handles:
|
||||
* - Domain prefix stripping: DOMAIN\user or AzureAD\KristinMcHugh
|
||||
* - CamelCase splitting: KristinMcHugh → Kristin Mc Hugh
|
||||
* - Word capitalization: johndaigle → Johndaigle, michael → Michael
|
||||
*
|
||||
* Note: all-lowercase concatenated names (johndaigle) can't be split without
|
||||
* external knowledge — we just capitalize the first letter. If a contact name
|
||||
* matches via fuzzy comparison the caller should prefer the contact's name.
|
||||
*
|
||||
* @param {string} raw — value from kabuto_information.last_user
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeUsername(raw) {
|
||||
if (!raw) return null;
|
||||
|
||||
// Strip domain prefix (handles both DOMAIN\user and AzureAD\Name)
|
||||
let name = raw.includes('\\') ? raw.split('\\').pop() : raw;
|
||||
if (!name) return null;
|
||||
|
||||
// Skip admin/system accounts — don't display or match these
|
||||
if (ADMIN_PATTERN.test(name)) return null;
|
||||
|
||||
// If no spaces already, try CamelCase split
|
||||
// e.g. KristinMcHugh → Kristin Mc Hugh
|
||||
if (!name.includes(' ')) {
|
||||
name = name.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
}
|
||||
|
||||
// Capitalize first letter of each word (leave remaining letters as-is to
|
||||
// preserve intentional casing like McHugh after a manual split)
|
||||
name = name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
return name || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy-matches a (possibly CamelCase or space-separated) username against a
|
||||
* contact's full name by stripping all spaces and comparing case-insensitively.
|
||||
*
|
||||
* "KristinMcHugh" ↔ "Kristin McHugh" → true (both → "kristinmchugh")
|
||||
* "johndaigle" ↔ "John Daigle" → true (both → "johndaigle")
|
||||
* "michael" ↔ "Michael Smith" → false (different after strip)
|
||||
*
|
||||
* @param {string} username — raw value from kabuto_information.last_user
|
||||
* @param {string} contactName — contact full name from Syncro
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function usernameFuzzyMatch(rawUsername, contactName) {
|
||||
if (!rawUsername || !contactName) return false;
|
||||
// Strip domain prefix first so DOMAIN\johndaigle compares as johndaigle
|
||||
const stripDomain = s => s.includes('\\') ? s.split('\\').pop() : s;
|
||||
const norm = s => stripDomain(s).toLowerCase().replace(/\s+/g, '');
|
||||
return norm(rawUsername) === norm(contactName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-name matching: if the username normalizes to a single word (e.g. "michael"),
|
||||
* checks whether that word uniquely matches the first name of exactly one distinct
|
||||
* contact across all contact names in the company. If so, and the assigned contact's
|
||||
* first name matches, returns true.
|
||||
*
|
||||
* username="michael", contactName="Michael Smith", allContactNames=["Michael Smith"]
|
||||
* → true (only one "michael" first name in the company)
|
||||
*
|
||||
* username="michael", allContactNames=["Michael Smith", "Michael Jones"]
|
||||
* → false (ambiguous — two Michaels)
|
||||
*
|
||||
* @param {string} rawUsername — raw value from kabuto_information.last_user
|
||||
* @param {string} contactName — contact full name of the assigned contact
|
||||
* @param {string[]} allContactNames — all distinct contact names in the customer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function usernameFirstNameMatch(rawUsername, contactName, allContactNames) {
|
||||
if (!rawUsername || !contactName || !allContactNames?.length) return false;
|
||||
|
||||
const normalized = normalizeUsername(rawUsername);
|
||||
if (!normalized) return false;
|
||||
|
||||
// Only apply to single-word usernames (multi-word is already handled by fuzzy match)
|
||||
if (normalized.includes(' ')) return false;
|
||||
|
||||
const lowerUser = normalized.toLowerCase();
|
||||
|
||||
// Assigned contact's first name must match the username
|
||||
const assignedFirst = contactName.split(' ')[0].toLowerCase();
|
||||
if (lowerUser !== assignedFirst) return false;
|
||||
|
||||
// Count how many distinct contacts share that first name
|
||||
const distinct = new Set(allContactNames.map(n => n.toLowerCase()));
|
||||
const matches = [...distinct].filter(cn => cn.split(' ')[0] === lowerUser);
|
||||
return matches.length === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial-plus-lastname matching: handles usernames like "jgallerani" that are
|
||||
* constructed as first-initial + full last name.
|
||||
*
|
||||
* "jgallerani" ↔ "James Gallerani" → true (j + gallerani)
|
||||
* "jsmith" ↔ "John Smith" → true only if unique in company
|
||||
*
|
||||
* Requires uniqueness across the company to avoid false positives.
|
||||
*
|
||||
* @param {string} rawUsername — raw value from kabuto_information.last_user
|
||||
* @param {string} contactName — contact full name of the assigned contact
|
||||
* @param {string[]} allContactNames — all distinct contact names in the customer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function usernameInitialLastNameMatch(rawUsername, contactName, allContactNames) {
|
||||
if (!rawUsername || !contactName || !allContactNames?.length) return false;
|
||||
|
||||
const normalized = normalizeUsername(rawUsername);
|
||||
if (!normalized || normalized.includes(' ')) return false;
|
||||
|
||||
const lowerUser = normalized.toLowerCase();
|
||||
|
||||
// Build pattern: first initial + last name word
|
||||
// e.g. "James Gallerani" → "jgallerani", "Mary Ann Smith" → "msmith"
|
||||
const _buildPattern = name => {
|
||||
const parts = name.toLowerCase().split(' ').filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
return parts[0][0] + parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const assignedPattern = _buildPattern(contactName);
|
||||
if (!assignedPattern || lowerUser !== assignedPattern) return false;
|
||||
|
||||
// Require uniqueness: prevent matching when multiple contacts share the same pattern
|
||||
const distinct = new Set(allContactNames.map(n => n.toLowerCase()));
|
||||
const matchCount = [...distinct].filter(cn => _buildPattern(cn) === lowerUser).length;
|
||||
return matchCount === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* First-name-plus-initials matching: handles usernames like "johnd" that are
|
||||
* constructed as firstname + first-letter(s) of last name parts.
|
||||
*
|
||||
* "johnd" ↔ "John Daigle" → true (john + d)
|
||||
* "johnd" ↔ "John Davis" → still true if only one "johnd" pattern in company
|
||||
* "johnd" ↔ "Jane Doe" → false (jane ≠ john)
|
||||
*
|
||||
* Only fires when the username is a single word and doesn't already match via
|
||||
* fuzzy or first-name matching. Requires uniqueness across the company.
|
||||
*
|
||||
* @param {string} rawUsername — raw value from kabuto_information.last_user
|
||||
* @param {string} contactName — contact full name of the assigned contact
|
||||
* @param {string[]} allContactNames — all distinct contact names in the customer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function usernameNameInitialMatch(rawUsername, contactName, allContactNames) {
|
||||
if (!rawUsername || !contactName || !allContactNames?.length) return false;
|
||||
|
||||
const normalized = normalizeUsername(rawUsername);
|
||||
if (!normalized || normalized.includes(' ')) return false;
|
||||
|
||||
const lowerUser = normalized.toLowerCase();
|
||||
|
||||
// Build the expected pattern for the assigned contact: firstname + initials
|
||||
// e.g. "John Daigle" → "johnd", "Mary Ann Smith" → "maryans"
|
||||
const _buildPattern = name => {
|
||||
const parts = name.toLowerCase().split(' ').filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
return parts[0] + parts.slice(1).map(p => p[0]).join('');
|
||||
};
|
||||
|
||||
const assignedPattern = _buildPattern(contactName);
|
||||
if (!assignedPattern || lowerUser !== assignedPattern) return false;
|
||||
|
||||
// Check uniqueness: how many contacts produce the same pattern?
|
||||
const distinct = new Set(allContactNames.map(n => n.toLowerCase()));
|
||||
const matchCount = [...distinct].filter(cn => _buildPattern(cn) === lowerUser).length;
|
||||
return matchCount === 1;
|
||||
}
|
||||
745
public/styles/card.css
Executable file
745
public/styles/card.css
Executable file
|
|
@ -0,0 +1,745 @@
|
|||
/* ===== Asset Card ===== */
|
||||
.asset-card {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: visible; /* must be visible so lifecycle dropdown isn't clipped */
|
||||
animation: fadeIn .3s ease;
|
||||
}
|
||||
|
||||
.asset-card.closing {
|
||||
animation: fadeOut .2s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
|
||||
.asset-card-header {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-mid) 100%);
|
||||
color: var(--white);
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0; /* replaces card overflow:hidden for top corners */
|
||||
}
|
||||
|
||||
.asset-card-title-block { flex: 1; min-width: 0; }
|
||||
|
||||
.asset-card-close {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255,255,255,.65);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
align-self: flex-start;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.asset-card-close:hover { color: var(--white); background: rgba(255,255,255,.15); }
|
||||
|
||||
.asset-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.asset-type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.asset-type-badge {
|
||||
background: rgba(255,255,255,.15);
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
border-radius: 100px;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.asset-customer {
|
||||
font-size: 0.88rem;
|
||||
color: rgba(255,255,255,.7);
|
||||
}
|
||||
|
||||
.asset-header-id {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-id-label {
|
||||
font-size: 0.68rem;
|
||||
color: rgba(255,255,255,.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
.asset-id-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(255,255,255,.9);
|
||||
}
|
||||
|
||||
.open-syncro-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255,255,255,.6);
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 8px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.open-syncro-link:hover {
|
||||
background: rgba(255,255,255,.15);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
/* ===== Card Body ===== */
|
||||
.asset-card-body {
|
||||
padding: 20px 24px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ===== Status Section ===== */
|
||||
.status-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--gray-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-last-seen {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
/* ===== Badges ===== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 100px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.badge-it-possession {
|
||||
background: var(--green-bg);
|
||||
color: var(--green);
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.badge-user-possession {
|
||||
background: var(--yellow-bg);
|
||||
color: var(--yellow);
|
||||
border-color: #fde047;
|
||||
}
|
||||
|
||||
.badge-pre-deployment { background: #cffafe; color: #0e7490; border-color: #67e8f9; }
|
||||
.badge-inventory { background: var(--primary-50); color: var(--primary); border-color: #93c5fd; }
|
||||
.badge-active { background: var(--green-bg); color: var(--green); border-color: #86efac; }
|
||||
.badge-for-repair { background: var(--yellow-bg); color: var(--yellow); border-color: #fde047; }
|
||||
.badge-for-upgrade { background: var(--purple-bg); color: var(--purple); border-color: #c4b5fd; }
|
||||
.badge-decommissioned { background: var(--red-bg); color: var(--red); border-color: #fca5a5; }
|
||||
.badge-for-parts { background: #fff3e0; color: #b45309; border-color: #fcd34d; }
|
||||
.badge-disposed-of { background: var(--gray-100); color: var(--gray-700); border-color: var(--gray-400); }
|
||||
.badge-unknown { background: var(--gray-100); color: var(--gray-600); border-color: var(--gray-300); }
|
||||
|
||||
/* ===== Info Grid ===== */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.info-item {}
|
||||
|
||||
.info-item-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--gray-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.info-item-value {
|
||||
font-size: 0.9rem;
|
||||
color: var(--gray-800);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-item-value.none { color: var(--gray-400); font-style: italic; font-weight: 400; }
|
||||
|
||||
.info-item-full { grid-column: 1 / -1; }
|
||||
|
||||
.asset-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 1px; }
|
||||
|
||||
.asset-tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Action Buttons Row ===== */
|
||||
.action-section { border-top: 1px solid var(--gray-100); padding-top: 18px; }
|
||||
|
||||
.action-section-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--gray-50);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
color: var(--gray-700);
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
padding: 9px 14px;
|
||||
transition: all .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.action-btn:active { transform: scale(.97); }
|
||||
|
||||
.action-btn.accent {
|
||||
background: var(--accent-50);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
.action-btn.accent:hover { background: var(--accent); color: var(--white); }
|
||||
|
||||
.action-btn.action-btn-remove {
|
||||
border-color: var(--red-200, #fecaca);
|
||||
color: var(--red, #dc2626);
|
||||
}
|
||||
.action-btn.action-btn-remove:hover {
|
||||
background: var(--red-bg, #fef2f2);
|
||||
border-color: var(--red, #dc2626);
|
||||
color: var(--red, #dc2626);
|
||||
}
|
||||
|
||||
.action-btn.loading {
|
||||
opacity: .65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
|
||||
/* ===== Lifecycle Dropdown ===== */
|
||||
|
||||
/* Wrapper keeps the dropdown in-flow relative to the button */
|
||||
.lc-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Chevron inside the lifecycle button */
|
||||
.lc-chevron {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
transition: transform .2s ease;
|
||||
}
|
||||
|
||||
#action-lifecycle .lc-btn-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#action-lifecycle.open .lc-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* The dropdown panel */
|
||||
.lifecycle-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
min-width: 170px;
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 4px;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(.97);
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition: opacity .15s ease, transform .15s ease, visibility 0s linear .15s;
|
||||
}
|
||||
|
||||
.lifecycle-dropdown.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
transition: opacity .15s ease, transform .15s ease, visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
.lifecycle-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font);
|
||||
color: var(--gray-700);
|
||||
transition: background .1s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.lifecycle-option:hover { background: var(--gray-50); }
|
||||
|
||||
.lifecycle-option.current {
|
||||
font-weight: 700;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.lc-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lc-dot-pre-deployment { background: #0e7490; }
|
||||
.lc-dot-inventory { background: var(--primary); }
|
||||
.lc-dot-active { background: var(--green); }
|
||||
.lc-dot-for-repair { background: var(--yellow); }
|
||||
.lc-dot-for-upgrade { background: var(--purple); }
|
||||
.lc-dot-for-parts { background: #b45309; }
|
||||
.lc-dot-decommissioned { background: var(--red); }
|
||||
.lc-dot-disposed-of { background: var(--gray-400); }
|
||||
|
||||
.lc-check {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* ===== Contact Dropdown Panel ===== */
|
||||
.contact-panel {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
background: var(--gray-50);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
animation: fadeIn .2s ease;
|
||||
}
|
||||
|
||||
.contact-panel.visible { display: block; }
|
||||
|
||||
.contact-panel-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--gray-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.contact-search {
|
||||
width: 100%;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.88rem;
|
||||
font-family: var(--font);
|
||||
margin-bottom: 10px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.contact-search:focus { border-color: var(--primary); }
|
||||
|
||||
.contact-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background .12s;
|
||||
}
|
||||
|
||||
.contact-item:hover { background: var(--primary-50); }
|
||||
|
||||
.contact-item-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.contact-item-email {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.contact-loading, .contact-empty {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--gray-500);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ===== Infrastructure ===== */
|
||||
|
||||
.infra-option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
border: 1.5px dashed var(--gray-300);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--gray-50);
|
||||
color: var(--gray-600);
|
||||
font-size: 0.84rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.infra-option-btn:hover {
|
||||
border-color: var(--gray-500);
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.infra-location-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* ===== Ticket History Panel ===== */
|
||||
.ticket-section {
|
||||
border-top: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
.ticket-toggle {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 0 0;
|
||||
font-family: var(--font);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--gray-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
transition: color .15s;
|
||||
}
|
||||
|
||||
.ticket-toggle:hover { color: var(--primary); }
|
||||
|
||||
.ticket-toggle .chevron {
|
||||
transition: transform .2s;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.ticket-toggle.open .chevron { transform: rotate(180deg); }
|
||||
|
||||
.ticket-list {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
animation: fadeIn .25s ease;
|
||||
}
|
||||
|
||||
.ticket-list.visible { display: flex; }
|
||||
|
||||
.ticket-item {
|
||||
background: var(--gray-50);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-left: 3px solid var(--primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ticket-item.status-resolved { border-left-color: var(--green); }
|
||||
.ticket-item.status-open { border-left-color: var(--accent); }
|
||||
.ticket-item.status-in-progress { border-left-color: var(--yellow); }
|
||||
|
||||
.ticket-subject {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.ticket-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray-500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ticket-status {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 100px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-status.resolved { background: var(--green-bg); color: var(--green); }
|
||||
.ticket-status.open { background: var(--accent-50); color: var(--accent-dark); }
|
||||
.ticket-status.in-progress { background: var(--yellow-bg); color: var(--yellow); }
|
||||
.ticket-status.default { background: var(--gray-100); color: var(--gray-600); }
|
||||
|
||||
/* ===== Last Scan / Action Metadata ===== */
|
||||
.meta-section {
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.meta-item {}
|
||||
|
||||
.meta-item-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--gray-400);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta-item-value {
|
||||
font-size: 0.82rem;
|
||||
color: var(--gray-700);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== Asset History Timeline ===== */
|
||||
.history-section { border-top: 1px solid var(--gray-100); }
|
||||
|
||||
.history-list {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
padding-left: 4px;
|
||||
animation: fadeIn .25s ease;
|
||||
}
|
||||
|
||||
.history-list.visible { display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
border-left: 2px solid var(--gray-200);
|
||||
margin-left: 6px;
|
||||
padding-left: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.history-dot {
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: 2px solid var(--white);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-desc {
|
||||
font-size: 0.84rem;
|
||||
color: var(--gray-800);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--gray-400);
|
||||
margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ===== Status Badge Dropdowns ===== */
|
||||
|
||||
.status-badge-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* The badge itself becomes a button — inherits all .badge + .badge-* colors */
|
||||
.status-badge-btn {
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
font-weight: 700;
|
||||
transition: filter .12s;
|
||||
}
|
||||
|
||||
.status-badge-btn:hover { filter: brightness(0.93); }
|
||||
.status-badge-btn:active { transform: scale(.97); }
|
||||
|
||||
.status-badge-chevron {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
|
||||
.status-badge-btn.open .status-badge-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Dropdown panel — mirrors .lifecycle-dropdown */
|
||||
.status-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
min-width: 160px;
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 4px;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(.97);
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition: opacity .15s ease, transform .15s ease, visibility 0s linear .15s;
|
||||
}
|
||||
|
||||
.status-dropdown.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
transition: opacity .15s ease, transform .15s ease, visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
.status-dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font);
|
||||
color: var(--gray-700);
|
||||
transition: background .1s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.status-dropdown-option:hover { background: var(--gray-50); }
|
||||
|
||||
.status-dropdown-option.current {
|
||||
font-weight: 700;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
167
public/styles/label.css
Executable file
167
public/styles/label.css
Executable file
|
|
@ -0,0 +1,167 @@
|
|||
/* ===== Label Preview (used in popup + in-app preview) ===== */
|
||||
.label-preview-wrap {
|
||||
background: var(--white);
|
||||
border: 2px dashed var(--gray-300);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 2.625" x 1" label — scales with its container, maintains 2.625:1 ratio */
|
||||
.label-preview {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
aspect-ratio: 384 / 146;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 8px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header: logo+company on left, deRenzy phone on right ── */
|
||||
.label-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label-logo {
|
||||
height: 18px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-company {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
color: #1a3565;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label-derenzy-phone {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
color: #1a3565;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Content: text on left (barcode SVG is a sibling, not inside here) ── */
|
||||
.label-content-row {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding-top: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.label-text-block {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 2px 0 2px 7px; /* left: 7px + label's 8px padding = 15px ≈ 1 line-height */
|
||||
max-width: 51%; /* gives ~13px gap on the right before the barcode */
|
||||
}
|
||||
|
||||
.label-asset-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.label-info-line {
|
||||
font-size: 10px;
|
||||
color: #444;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
margin-top: 1px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* Wrapper div stretches reliably (non-replaced block) from below the header to the
|
||||
label bottom. top: 30px clears padding (6px) + header row (~24px). */
|
||||
.label-barcode-area {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 45%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* img fills the wrapper; object-fit:fill stretches it to exact container dims.
|
||||
pixelated forces nearest-neighbor scaling → crisp bar edges, no blur. */
|
||||
.label-barcode-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
image-rendering: pixelated;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Caption: div handles position/centering; span gets the tight solid white pill */
|
||||
.label-barcode-caption {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 0;
|
||||
width: 45%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.label-barcode-caption span {
|
||||
display: inline-block;
|
||||
font-size: 7.5px;
|
||||
color: #444;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
background: #fff;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* ===== Print popup styles (Zebra direct print) ===== */
|
||||
@media print {
|
||||
body { margin: 0; background: white; }
|
||||
.no-print { display: none !important; }
|
||||
.label-preview {
|
||||
width: 2.625in;
|
||||
height: 1in;
|
||||
aspect-ratio: unset;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0.05in 0.07in;
|
||||
}
|
||||
.label-asset-name { font-size: 8pt; }
|
||||
.label-info-line { font-size: 6pt; }
|
||||
.label-company, .label-derenzy-phone { font-size: 5.5pt; }
|
||||
.label-barcode-caption { font-size: 5.5pt; }
|
||||
}
|
||||
1007
public/styles/labelCenter.css
Executable file
1007
public/styles/labelCenter.css
Executable file
File diff suppressed because it is too large
Load diff
138
public/styles/login.css
Executable file
138
public/styles/login.css
Executable file
|
|
@ -0,0 +1,138 @@
|
|||
/* ── Login Page ── */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
linear-gradient(135deg, var(--primary-dark) 0%, #234480 50%, var(--primary-dark) 100%);
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.login-wrap {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
background: var(--white);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Card header ── */
|
||||
.login-header {
|
||||
background: var(--primary-dark);
|
||||
color: var(--white);
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border-bottom: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--white);
|
||||
padding: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.login-sub {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255,255,255,.55);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Form ── */
|
||||
.login-form {
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: var(--red-bg);
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--red);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--gray-700);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font);
|
||||
color: var(--gray-900);
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(43,84,153,.15);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-btn:hover { background: var(--accent-light); }
|
||||
.login-btn:active { transform: scale(.98); }
|
||||
.login-btn:disabled { opacity: .6; cursor: not-allowed; transform: none; }
|
||||
|
||||
/* ── Footer ── */
|
||||
.login-footer {
|
||||
color: rgba(255,255,255,.45);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
932
public/styles/main.css
Executable file
932
public/styles/main.css
Executable file
|
|
@ -0,0 +1,932 @@
|
|||
/* ===== Reset ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { height: 100%; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-900);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
#app-header {
|
||||
background: var(--primary-dark);
|
||||
color: var(--white);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 10px 24px;
|
||||
min-height: 68px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
border-bottom: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
background: var(--white);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.header-logo-link { line-height: 0; border-radius: 50%; }
|
||||
.header-logo-link:focus-visible { outline: 2px solid rgba(255,255,255,.7); outline-offset: 2px; }
|
||||
|
||||
.title-main {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--white);
|
||||
letter-spacing: -.01em;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
.title-main:hover { text-decoration: none; }
|
||||
|
||||
.title-sub {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255,255,255,.55);
|
||||
letter-spacing: .02em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
}
|
||||
.title-sub:hover { color: rgba(255,255,255,.55); }
|
||||
|
||||
/* ===== Scan Input (Header Center) ===== */
|
||||
.header-center { flex: 1; min-width: 0; }
|
||||
|
||||
.scan-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scan-input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: rgba(255,255,255,.5);
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#scan-input {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,.1);
|
||||
border: 2px solid rgba(255,255,255,.2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--white);
|
||||
font-size: 1rem;
|
||||
font-family: var(--font);
|
||||
padding: 10px 42px 10px 44px;
|
||||
outline: none;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
|
||||
#scan-input::placeholder { color: rgba(255,255,255,.4); }
|
||||
|
||||
#scan-input:focus {
|
||||
background: rgba(255,255,255,.15);
|
||||
border-color: var(--accent-light);
|
||||
box-shadow: 0 0 0 3px rgba(196,98,42,.25);
|
||||
}
|
||||
|
||||
.scan-clear-btn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,.5);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
transition: color .15s;
|
||||
line-height: 1;
|
||||
}
|
||||
.scan-clear-btn:hover { color: var(--white); }
|
||||
|
||||
/* ===== Mode Nav ===== */
|
||||
.header-right { flex-shrink: 0; }
|
||||
|
||||
.mode-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,.1);
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
border-radius: var(--radius);
|
||||
color: rgba(255,255,255,.7);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
padding: 8px 14px;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
background: rgba(255,255,255,.18);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--white);
|
||||
box-shadow: 0 2px 6px rgba(196,98,42,.4);
|
||||
}
|
||||
|
||||
#btn-menu-toggle[aria-expanded="true"] {
|
||||
background: rgba(255,255,255,.25);
|
||||
border-color: rgba(255,255,255,.45);
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,.2);
|
||||
}
|
||||
|
||||
.mode-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
|
||||
|
||||
/* ===== Main Content ===== */
|
||||
#app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
min-width: 0; /* prevent flex blowout */
|
||||
overflow-y: auto; /* main content scrolls independently from sidebar */
|
||||
}
|
||||
|
||||
/* ===== View Sections ===== */
|
||||
.view { display: none; }
|
||||
.view.active { display: flex; flex-direction: column; flex: 1; }
|
||||
|
||||
/* ===== Idle / Ready State ===== */
|
||||
#view-idle {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.idle-content {
|
||||
text-align: center;
|
||||
animation: fadeIn .4s ease;
|
||||
}
|
||||
|
||||
.scan-indicator {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scan-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--primary);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: .8; }
|
||||
50% { transform: scale(1.12); opacity: .3; }
|
||||
}
|
||||
|
||||
.scan-icon-large {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--primary);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.idle-content h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--gray-800);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.idle-content p {
|
||||
color: var(--gray-500);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ===== Loading State ===== */
|
||||
#view-loading {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
text-align: center;
|
||||
animation: fadeIn .2s ease;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--gray-200);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.loading-content p {
|
||||
color: var(--gray-500);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ===== Error State ===== */
|
||||
.error-card {
|
||||
background: var(--red-bg);
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
margin: 60px auto;
|
||||
animation: fadeIn .3s ease;
|
||||
}
|
||||
|
||||
.error-card .error-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-card h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--red);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.error-card p {
|
||||
color: var(--gray-700);
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* ===== Search Results ===== */
|
||||
.search-results-wrap {
|
||||
animation: fadeIn .3s ease;
|
||||
}
|
||||
|
||||
.search-results-wrap h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.search-result-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.search-result-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--gray-500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ===== Toast Notifications ===== */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--gray-900);
|
||||
color: var(--white);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 20px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideUp .25s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast.success { background: var(--green); }
|
||||
.toast.error { background: var(--red); }
|
||||
.toast.info { background: var(--primary); }
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(12px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== Utility ===== */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes menuOpen {
|
||||
from { clip-path: inset(0 0 100% 0 round 8px); }
|
||||
to { clip-path: inset(0 0 0% 0 round 8px); }
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
padding: 9px 18px;
|
||||
font-size: 0.88rem;
|
||||
transition: background .15s, transform .1s, box-shadow .15s;
|
||||
}
|
||||
.btn:active { transform: scale(.97); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-light); }
|
||||
|
||||
.btn-accent {
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
}
|
||||
.btn-accent:hover { background: var(--accent-light); }
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--gray-200); }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
.btn-danger:hover { background: #fecaca; }
|
||||
|
||||
/* ===== Label Generation View ===== */
|
||||
.label-gen-wrap {
|
||||
max-width: 680px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
animation: fadeIn .3s ease;
|
||||
}
|
||||
|
||||
.label-gen-wrap h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.label-search-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.label-search-row input {
|
||||
flex: 1;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font);
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.label-search-row input:focus { border-color: var(--primary); }
|
||||
|
||||
/* ===== User Menu (header-right) ===== */
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-right: 14px;
|
||||
border-right: 1px solid rgba(255,255,255,.15);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-role-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
padding: 1px 7px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255,255,255,.15);
|
||||
color: rgba(255,255,255,.7);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Distinct colour per role */
|
||||
.user-role-badge[data-role="superduperadmin"] { background: var(--accent); color: #fff; }
|
||||
.user-role-badge[data-role="admin"] { background: var(--primary-light); color: #fff; }
|
||||
.user-role-badge[data-role="tech"] { background: rgba(255,255,255,.2); color: rgba(255,255,255,.85); }
|
||||
.user-role-badge[data-role="client"] { background: rgba(255,255,255,.1); color: rgba(255,255,255,.6); }
|
||||
|
||||
.btn-logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: rgba(255,255,255,.1);
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(255,255,255,.7);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
padding: 6px 11px;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-logout svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
|
||||
.btn-logout:hover {
|
||||
background: rgba(185,28,28,.3);
|
||||
border-color: rgba(252,165,165,.3);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ===== Header button divider ===== */
|
||||
.header-btn-divider {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: rgba(255,255,255,.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== App Menu Dropdown ===== */
|
||||
.app-menu-wrap {
|
||||
position: relative;
|
||||
filter: drop-shadow(0 12px 28px rgba(0,0,0,.18)) drop-shadow(0 3px 8px rgba(0,0,0,.10));
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
background: var(--white);
|
||||
border-radius: var(--radius-lg);
|
||||
min-width: 210px;
|
||||
z-index: 500;
|
||||
overflow: hidden;
|
||||
animation: menuOpen .2s ease;
|
||||
}
|
||||
|
||||
.app-menu[hidden] { display: none; }
|
||||
|
||||
.app-menu-user {
|
||||
padding: 14px 16px;
|
||||
background: var(--primary);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.app-menu-user .user-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.app-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--gray-100);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.app-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: var(--font);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--gray-700);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
transition: background .1s, color .1s;
|
||||
}
|
||||
|
||||
.app-menu-item:hover { background: var(--gray-50); color: var(--gray-900); }
|
||||
|
||||
.app-menu-item svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--gray-400);
|
||||
flex-shrink: 0;
|
||||
transition: color .1s;
|
||||
}
|
||||
|
||||
.app-menu-item:hover svg { color: var(--gray-600); }
|
||||
|
||||
.app-menu-item-danger { color: var(--red); }
|
||||
.app-menu-item-danger svg { color: var(--red); opacity: .7; }
|
||||
.app-menu-item-danger:hover { background: var(--red-bg); color: var(--red); }
|
||||
.app-menu-item-danger:hover svg { color: var(--red); opacity: 1; }
|
||||
|
||||
/* Active state inside the menu (e.g. timer ON) */
|
||||
.app-menu-item.active { color: var(--accent); font-weight: 600; }
|
||||
.app-menu-item.active svg { color: var(--accent); opacity: 1; }
|
||||
.app-menu-item.active:hover { background: var(--accent-50); color: var(--accent-dark); }
|
||||
|
||||
/* ===== Search Autocomplete Dropdown ===== */
|
||||
.search-autocomplete {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--white);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.22), 0 2px 8px rgba(0,0,0,.12);
|
||||
overflow: hidden;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
animation: fadeIn .12s ease;
|
||||
}
|
||||
|
||||
.search-autocomplete[hidden] { display: none; }
|
||||
|
||||
.ac-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
transition: background .1s;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
.ac-item:last-child { border-bottom: none; }
|
||||
|
||||
.ac-item:hover,
|
||||
.ac-item.ac-active {
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.ac-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.ac-item-body { min-width: 0; }
|
||||
|
||||
.ac-item-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ac-item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray-500);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.ac-item-syncro {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray-600);
|
||||
background: var(--gray-50);
|
||||
border-top: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.ac-item-syncro:hover,
|
||||
.ac-item-syncro.ac-active {
|
||||
background: var(--accent-50);
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
|
||||
.ac-item-syncro .ac-icon { color: var(--gray-400); }
|
||||
|
||||
.ac-item-syncro em {
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.user-info { display: none; }
|
||||
}
|
||||
|
||||
/* ── Camera scan overlay ──────────────────────────────────────────────────── */
|
||||
|
||||
.camera-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.camera-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-overlay-inner {
|
||||
background: #1a1a1a;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
width: min(92vw, 480px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.camera-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.camera-title {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.camera-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #aaa;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.camera-close-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.camera-viewport {
|
||||
position: relative;
|
||||
background: #000;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
#camera-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Targeting reticle — pure CSS, no images */
|
||||
.camera-reticle {
|
||||
position: absolute;
|
||||
inset: 20%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Corner accents — top-left and bottom-right */
|
||||
.camera-reticle::before,
|
||||
.camera-reticle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-style: solid;
|
||||
border-color: rgba(255, 255, 255, 0.75);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.camera-reticle::before {
|
||||
top: -1px; left: -1px;
|
||||
border-width: 3px 0 0 3px;
|
||||
border-radius: 4px 0 0 0;
|
||||
}
|
||||
.camera-reticle::after {
|
||||
bottom: -1px; right: -1px;
|
||||
border-width: 0 3px 3px 0;
|
||||
border-radius: 0 0 4px 0;
|
||||
}
|
||||
|
||||
/* Scan line — hidden by default, shown on detection */
|
||||
.camera-scan-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Flash green when a barcode is detected */
|
||||
.camera-overlay.detected .camera-reticle {
|
||||
border-color: rgba(74, 222, 128, 0.5);
|
||||
}
|
||||
.camera-overlay.detected .camera-reticle::before,
|
||||
.camera-overlay.detected .camera-reticle::after {
|
||||
border-color: #4ade80;
|
||||
}
|
||||
.camera-overlay.detected .camera-scan-line {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 4px; right: 4px;
|
||||
height: 2px;
|
||||
top: 50%;
|
||||
background: linear-gradient(90deg, transparent 0%, #4ade80 50%, transparent 100%);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.camera-status {
|
||||
padding: 10px 16px;
|
||||
color: #ccc;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
min-height: 2.5em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.camera-status.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* ── Client role: hide write controls and scan-only UI ──────────────────── */
|
||||
.role-client #btn-scan-mode,
|
||||
.role-client #btn-scan-mode-toggle,
|
||||
.role-client #btn-timer-toggle,
|
||||
.role-client #btn-label-center,
|
||||
.role-client #sidebar-refresh,
|
||||
.role-client #action-toggle-possession,
|
||||
.role-client #action-lifecycle,
|
||||
.role-client #action-change-owner,
|
||||
.role-client #action-remove-user,
|
||||
.role-client #action-sign-out,
|
||||
.role-client #action-infrastructure,
|
||||
.role-client #action-print-label,
|
||||
.role-client #action-add-to-queue { display: none; }
|
||||
|
||||
/* Show Quick View button only for client role */
|
||||
#btn-quick-view { display: none; }
|
||||
.role-client #btn-quick-view { display: inline-flex; }
|
||||
|
||||
/* Search bar: show magnifying glass icon for clients, barcode for staff */
|
||||
.icon-search { display: none; }
|
||||
.role-client .icon-barcode { display: none; }
|
||||
.role-client .icon-search { display: inline; }
|
||||
|
||||
/* Quick View dashboard layout */
|
||||
#view-quick-view { padding: 1.5rem; overflow-y: auto; }
|
||||
|
||||
/* Card — matches .admin-section from admin panel */
|
||||
.qv-section {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qv-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
.qv-card-header h2 { font-size: 1rem; font-weight: 700; color: var(--gray-800); margin: 0; }
|
||||
.qv-card-count { font-size: 0.8rem; color: var(--gray-500); }
|
||||
|
||||
/* Table */
|
||||
.qv-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
.qv-table th {
|
||||
text-align: center; padding: 0.5rem 0.75rem;
|
||||
background: var(--gray-50); color: var(--gray-600);
|
||||
font-weight: 600; border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
.qv-table th:first-child { text-align: left; padding-left: 24px; }
|
||||
.qv-table td {
|
||||
text-align: center; padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
.qv-table td:first-child { text-align: left; font-weight: 500; color: var(--gray-800); padding-left: 24px; }
|
||||
.qv-table tbody tr:last-child td { border-bottom: none; }
|
||||
.qv-table tfoot td {
|
||||
font-weight: 600; border-top: 2px solid var(--gray-200);
|
||||
border-bottom: none; background: var(--gray-50);
|
||||
padding-top: 0.6rem; padding-bottom: 0.6rem;
|
||||
}
|
||||
.qv-cell-link {
|
||||
display: inline-block; min-width: 2rem; padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px; cursor: pointer; transition: background 0.15s;
|
||||
color: var(--accent, #3b82f6);
|
||||
}
|
||||
.qv-cell-link:hover { background: var(--gray-100); text-decoration: underline; }
|
||||
.qv-cell-zero { color: var(--gray-400); }
|
||||
.qv-empty { padding: 1.5rem 24px; color: var(--gray-500); font-size: 0.875rem; }
|
||||
.qv-loading { padding: 1.5rem 24px; color: var(--gray-400); font-size: 0.875rem; }
|
||||
702
public/styles/sidebar.css
Executable file
702
public/styles/sidebar.css
Executable file
|
|
@ -0,0 +1,702 @@
|
|||
/* sidebar.css — Asset Browser sidebar styles */
|
||||
|
||||
/* ── App body shell (sidebar + main side-by-side) ──────────────────────────── */
|
||||
|
||||
#app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
min-width: 160px;
|
||||
max-width: 600px;
|
||||
background: var(--white);
|
||||
border-right: 1px solid var(--gray-200);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar resize handle ──────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-resize-handle {
|
||||
width: 3px;
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
transition: background .12s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Nubbin — short grip pill centered on the handle */
|
||||
.sidebar-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 3px;
|
||||
height: 28px;
|
||||
border-radius: 99px;
|
||||
background: var(--gray-400, #9ca3af);
|
||||
opacity: .35;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle:hover::after {
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle.dragging {
|
||||
background: var(--accent, #C4622A);
|
||||
}
|
||||
|
||||
.sidebar-resize-handle.dragging::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Prevent text selection while dragging */
|
||||
body.sidebar-resizing {
|
||||
user-select: none;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* ── Sidebar header ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.15);
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
white-space: nowrap;
|
||||
color: rgba(255,255,255,.9);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Refresh button ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-refresh-btn {
|
||||
background: rgba(255,255,255,.12);
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--white);
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background .15s, opacity .15s;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.sidebar-refresh-btn:hover { background: rgba(255,255,255,.25); }
|
||||
.sidebar-refresh-btn:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.sidebar-refresh-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
.sidebar-refresh-btn.spinning svg {
|
||||
animation: spin .7s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Filter + menu buttons (shared active style) ────────────────────────────── */
|
||||
|
||||
.sidebar-filter-btn,
|
||||
.sidebar-menu-btn {
|
||||
background: rgba(255,255,255,.12);
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--white);
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background .2s ease, border-color .2s ease;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.sidebar-filter-btn { position: relative; }
|
||||
|
||||
.sidebar-filter-btn:hover,
|
||||
.sidebar-menu-btn:hover { background: rgba(255,255,255,.25); }
|
||||
|
||||
.sidebar-filter-btn.active,
|
||||
.sidebar-menu-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-filter-btn svg,
|
||||
.sidebar-menu-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
.sidebar-filter-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
border: 1.5px solid var(--primary);
|
||||
}
|
||||
|
||||
.sidebar-filter-btn.active .sidebar-filter-badge {
|
||||
background: var(--white);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Slide-in panels (filter + menu) ────────────────────────────────────────── */
|
||||
|
||||
.sidebar-filter-panel,
|
||||
.sidebar-menu-panel {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: max-height .22s ease, opacity .18s ease, padding .2s ease;
|
||||
}
|
||||
|
||||
.sidebar-filter-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
background: var(--gray-50);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.sidebar-filter-panel.open {
|
||||
max-height: 400px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.sidebar-menu-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--white);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.sidebar-menu-panel.open {
|
||||
max-height: 600px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* ── Menu panel content ──────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-menu-section-title {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--gray-400);
|
||||
padding: 2px 0 6px;
|
||||
}
|
||||
|
||||
.sidebar-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--gray-700);
|
||||
user-select: none;
|
||||
transition: background .1s;
|
||||
}
|
||||
|
||||
.sidebar-menu-item:hover { background: var(--gray-50); }
|
||||
|
||||
.sidebar-menu-item input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sf-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.sf-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .07em;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.sf-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sf-chip {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--gray-300);
|
||||
background: var(--white);
|
||||
color: var(--gray-600);
|
||||
cursor: pointer;
|
||||
transition: background .1s, color .1s, border-color .1s;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sf-chip:hover {
|
||||
border-color: var(--primary-light);
|
||||
color: var(--primary);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
/* Possession / Infra — single-select: active = primary */
|
||||
[data-filter-type="possession"] .sf-chip.active,
|
||||
[data-filter-type="infra"] .sf-chip.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
/* Lifecycle — multi-select: each stage has its badge colour when active */
|
||||
[data-filter-type="lifecycle"] .sf-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
[data-filter-type="lifecycle"] .sf-chip[data-value="Active"].active { background: var(--green); border-color: var(--green); color: #fff; }
|
||||
[data-filter-type="lifecycle"] .sf-chip[data-value="Inventory"].active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
[data-filter-type="lifecycle"] .sf-chip[data-value="Pre-Deployment"].active { background: #0e7490; border-color: #0e7490; color: #fff; }
|
||||
[data-filter-type="lifecycle"] .sf-chip[data-value="For Repair"].active { background: var(--yellow); border-color: var(--yellow); color: #fff; }
|
||||
[data-filter-type="lifecycle"] .sf-chip[data-value="For Upgrade"].active { background: var(--purple); border-color: var(--purple); color: #fff; }
|
||||
[data-filter-type="lifecycle"] .sf-chip[data-value="For Parts"].active { background: #b45309; border-color: #b45309; color: #fff; }
|
||||
[data-filter-type="lifecycle"] .sf-chip[data-value="Decommissioned"].active { background: var(--red); border-color: var(--red); color: #fff; }
|
||||
[data-filter-type="lifecycle"] .sf-chip[data-value="Disposed of"].active { background: var(--gray-500); border-color: var(--gray-500); color: #fff; }
|
||||
|
||||
.sf-clear-btn {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-400);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color .1s, background .1s;
|
||||
}
|
||||
|
||||
.sf-clear-btn:hover { color: var(--red); background: var(--red-bg); }
|
||||
|
||||
/* ── Search input ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-search {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-search input {
|
||||
width: 100%;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 28px 6px 10px;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color .15s;
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.sidebar-search input:focus {
|
||||
border-color: var(--primary);
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.sidebar-search-clear {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--gray-400);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
|
||||
.sidebar-search-clear:hover {
|
||||
color: var(--gray-700);
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
/* ── Tree scroll area ───────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-tree {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
will-change: scroll-position;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
/* ── Tree loading/empty states ──────────────────────────────────────────────── */
|
||||
|
||||
.sb-loading,
|
||||
.sb-empty {
|
||||
padding: 20px 16px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--gray-400);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Customer row (level 1) ─────────────────────────────────────────────────── */
|
||||
|
||||
.sb-customer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background .12s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.sb-customer:hover { background: #eef2f9; }
|
||||
.sb-customer.expanded { background: #f0f4fb; border-bottom: 1px solid var(--gray-200); }
|
||||
|
||||
.sb-customer-chevron {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--gray-400);
|
||||
transition: transform .2s;
|
||||
}
|
||||
|
||||
.sb-customer.expanded .sb-customer-chevron {
|
||||
transform: rotate(90deg);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.sb-customer-icon {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--primary);
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.sb-customer-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sb-customer-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--gray-400);
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: var(--gray-100);
|
||||
border-radius: 10px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* ── Asset list (level 2 container) ─────────────────────────────────────────── */
|
||||
|
||||
.sb-asset-list {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
border-left: 2px solid #d0ddf0;
|
||||
margin-left: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sb-asset-list.visible { display: flex; }
|
||||
|
||||
.sb-asset-loading,
|
||||
.sb-asset-empty {
|
||||
padding: 7px 12px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--gray-400);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Asset row (level 2) ────────────────────────────────────────────────────── */
|
||||
|
||||
.sb-asset {
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
transition: background .1s;
|
||||
border-left: 3px solid transparent;
|
||||
margin-left: -2px;
|
||||
}
|
||||
|
||||
.sb-asset:hover { background: #eef2f9; }
|
||||
|
||||
.sb-asset.active {
|
||||
background: #e8eef9;
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
|
||||
.sb-asset-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sb-asset.active .sb-asset-name { color: var(--primary); }
|
||||
|
||||
/* ── Asset meta row (badges + type) ─────────────────────────────────────────── */
|
||||
|
||||
.sb-asset-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sb-asset-type {
|
||||
font-size: 0.68rem;
|
||||
color: var(--gray-400);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Mini status badges ──────────────────────────────────────────────────────── */
|
||||
|
||||
.sb-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sb-poss-it { background: var(--green-bg); color: var(--green); border-color: #86efac; }
|
||||
.sb-poss-user { background: var(--yellow-bg); color: var(--yellow); border-color: #fde047; }
|
||||
.sb-infra { background: var(--gray-100); color: var(--gray-600); border-color: var(--gray-400); }
|
||||
|
||||
.sb-user-last { background: #f0f9ff; color: #0369a1; border-color: #7dd3fc; font-weight: 500; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sb-user-assigned { background: #fdf4ff; color: #7e22ce; border-color: #d8b4fe; font-weight: 500; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sb-user-same { background: #f0fdf4; color: #15803d; border-color: #86efac; font-weight: 500; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.sb-lc-predeployment { background: #cffafe; color: #0e7490; border-color: #67e8f9; }
|
||||
.sb-lc-active { background: var(--green-bg); color: var(--green); border-color: #86efac; }
|
||||
.sb-lc-inventory { background: var(--primary-50); color: var(--primary); border-color: #93c5fd; }
|
||||
.sb-lc-repair { background: var(--yellow-bg); color: var(--yellow); border-color: #fde047; }
|
||||
.sb-lc-upgrade { background: var(--purple-bg); color: var(--purple); border-color: #c4b5fd; }
|
||||
.sb-lc-parts { background: #fff3e0; color: #b45309; border-color: #fcd34d; }
|
||||
.sb-lc-decommissioned{ background: var(--red-bg); color: var(--red); border-color: #fca5a5; }
|
||||
.sb-lc-disposed { background: var(--gray-100); color: var(--gray-600);border-color: var(--gray-300); }
|
||||
.sb-lc-unknown { background: var(--gray-100); color: var(--gray-500);border-color: var(--gray-200); }
|
||||
|
||||
/* ── Mini spinner (reuses @keyframes spin from main.css) ────────────────────── */
|
||||
|
||||
.sb-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Menu sub-sections ──────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-menu-subsection { border-top: 1px solid var(--gray-100); }
|
||||
.sidebar-menu-subsection:first-child { border-top: none; }
|
||||
|
||||
.sidebar-menu-subsection-header {
|
||||
display: flex; align-items: center; gap: 6px; width: 100%;
|
||||
background: none; border: none; padding: 6px 2px;
|
||||
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .06em; color: var(--gray-400); cursor: pointer;
|
||||
font-family: inherit; text-align: left; user-select: none;
|
||||
transition: color .12s;
|
||||
}
|
||||
.sidebar-menu-subsection-header:hover { color: var(--gray-600); }
|
||||
|
||||
.subsection-chevron { width: 12px; height: 12px; flex-shrink: 0; transition: transform .2s; }
|
||||
.sidebar-menu-subsection.open .subsection-chevron { transform: rotate(180deg); }
|
||||
|
||||
.sidebar-menu-subsection-body {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
}
|
||||
/* Only animate subsection collapse/expand after the panel has fully opened */
|
||||
.sidebar-menu-panel.ready .sidebar-menu-subsection-body {
|
||||
transition: max-height .2s ease;
|
||||
}
|
||||
.sidebar-menu-subsection.open .sidebar-menu-subsection-body { max-height: 200px; }
|
||||
|
||||
/* ── Checkbox rows (inline wrap) ────────────────────────────────────────────── */
|
||||
.sidebar-menu-checkbox-row { display: flex; flex-wrap: wrap; gap: 2px 4px; padding-bottom: 4px; }
|
||||
|
||||
/* ── Label + control rows ───────────────────────────────────────────────────── */
|
||||
.sidebar-menu-row { display: flex; align-items: center; gap: 6px; padding: 3px 0; }
|
||||
.sidebar-menu-row-label { font-size: 0.72rem; color: var(--gray-500); white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
.sidebar-menu-select {
|
||||
font-size: 0.72rem; font-family: inherit; color: var(--gray-700);
|
||||
background: var(--white); border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-sm); padding: 2px 4px;
|
||||
cursor: pointer; outline: none; flex: 1;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.sidebar-menu-select:focus { border-color: var(--primary); }
|
||||
|
||||
/* ── Sort chips ─────────────────────────────────────────────────────────────── */
|
||||
.sidebar-sort-chips { display: flex; flex-wrap: wrap; gap: 4px; padding-bottom: 6px; }
|
||||
|
||||
.sidebar-sort-chip {
|
||||
font-size: 0.68rem; font-weight: 600; padding: 2px 7px;
|
||||
border-radius: 4px; border: 1px solid var(--gray-300);
|
||||
background: var(--white); color: var(--gray-500);
|
||||
cursor: pointer; font-family: inherit; line-height: 1.5;
|
||||
transition: background .1s, color .1s, border-color .1s;
|
||||
}
|
||||
.sidebar-sort-chip:hover { border-color: var(--primary-light); color: var(--primary); background: var(--primary-50); }
|
||||
.sidebar-sort-chip.active { background: var(--primary); border-color: var(--primary); color: var(--white); }
|
||||
|
||||
/* ── Menu panel footer (remember toggle) ───────────────────────────────────── */
|
||||
.sidebar-menu-footer {
|
||||
border-top: 1px solid var(--gray-100);
|
||||
padding-top: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-menu-remember {
|
||||
font-size: 0.72rem;
|
||||
color: var(--gray-500);
|
||||
padding: 3px 2px;
|
||||
}
|
||||
|
||||
.sidebar-menu-remember input[type="checkbox"] {
|
||||
accent-color: var(--primary);
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
/* ── Filter panel footer (remember toggle) ──────────────────────────────────── */
|
||||
.sf-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.sf-remember-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--gray-400);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sf-remember-label input[type="checkbox"] {
|
||||
accent-color: var(--primary);
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Billable count badge ───────────────────────────────────────────────────── */
|
||||
.sb-customer-billable {
|
||||
font-size: 0.7rem; font-variant-numeric: tabular-nums;
|
||||
background: var(--green-bg); color: var(--green);
|
||||
border-radius: 10px; padding: 1px 6px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Mobile: sidebar overlays content ───────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 64px;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
#app-main {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
56
public/styles/variables.css
Executable file
56
public/styles/variables.css
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
/* ===== Design Tokens — single source of truth =====
|
||||
Import this file first in every page before any other stylesheet.
|
||||
Corner radii follow the WinUI 3 spec:
|
||||
--radius-sm → ControlCornerRadius (4px) buttons, inputs, small controls
|
||||
--radius → ControlCornerRadius (4px) standard controls
|
||||
--radius-lg → OverlayCornerRadius (8px) cards, panels, flyouts, dialogs
|
||||
===================================================== */
|
||||
:root {
|
||||
/* Brand */
|
||||
--primary: #2B5499;
|
||||
--primary-dark: #1a3565;
|
||||
--primary-mid: #234480;
|
||||
--primary-light: #3a6dbf;
|
||||
--primary-50: #e8eef8;
|
||||
|
||||
--accent: #C4622A;
|
||||
--accent-dark: #a3501f;
|
||||
--accent-light: #e07a3a;
|
||||
--accent-50: #fde8d8;
|
||||
|
||||
/* Semantic colours */
|
||||
--green: #15803d;
|
||||
--green-bg: #dcfce7;
|
||||
--yellow: #a16207;
|
||||
--yellow-bg: #fef9c3;
|
||||
--red: #b91c1c;
|
||||
--red-bg: #fee2e2;
|
||||
--purple: #7c3aed;
|
||||
--purple-bg: #ede9fe;
|
||||
|
||||
/* Greys */
|
||||
--gray-900: #111827;
|
||||
--gray-800: #1f2937;
|
||||
--gray-700: #374151;
|
||||
--gray-600: #4b5563;
|
||||
--gray-500: #6b7280;
|
||||
--gray-400: #9ca3af;
|
||||
--gray-300: #d1d5db;
|
||||
--gray-200: #e5e7eb;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-50: #f9fafb;
|
||||
--white: #ffffff;
|
||||
|
||||
/* Corner radii — WinUI 3 */
|
||||
--radius-sm: 4px; /* ControlCornerRadius — buttons, inputs, chips */
|
||||
--radius: 4px; /* ControlCornerRadius — standard controls */
|
||||
--radius-lg: 8px; /* OverlayCornerRadius — cards, panels, flyouts */
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,.10), 0 1px 2px rgba(0,0,0,.06);
|
||||
--shadow: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.06);
|
||||
--shadow-lg: 0 10px 25px rgba(0,0,0,.12), 0 4px 10px rgba(0,0,0,.08);
|
||||
|
||||
/* Typography */
|
||||
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
2
public/vendor/JsBarcode.all.min.js
vendored
Executable file
2
public/vendor/JsBarcode.all.min.js
vendored
Executable file
File diff suppressed because one or more lines are too long
106
scripts/create-user.js
Executable file
106
scripts/create-user.js
Executable file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// Usage:
|
||||
// node scripts/create-user.js — interactive: create a user
|
||||
// node scripts/create-user.js list — list all users
|
||||
// node scripts/create-user.js deactivate 3 — deactivate user by ID
|
||||
// node scripts/create-user.js reset 3 — reset a user's password
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const readline = require('readline');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Ensure data dir exists before DB init
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
const { initDb, getAllUsers, createUser, updateUser } = require('../auth/db');
|
||||
|
||||
const ROLES = ['superduperadmin', 'admin', 'tech', 'client'];
|
||||
|
||||
initDb();
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
|
||||
|
||||
async function promptPassword(label = 'Password') {
|
||||
const pass = await ask(`${label} (min 12 chars): `);
|
||||
const confirm = await ask('Confirm password: ');
|
||||
if (pass !== confirm) { console.error('Passwords do not match.'); process.exit(1); }
|
||||
if (pass.length < 12) { console.error('Password must be at least 12 characters.'); process.exit(1); }
|
||||
return pass;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [,, cmd, arg] = process.argv;
|
||||
|
||||
if (cmd === 'list') {
|
||||
const users = getAllUsers();
|
||||
if (!users.length) { console.log('No users found.'); }
|
||||
else {
|
||||
console.log('\n ID Username Role Company Name');
|
||||
console.log(' ───────────────────────────────────────────────────────────────────────');
|
||||
for (const u of users) {
|
||||
const status = u.active ? '' : ' [INACTIVE]';
|
||||
console.log(` ${String(u.id).padEnd(4)}${u.username.padEnd(21)}${u.role.padEnd(19)}${(u.company || '—').padEnd(21)}${u.name}${status}`);
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'deactivate') {
|
||||
const id = parseInt(arg);
|
||||
if (!id) { console.error('Usage: create-user.js deactivate <id>'); rl.close(); return; }
|
||||
updateUser(id, { active: 0 });
|
||||
console.log(`User ${id} deactivated.`);
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'reset') {
|
||||
const id = parseInt(arg);
|
||||
if (!id) { console.error('Usage: create-user.js reset <id>'); rl.close(); return; }
|
||||
const pass = await promptPassword('New password');
|
||||
const hash = await bcrypt.hash(pass, 12);
|
||||
updateUser(id, { password_hash: hash });
|
||||
console.log(`Password reset for user ${id}.`);
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: create new user
|
||||
console.log('\n── Create New User ─────────────────────────────\n');
|
||||
const username = (await ask('Username: ')).trim().toLowerCase();
|
||||
if (!username) { console.error('Username cannot be empty.'); rl.close(); return; }
|
||||
|
||||
const name = (await ask('Full name: ')).trim();
|
||||
if (!name) { console.error('Name cannot be empty.'); rl.close(); return; }
|
||||
|
||||
const company = (await ask('Company (optional, press Enter to skip): ')).trim();
|
||||
|
||||
console.log(`Roles: ${ROLES.join(' | ')}`);
|
||||
const role = (await ask('Role: ')).trim().toLowerCase();
|
||||
if (!ROLES.includes(role)) {
|
||||
console.error(`Invalid role. Choose from: ${ROLES.join(', ')}`);
|
||||
rl.close(); return;
|
||||
}
|
||||
|
||||
const pass = await promptPassword();
|
||||
const hash = await bcrypt.hash(pass, 12);
|
||||
|
||||
try {
|
||||
const result = createUser(username, hash, name, role, company);
|
||||
console.log(`\n✓ User "${username}" created (id=${result.lastInsertRowid}) with role "${role}"${company ? ` at "${company}"` : ''}.`);
|
||||
} catch (err) {
|
||||
if (err.message.includes('UNIQUE')) console.error(`Username "${username}" already exists.`);
|
||||
else console.error(err.message);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err.message); rl.close(); process.exit(1); });
|
||||
476
server.js
Executable file
476
server.js
Executable file
|
|
@ -0,0 +1,476 @@
|
|||
'use strict';
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const helmet = require('helmet');
|
||||
const { rateLimit } = require('express-rate-limit');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const { initDb, getDb } = require('./auth/db');
|
||||
const { logRequest: _logSyncroRequest } = require('./syncroStats');
|
||||
const { createSessionStore } = require('./auth/sessionStore');
|
||||
const { requireAuth, requireRole } = require('./auth/middleware');
|
||||
const authRoutes = require('./auth/routes');
|
||||
const adminRoutes = require('./auth/admin');
|
||||
|
||||
// ── Validate required environment ────────────────────────────────────────────
|
||||
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET.length < 32) {
|
||||
console.error('FATAL: SESSION_SECRET is missing or too short. Set it in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!process.env.SYNCRO_BASE_URL || !process.env.SYNCRO_API_KEY) {
|
||||
console.error('FATAL: SYNCRO_BASE_URL and SYNCRO_API_KEY must be set in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Boot ID — changes on every restart so clients can detect and reload ───────
|
||||
const SERVER_BOOT_ID = Date.now().toString(36);
|
||||
|
||||
// ── Syncro in-memory cache ────────────────────────────────────────────────────
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const _cache = {
|
||||
customers: { data: null, ts: 0 },
|
||||
assets: new Map(), // customerId (number) → { data: [], ts: number }
|
||||
contacts: new Map(), // customerId (number) → { data: [], ts: number }
|
||||
};
|
||||
let _refreshCooldownUntil = 0;
|
||||
const _inflight = {
|
||||
customers: null, // Promise | null
|
||||
assets: new Map(), // customerId → Promise
|
||||
contacts: new Map(), // customerId → Promise
|
||||
};
|
||||
|
||||
// ── Syncro HTTP helpers ───────────────────────────────────────────────────────
|
||||
async function syncroGet(path, params = {}) {
|
||||
const url = new URL(process.env.SYNCRO_BASE_URL + path);
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
||||
_logSyncroRequest();
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: process.env.SYNCRO_API_KEY, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Syncro ${res.status} on GET ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function syncroPut(path, body) {
|
||||
const url = new URL(process.env.SYNCRO_BASE_URL + path);
|
||||
_logSyncroRequest();
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: process.env.SYNCRO_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 204) return {};
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error ?? data.message ?? `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Paginated Syncro fetchers ─────────────────────────────────────────────────
|
||||
async function _fetchCustomers() {
|
||||
let page = 1, all = [];
|
||||
while (true) {
|
||||
const data = await syncroGet('/customers', { per_page: 100, page });
|
||||
all = all.concat(data.customers ?? []);
|
||||
if (page >= (data.meta?.total_pages ?? 1)) break;
|
||||
page++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function _fetchCustomerAssets(customerId) {
|
||||
let page = 1, all = [];
|
||||
while (true) {
|
||||
const data = await syncroGet('/customer_assets', { customer_id: customerId, per_page: 250, page });
|
||||
all = all.concat(data.assets ?? data.customer_assets ?? []);
|
||||
if (page >= (data.meta?.total_pages ?? 1)) break;
|
||||
page++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function _fetchContacts(customerId) {
|
||||
let page = 1, all = [];
|
||||
while (true) {
|
||||
const data = await syncroGet('/contacts', { customer_id: customerId, per_page: 50, page });
|
||||
all = all.concat(data.contacts ?? []);
|
||||
if (page >= (data.meta?.total_pages ?? 1)) break;
|
||||
page++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// ── Cache-with-dedup accessors ────────────────────────────────────────────────
|
||||
async function cachedCustomers() {
|
||||
const now = Date.now();
|
||||
if (_cache.customers.data && now - _cache.customers.ts < CACHE_TTL_MS) return _cache.customers.data;
|
||||
if (_inflight.customers) return _inflight.customers;
|
||||
_inflight.customers = _fetchCustomers().then(data => {
|
||||
_cache.customers = { data, ts: Date.now() };
|
||||
_inflight.customers = null;
|
||||
return data;
|
||||
}).catch(err => { _inflight.customers = null; throw err; });
|
||||
return _inflight.customers;
|
||||
}
|
||||
|
||||
async function cachedAssets(customerId) {
|
||||
const id = Number(customerId);
|
||||
const now = Date.now();
|
||||
const entry = _cache.assets.get(id);
|
||||
if (entry && now - entry.ts < CACHE_TTL_MS) return entry.data;
|
||||
if (_inflight.assets.has(id)) return _inflight.assets.get(id);
|
||||
const p = _fetchCustomerAssets(id).then(data => {
|
||||
_cache.assets.set(id, { data, ts: Date.now() });
|
||||
_inflight.assets.delete(id);
|
||||
return data;
|
||||
}).catch(err => { _inflight.assets.delete(id); throw err; });
|
||||
_inflight.assets.set(id, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function cachedContacts(customerId) {
|
||||
const id = Number(customerId);
|
||||
const now = Date.now();
|
||||
const entry = _cache.contacts.get(id);
|
||||
if (entry && now - entry.ts < CACHE_TTL_MS) return entry.data;
|
||||
if (_inflight.contacts.has(id)) return _inflight.contacts.get(id);
|
||||
const p = _fetchContacts(id).then(data => {
|
||||
_cache.contacts.set(id, { data, ts: Date.now() });
|
||||
_inflight.contacts.delete(id);
|
||||
return data;
|
||||
}).catch(err => { _inflight.contacts.delete(id); throw err; });
|
||||
_inflight.contacts.set(id, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── Bootstrap DB ─────────────────────────────────────────────────────────────
|
||||
const dataDir = path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
initDb();
|
||||
|
||||
// ── App ───────────────────────────────────────────────────────────────────────
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT ?? '3000', 10);
|
||||
|
||||
// Trust CloudPanel's nginx reverse proxy (needed for secure cookies over HTTPS)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// ── Security headers ─────────────────────────────────────────────────────────
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", 'https://cdn.jsdelivr.net'], // wasm-unsafe-eval needed for WASM instantiation; jsdelivr for barcode-detector polyfill
|
||||
scriptSrcAttr: ["'unsafe-inline'"], // needed for onclick= handlers in generated popup HTML
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], // needed for inline styles in JS-rendered HTML
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'", 'https://cdn.jsdelivr.net', 'https://fastly.jsdelivr.net'], // fastly.jsdelivr.net serves the .wasm binary via CDN edge
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false, // not needed for this app
|
||||
}));
|
||||
|
||||
// ── Body parsing ──────────────────────────────────────────────────────────────
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: false, limit: '1mb' }));
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────────
|
||||
app.use(session({
|
||||
store: createSessionStore(getDb()),
|
||||
secret: process.env.SESSION_SECRET,
|
||||
name: 'sid',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true, // slide expiry on activity
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 8 * 60 * 60 * 1000, // 8 hours
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Rate limiting (login endpoint only) ───────────────────────────────────────
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true,
|
||||
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
|
||||
});
|
||||
|
||||
// ── Public routes (no auth required) ─────────────────────────────────────────
|
||||
app.post('/auth/login', loginLimiter);
|
||||
app.use('/auth', authRoutes);
|
||||
|
||||
// Serve login page assets without auth
|
||||
const pub = path.join(__dirname, 'public');
|
||||
app.get('/login.html', (req, res) => res.sendFile(path.join(pub, 'login.html')));
|
||||
app.get('/login.js', (req, res) => res.sendFile(path.join(pub, 'login.js')));
|
||||
app.get('/styles/login.css', (req, res) => res.sendFile(path.join(pub, 'styles', 'login.css')));
|
||||
app.get('/styles/variables.css', (req, res) => res.sendFile(path.join(pub, 'styles', 'variables.css')));
|
||||
app.use('/assets', express.static(path.join(pub, 'assets')));
|
||||
|
||||
// ── Auth wall ─────────────────────────────────────────────────────────────────
|
||||
app.use(requireAuth);
|
||||
|
||||
// ── Admin API (superduperadmin + admin only) ───────────────────────────────────
|
||||
app.use('/admin', requireRole('superduperadmin', 'admin'), adminRoutes);
|
||||
|
||||
// ── Health / boot ID — lets clients detect a restart and reload ───────────────
|
||||
app.get('/api/ping', (_req, res) => {
|
||||
res.json({ ok: true, bootId: SERVER_BOOT_ID });
|
||||
});
|
||||
|
||||
// ── Syncro API cache routes (auth-gated) ──────────────────────────────────────
|
||||
|
||||
app.get('/api/customers', async (req, res) => {
|
||||
try {
|
||||
const user = req.session.user;
|
||||
if (user.role === 'client') {
|
||||
if (!user.syncro_customer_id) return res.json({ customers: [] });
|
||||
const all = await cachedCustomers();
|
||||
return res.json({ customers: all.filter(c => c.id === user.syncro_customer_id) });
|
||||
}
|
||||
res.json({ customers: await cachedCustomers() });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/customer_assets/:customerId', async (req, res) => {
|
||||
const user = req.session.user;
|
||||
if (user.role === 'client') {
|
||||
if (!user.syncro_customer_id) return res.status(403).json({ error: 'Forbidden.' });
|
||||
if (Number(req.params.customerId) !== user.syncro_customer_id)
|
||||
return res.status(403).json({ error: 'Forbidden.' });
|
||||
}
|
||||
try {
|
||||
const assets = await cachedAssets(req.params.customerId);
|
||||
res.json({ assets });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/contacts/:customerId', async (req, res) => {
|
||||
const user = req.session.user;
|
||||
if (user.role === 'client') {
|
||||
if (!user.syncro_customer_id) return res.status(403).json({ error: 'Forbidden.' });
|
||||
if (Number(req.params.customerId) !== user.syncro_customer_id)
|
||||
return res.status(403).json({ error: 'Forbidden.' });
|
||||
}
|
||||
try {
|
||||
const contacts = await cachedContacts(req.params.customerId);
|
||||
res.json({ contacts });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Write-through: forward PUT to Syncro then invalidate that customer's asset cache
|
||||
app.put('/api/customer_assets/:id', async (req, res) => {
|
||||
if (req.session.user.role === 'client') return res.status(403).json({ error: 'Forbidden.' });
|
||||
const { customer_id, ...assetBody } = req.body;
|
||||
try {
|
||||
const result = await syncroPut(`/customer_assets/${req.params.id}`, assetBody);
|
||||
if (customer_id) _cache.assets.delete(Number(customer_id));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Label Center queue ────────────────────────────────────────────────────────
|
||||
|
||||
function getLabelQueue(userId) {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM label_queue WHERE user_id = ? ORDER BY added_at ASC')
|
||||
.all(userId);
|
||||
}
|
||||
|
||||
app.get('/api/label-queue', (req, res) => {
|
||||
try {
|
||||
res.json({ queue: getLabelQueue(req.session.user.id) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/label-queue', (req, res) => {
|
||||
const { asset_id, asset_name, asset_serial, customer_name, customer_phone, custom_line } = req.body;
|
||||
if (!asset_id || !asset_name || !customer_name) {
|
||||
return res.status(400).json({ error: 'asset_id, asset_name, and customer_name are required' });
|
||||
}
|
||||
const uid = req.session.user.id;
|
||||
try {
|
||||
getDb()
|
||||
.prepare(`INSERT OR REPLACE INTO label_queue
|
||||
(user_id, asset_id, asset_name, asset_serial, customer_name, customer_phone, custom_line, sheet_position, added_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?,
|
||||
(SELECT sheet_position FROM label_queue WHERE user_id = ? AND asset_id = ?),
|
||||
COALESCE(
|
||||
(SELECT added_at FROM label_queue WHERE user_id = ? AND asset_id = ?),
|
||||
datetime('now')
|
||||
))`)
|
||||
.run(uid, asset_id, asset_name, asset_serial ?? null,
|
||||
customer_name, customer_phone ?? null, custom_line ?? null,
|
||||
uid, asset_id,
|
||||
uid, asset_id);
|
||||
res.json({ queue: getLabelQueue(uid) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/label-queue/:assetId', (req, res) => {
|
||||
const assetId = parseInt(req.params.assetId, 10);
|
||||
if (isNaN(assetId)) return res.status(400).json({ error: 'Invalid assetId' });
|
||||
const { sheet_position } = req.body;
|
||||
if (sheet_position !== null && (typeof sheet_position !== 'number' || !Number.isInteger(sheet_position) || sheet_position < 1)) {
|
||||
return res.status(400).json({ error: 'sheet_position must be a positive integer or null' });
|
||||
}
|
||||
const uid = req.session.user.id;
|
||||
try {
|
||||
getDb()
|
||||
.prepare('UPDATE label_queue SET sheet_position = ? WHERE user_id = ? AND asset_id = ?')
|
||||
.run(sheet_position ?? null, uid, assetId);
|
||||
res.json({ queue: getLabelQueue(uid) });
|
||||
} catch (err) {
|
||||
// UNIQUE constraint on (user_id, sheet_position) — position already taken
|
||||
if (err.message.includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: 'That position is already occupied' });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/label-queue/:assetId', (req, res) => {
|
||||
const assetId = parseInt(req.params.assetId, 10);
|
||||
if (isNaN(assetId)) return res.status(400).json({ error: 'Invalid assetId' });
|
||||
const { asset_name, asset_serial, customer_name, customer_phone, custom_line } = req.body;
|
||||
if (!asset_name || !customer_name) return res.status(400).json({ error: 'asset_name and customer_name are required' });
|
||||
const uid = req.session.user.id;
|
||||
try {
|
||||
getDb()
|
||||
.prepare('UPDATE label_queue SET asset_name = ?, asset_serial = ?, customer_name = ?, customer_phone = ?, custom_line = ? WHERE user_id = ? AND asset_id = ?')
|
||||
.run(asset_name, asset_serial ?? null, customer_name, customer_phone ?? null, custom_line ?? null, uid, assetId);
|
||||
res.json({ queue: getLabelQueue(uid) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/label-queue/:assetId', (req, res) => {
|
||||
const assetId = parseInt(req.params.assetId, 10);
|
||||
if (isNaN(assetId)) return res.status(400).json({ error: 'Invalid assetId' });
|
||||
const uid = req.session.user.id;
|
||||
try {
|
||||
getDb()
|
||||
.prepare('DELETE FROM label_queue WHERE user_id = ? AND asset_id = ?')
|
||||
.run(uid, assetId);
|
||||
res.json({ queue: getLabelQueue(uid) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/label-queue', (req, res) => {
|
||||
const uid = req.session.user.id;
|
||||
try {
|
||||
if (req.body?.reset_sheet_only) {
|
||||
getDb()
|
||||
.prepare('UPDATE label_queue SET sheet_position = NULL WHERE user_id = ?')
|
||||
.run(uid);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare('DELETE FROM label_queue WHERE user_id = ?')
|
||||
.run(uid);
|
||||
}
|
||||
res.json({ queue: getLabelQueue(uid) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Cache refresh with 30s global cooldown
|
||||
app.post('/api/cache/refresh', (req, res) => {
|
||||
if (req.session.user.role === 'client') return res.status(403).json({ error: 'Forbidden.' });
|
||||
const now = Date.now();
|
||||
if (now < _refreshCooldownUntil) {
|
||||
return res.status(429).json({
|
||||
error: 'Refresh cooldown active',
|
||||
retryAfter: Math.ceil((_refreshCooldownUntil - now) / 1000),
|
||||
});
|
||||
}
|
||||
_refreshCooldownUntil = now + 30_000;
|
||||
_cache.customers = { data: null, ts: 0 };
|
||||
_cache.assets.clear();
|
||||
_cache.contacts.clear();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Syncro single-asset proxy routes (ownership-checked, replaces /syncro-api/ bypass) ──
|
||||
// Must register /api/asset/search BEFORE /api/asset/:id or "search" matches the :id param.
|
||||
|
||||
app.get('/api/asset/search', async (req, res) => {
|
||||
const user = req.session.user;
|
||||
try {
|
||||
const params = { per_page: req.query.per_page ?? 10 };
|
||||
if (req.query.query) params.query = req.query.query;
|
||||
if (user.role === 'client') {
|
||||
if (!user.syncro_customer_id) return res.json({ assets: [], customer_assets: [] });
|
||||
params.customer_id = user.syncro_customer_id;
|
||||
}
|
||||
res.json(await syncroGet('/customer_assets', params));
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/asset/:id', async (req, res) => {
|
||||
try {
|
||||
const data = await syncroGet(`/customer_assets/${req.params.id}`);
|
||||
const asset = data.asset ?? data;
|
||||
const user = req.session.user;
|
||||
if (user.role === 'client') {
|
||||
if (!user.syncro_customer_id) return res.status(403).json({ error: 'Forbidden.' });
|
||||
if (asset.customer_id !== user.syncro_customer_id)
|
||||
return res.status(403).json({ error: 'Forbidden.' });
|
||||
}
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
const status = err.message.includes('404') ? 404 : 502;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Static app files (auth-gated) ─────────────────────────────────────────────
|
||||
// JS and CSS: no-cache so browsers always revalidate (ETags mean no wasted
|
||||
// bandwidth when files haven't changed — just a fast 304).
|
||||
app.use((req, res, next) => {
|
||||
if (/\.(js|css)$/.test(req.path)) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.use(express.static(pub));
|
||||
|
||||
// Catch-all: return index.html for any unmatched GET (SPA)
|
||||
app.get('*', (req, res) => res.sendFile(path.join(pub, 'index.html')));
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
app.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`Asset Browser listening on port ${PORT}`);
|
||||
});
|
||||
100
syncroStats.js
Executable file
100
syncroStats.js
Executable file
|
|
@ -0,0 +1,100 @@
|
|||
'use strict';
|
||||
|
||||
// Rolling request log for Syncro API calls.
|
||||
// Shared between server.js (writes) and auth/admin.js (reads).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PERSIST_PATH = path.join(__dirname, 'data', 'syncro-history.json');
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const PERSIST_INTERVAL_MS = 60_000; // flush to disk at most once per minute
|
||||
|
||||
const _log = []; // timestamps for rolling 60s count
|
||||
const _buckets = new Map(); // minuteTs (ms) → request count
|
||||
|
||||
// ── Load persisted history on startup ────────────────────────────────────────
|
||||
(function _loadHistory() {
|
||||
try {
|
||||
const raw = fs.readFileSync(PERSIST_PATH, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
const cutoff = Date.now() - SEVEN_DAYS_MS;
|
||||
for (const [tsStr, count] of Object.entries(data)) {
|
||||
const ts = Number(tsStr);
|
||||
if (ts >= cutoff) _buckets.set(ts, count);
|
||||
}
|
||||
} catch (_) {
|
||||
// File missing or corrupt — start fresh, no problem
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Persist helper ────────────────────────────────────────────────────────────
|
||||
let _persistTimer = null;
|
||||
|
||||
function _schedulePersist() {
|
||||
if (_persistTimer) return;
|
||||
_persistTimer = setTimeout(() => {
|
||||
_persistTimer = null;
|
||||
_flushToDisk();
|
||||
}, PERSIST_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function _flushToDisk() {
|
||||
try {
|
||||
const obj = {};
|
||||
for (const [ts, count] of _buckets) obj[ts] = count;
|
||||
fs.writeFileSync(PERSIST_PATH, JSON.stringify(obj));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
function logRequest() {
|
||||
const now = Date.now();
|
||||
|
||||
// Rolling 60s log
|
||||
_log.push(now);
|
||||
while (_log.length && _log[0] < now - 60_000) _log.shift();
|
||||
|
||||
// Per-minute bucket
|
||||
const minuteTs = Math.floor(now / 60_000) * 60_000;
|
||||
_buckets.set(minuteTs, (_buckets.get(minuteTs) ?? 0) + 1);
|
||||
|
||||
// Prune buckets older than 7 days (Map is in insertion/chronological order)
|
||||
const cutoff = now - SEVEN_DAYS_MS;
|
||||
for (const ts of _buckets.keys()) {
|
||||
if (ts < cutoff) _buckets.delete(ts);
|
||||
else break;
|
||||
}
|
||||
|
||||
_schedulePersist();
|
||||
}
|
||||
|
||||
function getUsage() {
|
||||
const now = Date.now();
|
||||
while (_log.length && _log[0] < now - 60_000) _log.shift();
|
||||
return _log.length;
|
||||
}
|
||||
|
||||
// Returns an array of { ts, count } for the given window (capped at 7 days).
|
||||
// Already in ascending chronological order.
|
||||
function getHistory(windowMs) {
|
||||
const now = Date.now();
|
||||
const cutoff = now - Math.min(windowMs, SEVEN_DAYS_MS);
|
||||
const result = [];
|
||||
for (const [ts, count] of _buckets) {
|
||||
if (ts >= cutoff) result.push({ ts, count });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Returns the number of minute buckets in the last 7 days where requests >= limit.
|
||||
function getLimitHits(limit) {
|
||||
const cutoff = Date.now() - SEVEN_DAYS_MS;
|
||||
let hits = 0;
|
||||
for (const [ts, count] of _buckets) {
|
||||
if (ts >= cutoff && count >= limit) hits++;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
module.exports = { logRequest, getUsage, getHistory, getLimitHits };
|
||||
Loading…
Reference in a new issue