// app.js — entry point: initialises all modules, wires scan flow and mode switching. import CONFIG from './config.js'; import { getAsset, searchAssets, updateAsset } from './api/syncro.js'; import { initScanner, resetIdleTimer, focusScanInput, cancelIdleTimer, setTimerDuration, getTimerDuration, setKeyInterceptor, pause as pauseScanner, resume as resumeScanner } from './modules/scanner.js'; import { init as initCameraScanner, start as startCamera, stop as stopCamera, isActive as isCameraActive } from './modules/cameraScanner.js'; import { renderAssetCard } from './modules/assetCard.js'; import { initActions } from './modules/actions.js'; import { initTicketHistory } from './modules/ticketHistory.js'; import { showToast } from './modules/toast.js'; import { initAssetBrowser, setActiveAsset, setFiltersAndRender } from './modules/assetBrowser.js'; import { initSearchAutocomplete, handleAutocompleteKey } from './modules/searchAutocomplete.js'; import { initLabelCenter, closeLabelCenter, refreshBadge } from './modules/labelCenter.js'; import { renderClientDashboard } from './modules/clientDashboard.js'; // ── View management ─────────────────────────────────────────────────────────── const VIEWS = ['neutral', 'idle', 'loading', 'asset', 'search-results', 'quick-view', 'error']; // ── Timer cycle ─────────────────────────────────────────────────────────────── const TIMER_STEPS = [30_000, 60_000, 300_000, 900_000, null]; const TIMER_LABELS = ['Timer: 30s', 'Timer: 1m', 'Timer: 5m', 'Timer: 15m', 'Timer: OFF']; function _updateTimerButton(ms) { const idx = TIMER_STEPS.indexOf(ms); document.getElementById('timer-toggle-label').textContent = TIMER_LABELS[idx] ?? 'Timer: OFF'; document.getElementById('btn-timer-toggle')?.classList.toggle('active', ms !== null); } // Views worth returning to when closing an asset card const _BACK_VIEWS = new Set(['neutral', 'idle', 'search-results', 'quick-view']); let _returnView = 'neutral'; export function showView(name) { // Capture where we came from so closeAssetView() can go back there if (name === 'asset') { const current = VIEWS.find(v => document.getElementById(`view-${v}`)?.classList.contains('active')); if (current && _BACK_VIEWS.has(current)) _returnView = current; } VIEWS.forEach(v => { const el = document.getElementById(`view-${v}`); if (el) el.classList.toggle('active', v === name); }); if (name === 'quick-view') { document.getElementById('btn-quick-view')?.classList.add('active'); } else { document.getElementById('btn-quick-view')?.classList.remove('active'); } } export function closeAssetView() { setActiveAsset(null); const card = document.querySelector('.asset-card'); if (card) { card.classList.add('closing'); setTimeout(() => showView(_returnView), 180); } else { showView(_returnView); } } // ── Scan mode state ─────────────────────────────────────────────────────────── let scanModeActive = false; function setScanMode(active) { scanModeActive = active; setActiveMode(active ? 'scan' : null); localStorage.setItem('appMode', active ? 'scan' : 'label'); } // ── Scan flow ───────────────────────────────────────────────────────────────── async function handleScan(rawValue) { const value = rawValue.trim(); if (!value) return; showView('loading'); try { if (/^\d+$/.test(value)) { await resolveById(parseInt(value, 10)); } else { await resolveBySearch(value); } } catch (err) { showView('error'); document.getElementById('error-container').innerHTML = errorCardHTML( 'Asset Not Found', err.message, value ); } } async function resolveById(id) { let asset; try { asset = await getAsset(id); } catch (err) { if (err.message.includes('404') || err.message.toLowerCase().includes('not found')) { await resolveBySearch(String(id)); return; } throw err; } await showAsset(asset); } async function resolveBySearch(query) { const assets = await searchAssets(query); if (!assets.length) { throw new Error(`No asset found matching "${query}"`); } if (assets.length === 1) { await showAsset(assets[0]); return; } showSearchResults(assets, query); } async function showAsset(asset) { stampScan(asset); renderAssetCard(asset); showView('asset'); initActions(asset); initTicketHistory(asset); setActiveAsset(asset.id); resetIdleTimer(); } function stampScan(asset) { if (window._currentUser?.role === 'client') return; updateAsset(asset.id, { properties: { 'Last Scan Date': new Date().toISOString().split('T')[0], 'Last Action': asset.properties?.['Last Action'] ?? 'Scanned', }, }).catch(() => {}); } // ── Search results picker ───────────────────────────────────────────────────── function showSearchResults(assets, query) { const container = document.getElementById('search-results-container'); container.innerHTML = `

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

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

${esc(title)}

${esc(message)}

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