Initial commit — asset browser web app

This commit is contained in:
setonc 2026-03-27 09:06:25 -04:00
commit e9506b400f
49 changed files with 13981 additions and 0 deletions

9
.env.example Executable file
View 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
View file

@ -0,0 +1,4 @@
node_modules/
data/
.env
*.log

125
auth/admin.js Executable file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

21
package.json Executable file
View 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
View 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
View 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 12 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

1013
public/admin/index.html Executable file

File diff suppressed because it is too large Load diff

72
public/api/syncro.js Executable file
View 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
View 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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
public/assets/logo-text.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

15
public/config.js Executable file
View 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
View 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">AZ</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">AZ</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 &mdash; 2.625&quot; &times; 1&quot; (30-up)</option>
<option value="OL25LP">OL25LP &mdash; 1.75&quot; &times; 0.5&quot; (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">&times;</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
View 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">
&copy; <span id="login-year"></span> Carmichael Computing
</div>
</div>
<script src="/login.js"></script>
</body>
</html>

69
public/login.js Executable file
View 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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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

File diff suppressed because it is too large Load diff

391
public/modules/assetCard.js Executable file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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

File diff suppressed because it is too large Load diff

81
public/modules/labelGen.js Executable file
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

128
public/modules/scanner.js Executable file
View 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);
}

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

106
public/modules/ticketHistory.js Executable file
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

17
public/modules/toast.js Executable file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

138
public/styles/login.css Executable file
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

106
scripts/create-user.js Executable file
View 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
View 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
View 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 };