/** * OTRS Turbo — Core Application * SPA router, API client, lookup cache, and utility functions. */ const App = { lookups: { queues: [], states: [], priorities: [], users: [], types: [], }, lookupsLoaded: false, demotivationalPhrases: [], motivationalPhrases: [], /** Initialize the application */ init() { this.initTheme(); this.loadDemotivationalPhrases(); this.loadMotivationalPhrases(); Toast.init(); // Hash-based SPA router window.addEventListener('hashchange', () => this.route()); // Global search const searchInput = document.getElementById('global-search'); const searchClearBtn = document.getElementById('global-search-clear'); if (searchInput) { const toggleClearBtn = () => { if (searchClearBtn) { searchClearBtn.style.display = searchInput.value.trim() ? 'flex' : 'none'; } }; let timeout; searchInput.addEventListener('input', () => { toggleClearBtn(); clearTimeout(timeout); timeout = setTimeout(() => { const hash = window.location.hash; if (hash.startsWith('#/tickets') && !hash.includes('/new') && !hash.match(/#\/tickets\/\d+/)) { TicketListView.currentPage = 1; TicketListView.render(); } else { // Navigate to ticket list with search window.location.hash = '#/tickets'; } }, 350); }); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const hash = window.location.hash; if (!hash.startsWith('#/tickets') || hash.includes('/new') || hash.match(/#\/tickets\/\d+/)) { window.location.hash = '#/tickets'; } else { TicketListView.currentPage = 1; TicketListView.render(); } } }); if (searchClearBtn) { searchClearBtn.addEventListener('click', () => { searchInput.value = ''; searchClearBtn.style.display = 'none'; searchInput.focus(); const hash = window.location.hash; if (hash.startsWith('#/tickets') && !hash.includes('/new') && !hash.match(/#\/tickets\/\d+/)) { TicketListView.currentPage = 1; TicketListView.render(); } else { window.location.hash = '#/tickets'; } }); } // Initial state of clear button toggleClearBtn(); } // Check DB connection this.checkConnection(); // Init active agent selector this.initAgentSelector(); // Bind refresh lookups button const refreshBtn = document.getElementById('refresh-lookups-btn'); if (refreshBtn) { refreshBtn.addEventListener('click', () => this.refreshLookups()); } // Initial route if (!window.location.hash || window.location.hash === '#/') { window.location.hash = '#/dashboard'; } else { this.route(); } }, /** Route based on current hash */ route() { const fullHash = window.location.hash || '#/dashboard'; const hash = fullHash.split('?')[0]; const titleEl = document.getElementById('page-title'); // Update active nav link document.querySelectorAll('.nav-link').forEach(link => { link.classList.remove('active'); }); if (hash === '#/dashboard') { document.getElementById('nav-dashboard')?.classList.add('active'); titleEl.textContent = 'Dashboard'; DashboardView.render(); } else if (hash === '#/tickets') { document.getElementById('nav-tickets')?.classList.add('active'); titleEl.textContent = 'Ticket'; TicketListView.render(); } else if (hash === '#/tickets/my') { document.getElementById('nav-tickets-my')?.classList.add('active'); titleEl.textContent = 'Ticket a mio carico'; TicketListView.render(); } else if (hash === '#/tickets/new') { document.getElementById('nav-new-ticket')?.classList.add('active'); titleEl.textContent = 'Nuovo Ticket'; TicketCreateView.render(); } else if (hash === '#/tickets/bulk') { document.getElementById('nav-bulk-tickets')?.classList.add('active'); titleEl.textContent = 'Apertura Massiva Ticket'; TicketBulkView.render(); } else if (hash === '#/activity') { document.getElementById('nav-activity')?.classList.add('active'); titleEl.textContent = 'Storico Attività'; ActivityLogView.render(); } else if (hash.match(/^#\/tickets\/(\d+)$/)) { const id = hash.match(/^#\/tickets\/(\d+)$/)[1]; document.getElementById('nav-tickets')?.classList.add('active'); titleEl.textContent = `Ticket #${id}`; TicketDetailView.render(id); } else { // Fallback to dashboard window.location.hash = '#/dashboard'; } }, /** API fetch wrapper */ async api(url, options = {}) { const activeAgentId = localStorage.getItem('activeAgentId') || '1'; const defaultOptions = { headers: { 'Content-Type': 'application/json', 'X-Agent-ID': activeAgentId, }, }; const headers = { ...defaultOptions.headers, ...(options.headers || {}) }; const response = await fetch(url, { ...defaultOptions, ...options, headers }); if (!response.ok) { const errData = await response.json().catch(() => ({})); throw new Error(errData.error || errData.message || `HTTP ${response.status}`); } return response.json(); }, /** Ensure lookup data is loaded (cached) */ async ensureLookups(forceRefresh = false) { if (this.lookupsLoaded && !forceRefresh) return; // Check localStorage cache first if not forcing refresh if (!forceRefresh) { const cached = localStorage.getItem('otrs_lookups'); if (cached) { try { this.lookups = JSON.parse(cached); this.lookupsLoaded = true; return; } catch (e) { console.warn('Failed to parse cached lookups, reloading...', e); } } } try { const [queues, states, priorities, users, types, config] = await Promise.all([ this.api('/api/queues'), this.api('/api/states'), this.api('/api/priorities'), this.api('/api/users'), this.api('/api/types'), this.api('/api/config').catch(() => ({ defaultAgentLogin: '' })), ]); this.lookups = { queues, states, priorities, users, types, config }; localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups)); this.lookupsLoaded = true; } catch (err) { console.error('Failed to load lookups:', err); throw err; } }, /** Force refresh of lookups */ async refreshLookups() { const btn = document.getElementById('refresh-lookups-btn'); const origHtml = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = '
'; } try { // Sync LDAP customer users to local DB cache await this.api('/api/customer-users/sync', { method: 'POST' }); await this.ensureLookups(true); await this.initAgentSelector(); Toast.success('Dati locali (code, utenti, ecc.) aggiornati con successo!'); // If we are on a view that needs lookups, we can re-render it const hash = window.location.hash; if (hash === '#/tickets/bulk') { TicketBulkView.render(); } else if (hash === '#/tickets/new') { TicketCreateView.render(); } } catch (err) { Toast.error('Errore durante l\'aggiornamento: ' + err.message); } finally { if (btn) { btn.disabled = false; btn.innerHTML = origHtml; } } }, /** Check database connection */ async checkConnection() { const dot = document.getElementById('connection-status'); const text = document.getElementById('connection-text'); try { await this.api('/api/queues'); dot.classList.add('connected'); dot.classList.remove('error'); text.textContent = 'DB connesso'; } catch (err) { dot.classList.add('error'); dot.classList.remove('connected'); text.textContent = 'DB non raggiungibile'; Toast.error('Impossibile connettersi al database OTRS'); } }, /** Escape HTML to prevent XSS */ escapeHtml(str) { const div = document.createElement('div'); div.textContent = str || ''; return div.innerHTML; }, /** Format date for display */ formatDate(dateStr) { if (!dateStr) return '—'; try { const d = new Date(dateStr); return d.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }); } catch { return dateStr; } }, /** Format date+time for display */ formatDateTime(dateStr) { if (!dateStr) return '—'; try { const d = new Date(dateStr); return d.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } catch { return dateStr; } }, /** Initialize active agent dropdown */ async initAgentSelector() { const select = document.getElementById('active-agent-select'); if (!select) return; try { // Ensure lookups are loaded await this.ensureLookups(); // Populate select dropdown select.innerHTML = (this.lookups.users || []).map(u => `` ).join(''); // Load saved agent ID or default to the config-specified default agent, or first available const savedAgentId = localStorage.getItem('activeAgentId'); const defaultAgentLogin = this.lookups.config ? this.lookups.config.defaultAgentLogin : null; const defaultAgent = defaultAgentLogin ? (this.lookups.users || []).find(u => u.login === defaultAgentLogin) : null; if (savedAgentId && (this.lookups.users || []).some(u => String(u.id) === String(savedAgentId))) { select.value = savedAgentId; } else if (defaultAgent) { select.value = defaultAgent.id; localStorage.setItem('activeAgentId', select.value); } else if ((this.lookups.users || []).length > 0) { select.value = this.lookups.users[0].id; localStorage.setItem('activeAgentId', select.value); } // Handle dropdown change event select.addEventListener('change', () => { localStorage.setItem('activeAgentId', select.value); Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`); this.updateDailyTimer(); this.route(); }); // Initial update this.updateDailyTimer(); } catch (err) { console.error('Failed to init agent selector:', err); } }, /** Map priority name to a 1-5 index for styling */ priorityIndex(name) { if (!name) return 3; const lower = name.toLowerCase(); if (lower.includes('very low') || lower.includes('1')) return 1; if (lower.includes('low') || lower.includes('2')) return 2; if (lower.includes('normal') || lower.includes('3')) return 3; if (lower.includes('high') && !lower.includes('very') || lower.includes('4')) return 4; if (lower.includes('very high') || lower.includes('5')) return 5; return 3; }, /** Initialize and handle theme selection */ initTheme() { const themeSelect = document.getElementById('theme-select'); const savedTheme = localStorage.getItem('app-theme') || 'light'; // Apply the saved theme class to body this.applyThemeClass(savedTheme); if (themeSelect) { themeSelect.value = savedTheme; themeSelect.addEventListener('change', () => { const selectedTheme = themeSelect.value; this.applyThemeClass(selectedTheme); localStorage.setItem('app-theme', selectedTheme); Toast.success(`Tema cambiato in: ${themeSelect.options[themeSelect.selectedIndex].text}`); }); } }, /** Helper to apply theme classes to document.body */ applyThemeClass(theme) { // Remove any existing theme- classes document.body.className = document.body.className .split(' ') .filter(c => !c.startsWith('theme-')) .join(' '); if (theme !== 'light') { document.body.classList.add(`theme-${theme}`); } }, /** Load demotivational phrases from txt file */ async loadDemotivationalPhrases() { try { const res = await fetch('/demotivational.txt'); if (res.ok) { const text = await res.text(); this.demotivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0); } } catch (e) { console.warn('Failed to load demotivational phrases:', e); } }, /** Load motivational phrases from txt file */ async loadMotivationalPhrases() { try { const res = await fetch('/motivational.txt'); if (res.ok) { const text = await res.text(); this.motivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0); } } catch (e) { console.warn('Failed to load motivational phrases:', e); } }, /** Fetch daily time accounting and update sidebar display */ async updateDailyTimer() { const timerEl = document.getElementById('daily-timer'); if (!timerEl) return; try { await this.ensureLookups(); const targetTime = this.lookups.config?.dailyTargetTime || 480; const data = await this.api('/api/users/time-today'); const todayTime = typeof data.totalToday === 'number' ? data.totalToday : 0; const remaining = Math.max(0, targetTime - todayTime); const percentage = Math.min(100, Math.round((todayTime / targetTime) * 100)); let phrase = ""; const isDemotivational = percentage >= 70; if (isDemotivational) { if (this.demotivationalPhrases.length > 0) { const daySeed = new Date().getDate() + todayTime; const idx = Math.floor(Math.abs(Math.sin(daySeed) * this.demotivationalPhrases.length)); phrase = this.demotivationalPhrases[idx % this.demotivationalPhrases.length]; } else { phrase = "Hai fatto fin troppo lavoro per oggi. Smetti."; } } else { if (this.motivationalPhrases.length > 0) { const daySeed = new Date().getDate() + todayTime; const idx = Math.floor(Math.abs(Math.sin(daySeed) * this.motivationalPhrases.length)); phrase = this.motivationalPhrases[idx % this.motivationalPhrases.length]; } else { phrase = "Continua così! Stai andando alla grande."; } } timerEl.innerHTML = `