asset_browser/public/app.js
2026-03-27 09:18:39 -04:00

603 lines
23 KiB
JavaScript
Executable file

// 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;');
}