Files
otrs-turbo/public/js/app.js
T

1289 lines
47 KiB
JavaScript

/**
* 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: [],
drafts: {},
tabs: [],
lastListView: '#/tickets',
loadTabs() {
try {
const saved = localStorage.getItem('otrs_turbo_tabs');
if (saved) this.tabs = JSON.parse(saved);
const savedDrafts = localStorage.getItem('otrs_turbo_drafts');
if (savedDrafts) this.drafts = JSON.parse(savedDrafts);
} catch (e) { }
this.renderTabs();
},
saveTabs() {
localStorage.setItem('otrs_turbo_tabs', JSON.stringify(this.tabs));
this.renderTabs();
},
saveDraft(ticketId, draft) {
if (!this.drafts[ticketId]) this.drafts[ticketId] = {};
this.drafts[ticketId][draft.type] = draft;
localStorage.setItem('otrs_turbo_drafts', JSON.stringify(this.drafts));
},
getDraft(ticketId, type) {
return this.drafts[ticketId] ? this.drafts[ticketId][type] : null;
},
clearDraft(ticketId, type) {
if (this.drafts[ticketId] && this.drafts[ticketId][type]) {
delete this.drafts[ticketId][type];
if (Object.keys(this.drafts[ticketId]).length === 0) {
delete this.drafts[ticketId];
}
localStorage.setItem('otrs_turbo_drafts', JSON.stringify(this.drafts));
this.renderTabs();
}
},
getShortcut(id) {
try {
const stored = localStorage.getItem('otrs_shortcuts');
const customMap = stored ? JSON.parse(stored) : {};
if (customMap[id]) return customMap[id];
} catch (e) {}
const defaults = {
create_ticket_open: { ctrl: true, alt: true, shift: false, key: 'n', enabled: true },
create_ticket_submit: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
quick_edit_save: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
};
return defaults[id] || { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true };
},
saveCustomShortcut(id, combo) {
try {
const stored = localStorage.getItem('otrs_shortcuts');
const customMap = stored ? JSON.parse(stored) : {};
customMap[id] = combo;
localStorage.setItem('otrs_shortcuts', JSON.stringify(customMap));
} catch (e) {
console.error(e);
}
},
isShortcutTriggered(e, id) {
const sc = this.getShortcut(id);
if (!sc || !sc.enabled) return false;
const isCtrlOrCmd = sc.ctrl ? (e.ctrlKey || e.metaKey) : (!e.ctrlKey && !e.metaKey);
const isAlt = sc.alt ? e.altKey : !e.altKey;
const isShift = sc.shift ? e.shiftKey : !e.shiftKey;
let targetKey = sc.key.toLowerCase();
let pressedKey = e.key.toLowerCase();
if (targetKey === 'space' && (pressedKey === ' ' || pressedKey === 'space')) {
return isCtrlOrCmd && isAlt && isShift;
}
return isCtrlOrCmd && isAlt && isShift && (pressedKey === targetKey);
},
formatShortcutLabel(sc) {
if (!sc) return '';
const parts = [];
if (sc.ctrl) parts.push(navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl');
if (sc.alt) parts.push('Alt');
if (sc.shift) parts.push('Shift');
let keyName = sc.key;
if (keyName === 'Enter') keyName = 'Invio';
else if (keyName === ' ' || keyName === 'Space') keyName = 'Spazio';
parts.push(keyName);
return parts.join(' + ');
},
linkifyText(text) {
if (!text) return '';
const urlRegex = /(https?:\/\/[^\s<]+)/g;
return text.replace(urlRegex, url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color:var(--accent-primary); text-decoration:underline;">${url}</a>`);
},
openTab(id, tn, title) {
const exists = this.tabs.find(t => String(t.id) === String(id));
if (!exists) {
this.tabs.push({ id, tn, title });
this.saveTabs();
}
},
addTabWithoutRedirect(id, tn, title) {
const exists = this.tabs.find(t => String(t.id) === String(id));
if (!exists) {
this.tabs.push({ id, tn, title });
this.saveTabs();
} else {
if (title && exists.title !== title) {
exists.title = title;
this.saveTabs();
} else {
this.renderTabs();
}
}
},
closeTab(id, e) {
if (e) e.stopPropagation();
this.tabs = this.tabs.filter(t => String(t.id) !== String(id));
this.saveTabs();
// Clear drafts for closed tab
delete this.drafts[id];
localStorage.setItem('otrs_turbo_drafts', JSON.stringify(this.drafts));
const hash = window.location.hash;
if (hash === `#/tickets/${id}`) {
if (this.tabs.length > 0) {
window.location.hash = `#/tickets/${this.tabs[this.tabs.length - 1].id}`;
} else {
window.location.hash = '#/tickets';
}
}
},
renderTabs() {
const bar = document.getElementById('tabs-bar');
if (!bar) return;
if (this.tabs.length === 0) {
bar.style.display = 'none';
this.updateHeaderHeight();
return;
}
bar.style.display = 'flex';
const currentHash = window.location.hash;
bar.innerHTML = this.tabs.map(t => {
const isActive = currentHash === `#/tickets/${t.id}`;
const hasEmailDraft = this.getDraft(t.id, 'email');
const emailIconHtml = hasEmailDraft ? `<span style="color:#22c55e; margin-right:4px;" title="Bozza email presente">✉️</span>` : '';
const displayTitle = t.title ? (t.title.length > 25 ? t.title.substring(0, 22) + '...' : t.title) : `#${t.tn}`;
return `
<div class="tab-item ${isActive ? 'active' : ''}" onclick="window.location.hash = '#/tickets/${t.id}'" title="${App.escapeHtml(t.title || '')}">
${emailIconHtml}
<span>${App.escapeHtml(displayTitle)}</span>
<button class="tab-close" onclick="App.closeTab(${t.id}, event)">✕</button>
</div>
`;
}).join('');
this.updateHeaderHeight();
},
updateHeaderHeight() {
const topbar = document.getElementById('topbar');
if (topbar) {
const height = topbar.offsetHeight;
document.documentElement.style.setProperty('--topbar-total-height', `${height}px`);
}
},
get currentAgentId() {
return parseInt(localStorage.getItem('activeAgentId') || '1', 10);
},
/** Initialize the application */
init() {
this.initTheme();
this.loadDemotivationalPhrases();
this.loadMotivationalPhrases();
this.loadTabs();
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';
}
};
searchInput.addEventListener('input', () => {
toggleClearBtn();
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const query = searchInput.value.trim();
searchInput.value = '';
toggleClearBtn();
if (query) {
const newHash = `#/ricerca-spudorata?q=${encodeURIComponent(query)}`;
if (window.location.hash === newHash && typeof RicercaSpudorataView !== 'undefined') {
const input = document.getElementById('spudorata-search-input');
if (input) input.value = query;
RicercaSpudorataView.executeSearch(query);
} else {
window.location.hash = newHash;
}
} else {
window.location.hash = '#/ricerca-spudorata';
}
}
});
if (searchClearBtn) {
searchClearBtn.addEventListener('click', () => {
searchInput.value = '';
searchClearBtn.style.display = 'none';
searchInput.focus();
if (window.location.hash.startsWith('#/ricerca-spudorata') && typeof RicercaSpudorataView !== 'undefined') {
RicercaSpudorataView.invalidateCache();
RicercaSpudorataView.render();
}
});
}
// Initial state of clear button
toggleClearBtn();
}
// Global keyboard shortcuts (e.g. Crea Nuovo Ticket)
document.addEventListener('keydown', (e) => {
if (this.isShortcutTriggered(e, 'create_ticket_open')) {
if (window.location.hash !== '#/tickets/new') {
e.preventDefault();
window.location.hash = '#/tickets/new';
}
}
});
// 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());
}
// Reset old global hide setting if present
localStorage.removeItem('hide_sidebar_badges');
document.body.classList.remove('badges-hidden');
// Helper to check if a specific badge is hidden
this.isBadgeHidden = (badgeId) => localStorage.getItem(`hide_badge_${badgeId}`) === 'true';
// Bind click event on nav-badges to toggle individual visibility
document.querySelectorAll('.nav-badge').forEach(badge => {
if (!badge.id) return;
badge.title = 'Clicca per nascondere/mostrare questo contatore';
badge.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const isCurrentlyHidden = this.isBadgeHidden(badge.id);
const newHiddenState = !isCurrentlyHidden;
localStorage.setItem(`hide_badge_${badge.id}`, newHiddenState ? 'true' : 'false');
if (newHiddenState) {
badge.classList.add('hidden-badge');
Toast.info('Contatore nascosto');
} else {
badge.classList.remove('hidden-badge');
Toast.info('Contatore visibile');
}
// Refresh display text for badges
this.updateSidebarBadges();
});
});
// Bind button close end of day tickets
const btnCloseEod = document.getElementById('btn-close-end-of-day');
if (btnCloseEod) {
btnCloseEod.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const confirmed = await this.confirm(
'Chiusura Ticket Fine Giornata',
'Sei sicuro di voler chiudere tutti i ticket nel gruppo "CHIUDI A FINE GIORNATA"?\nI ticket rimarranno nel gruppo.'
);
if (!confirmed) return;
try {
const res = await this.api('/api/groups/close-end-of-day', { method: 'POST' });
if (res.count > 0) {
Toast.success(res.message);
} else {
Toast.info(res.message);
}
this.updateSidebarBadges();
this.route();
} catch (err) {
Toast.error('Errore durante la chiusura dei ticket: ' + err.message);
}
});
}
// Initial route
window.addEventListener('resize', () => this.updateHeaderHeight());
setTimeout(() => this.updateHeaderHeight(), 100);
if (!window.location.hash || window.location.hash === '#/') {
window.location.hash = '#/dashboard';
} else {
this.route();
}
// Set periodic refresh for daily time accounting counter (every 60 seconds)
if (!this.dailyTimerInterval) {
this.dailyTimerInterval = setInterval(() => this.updateDailyTimer(), 60000);
}
},
/** Route based on current hash */
route() {
this.updateDailyTimer();
const fullHash = window.location.hash || '#/dashboard';
const hash = fullHash.split('?')[0];
const titleEl = document.getElementById('page-title');
if (hash === '#/tickets' || hash === '#/tickets/my') {
this.lastListView = hash;
}
// Save draft for previous ticket before routing
if (typeof TicketDetailView !== 'undefined' && TicketDetailView.ticketId) {
TicketDetailView.saveDraft();
if (typeof EmailCompose !== 'undefined') {
EmailCompose.saveDraft();
const overlay = document.getElementById('email-compose-overlay');
if (overlay) overlay.remove();
}
}
this.renderTabs();
this.updateHeaderHeight();
const container = document.getElementById('view-container');
if (container) {
if (hash.match(/^#\/tickets\/(\d+)$/)) {
container.classList.add('ticket-view-active');
} else {
container.classList.remove('ticket-view-active');
}
}
// 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 === '#/ricerca-spudorata') {
document.getElementById('nav-ricerca-spudorata')?.classList.add('active');
titleEl.textContent = 'Ricerca Spudorata';
RicercaSpudorataView.render();
} else if (hash === '#/note-temporanee') {
document.getElementById('nav-note-temporanee')?.classList.add('active');
titleEl.textContent = 'Note';
NoteTemporaneeView.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 === '#/tickets/groups') {
document.getElementById('nav-ticket-groups')?.classList.add('active');
titleEl.textContent = 'Gruppi ticket';
TicketGroupsView.render();
} else if (hash === '#/activity') {
document.getElementById('nav-activity')?.classList.add('active');
titleEl.textContent = 'Storico Attività';
ActivityLogView.render();
} else if (hash === '#/shortcuts') {
document.getElementById('nav-shortcuts')?.classList.add('active');
titleEl.textContent = 'Shortcuts';
ShortcutsView.render();
} else if (hash === '#/mail-management') {
document.getElementById('nav-mail-management')?.classList.add('active');
titleEl.textContent = 'Gestione Mail';
MailManagementView.render();
} else if (hash === '#/config-env') {
document.getElementById('nav-config-env')?.classList.add('active');
titleEl.textContent = 'Modifica .env';
EnvConfigView.render();
} else if (hash === '#/time-accounting') {
document.getElementById('nav-time-accounting')?.classList.add('active');
titleEl.textContent = 'Storico consuntivazione';
TimeAccountingView.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}`);
}
const data = await response.json();
// Automatically update daily timer on any system mutation (POST, PUT, PATCH, DELETE)
const method = (options.method || 'GET').toUpperCase();
if (method !== 'GET' && !url.includes('/api/users/time-today')) {
setTimeout(() => {
this.updateDailyTimer();
this.updateSidebarBadges();
}, 100);
}
return data;
},
/** 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);
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined || !this.lookups.customerUsers || !this.lookups.customer_users_version_1) {
throw new Error('Outdated config cache (missing autoTimeMinHour or customerUsers)');
}
this.lookupsLoaded = true;
return;
} catch (e) {
console.warn('Failed to parse cached lookups, reloading...', e.message);
}
}
}
try {
const [queues, states, priorities, users, types, config, customerUsers] = 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.api('/api/customer-users/search?q=').catch(() => []),
]);
this.lookups = { queues, states, priorities, users, types, config, customerUsers, customer_users_version_1: true };
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 = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0;"></div>';
}
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 =>
`<option value="${u.id}">${u.first_name} ${u.last_name} (${u.login})</option>`
).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);
}
if (typeof Filters !== 'undefined' && Filters.allStates && Filters.allStates.my) {
Filters.allStates.my.user_id = select.value;
}
// Handle dropdown change event
select.addEventListener('change', () => {
const newAgentId = select.value;
localStorage.setItem('activeAgentId', newAgentId);
if (typeof Filters !== 'undefined' && Filters.allStates && Filters.allStates.my) {
Filters.allStates.my.user_id = newAgentId;
localStorage.setItem('otrs_turbo_filters_my', JSON.stringify(Filters.allStates.my));
}
Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`);
this.updateDailyTimer();
this.updateSidebarBadges();
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 SQLite cache */
async loadDemotivationalPhrases() {
try {
const res = await fetch('/api/dashboard/phrases?tipo=demotivational');
if (res.ok) {
this.demotivationalPhrases = await res.json();
}
} catch (e) {
console.warn('Failed to load demotivational phrases:', e);
}
},
/** Load motivational phrases from SQLite cache */
async loadMotivationalPhrases() {
try {
const res = await fetch('/api/dashboard/phrases?tipo=motivational');
if (res.ok) {
this.motivationalPhrases = await res.json();
}
} 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 mathematicalPercentage = Math.round((todayTime / targetTime) * 100);
const hasOverperformance = mathematicalPercentage > 100;
const percentage = Math.min(100, mathematicalPercentage);
const brandEl = document.querySelector('.sidebar-brand');
if (brandEl) {
if (hasOverperformance) {
brandEl.classList.add('glow');
} else {
brandEl.classList.remove('glow');
}
}
if (timerEl) {
if (hasOverperformance) {
timerEl.classList.add('overperformance-alarm');
} else {
timerEl.classList.remove('overperformance-alarm');
}
}
const minTimeStr = this.lookups.config?.autoTimeMinHour || '18:00';
const [minHour, minMin] = minTimeStr.split(':').map(x => parseInt(x, 10));
const now = new Date();
const currentHour = now.getHours();
const currentMin = now.getMinutes();
let isPastTime = false;
if (currentHour > minHour) {
isPastTime = true;
} else if (currentHour === minHour && currentMin >= minMin) {
isPastTime = true;
}
const triggerAction = async (e) => {
if (e && e.target && e.target.closest('#btn-close-end-of-day')) {
return;
}
e.preventDefault();
e.stopPropagation();
const confirmed = await this.confirm(
'Consuntivazione Automatica',
`Sei sicuro di voler effettuare la consuntivazione automatica di ${remaining} minuti rimanenti di oggi? Verrà creato un ticket chiuso a tuo carico.`
);
if (!confirmed) return;
try {
Toast.success('Consuntivazione in corso...');
const res = await this.api('/api/tickets/auto-time', { method: 'POST' });
this.updateDailyTimer();
this.route();
await this.alert('Consuntivazione Completata', res.message || 'La consuntivazione automatica è stata completata con successo.');
} catch (err) {
Toast.error('Errore consuntivazione automatica: ' + err.message);
}
};
if (isPastTime && remaining > 0) {
if (brandEl) {
brandEl.classList.add('clickable-auto-time');
brandEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m)`);
brandEl.onclick = triggerAction;
}
if (timerEl) {
timerEl.classList.add('clickable-auto-time');
//timerEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m). Clicca per eseguire.`);
timerEl.onclick = triggerAction;
}
} else {
if (brandEl) {
brandEl.classList.remove('clickable-auto-time');
brandEl.removeAttribute('title');
brandEl.onclick = null;
}
if (timerEl) {
timerEl.classList.remove('clickable-auto-time');
timerEl.removeAttribute('title');
timerEl.onclick = null;
}
}
let phrase = "";
const phraseThreshold = this.lookups.config?.phraseThreshold || 70;
const isDemotivational = percentage >= phraseThreshold;
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.";
}
}
const overDelta = Math.max(0, mathematicalPercentage - 100);
const visualBarWidth = hasOverperformance ? (100 + (overDelta * 3.5)) : percentage;
let overImgName = 'over9000.jpg';
let overPhraseText = '';
if (mathematicalPercentage > 150) {
overImgName = this.lookups?.config?.overperformanceImage || 'over9000.jpg';
overPhraseText = "IT'S OVER 9000! Il livello di consuntivazione ha superato ogni limite!";
} else if (mathematicalPercentage > 140) {
overImgName = 'over_150.jpg';
overPhraseText = 'Questo livello di consuntivazione è impossibile.';
} else if (mathematicalPercentage > 130) {
overImgName = 'over_140.jpg';
overPhraseText = 'Io sono il principe dei Saiyan, non glielo permetterò';
} else if (mathematicalPercentage > 120) {
overImgName = 'over_130.jpg';
overPhraseText = 'Sono un Saiyan, non può consuntivare più di me.';
} else if (mathematicalPercentage > 110) {
overImgName = 'over_120.jpg';
overPhraseText = 'Non è possibile! La sua consuntivazione continua ad aumentare.';
} else {
overImgName = 'over_110.jpg';
overPhraseText = "C'è qualcosa di sicuro nella vita. Il più forte è al di sopra di tutti e può consuntivare tutto!";
}
timerEl.innerHTML = `
<div class="timer-display" style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; font-weight:500;">
<span>⏱ ${todayTime} / ${targetTime} m</span>
<span style="color:var(--text-muted);">|</span>
<span>rimanenti: ${remaining} m</span>
</div>
<div class="timer-tooltip">
<div class="timer-tooltip-border"></div>
<div style="font-size:0.75rem; font-weight:600; color:var(--text-primary); margin-bottom:4px; display:flex; justify-content:space-between; align-items:center;">
<span>Progresso Giornaliero</span>
<strong>${mathematicalPercentage}% ${hasOverperformance ? '<span style="animation: pulse 1.5s infinite; display:inline-block; margin-left:4px;">⚠️</span>' : ''}</strong>
</div>
<div style="background:var(--border-light); border-radius:var(--radius-full); height:10px; width:100%; position:relative; overflow:visible; border:1px solid var(--border-subtle);">
<div style="width:${visualBarWidth}%; background:${hasOverperformance ? 'linear-gradient(90deg, var(--accent-primary), #ef4444, #f59e0b, #ec4899)' : 'linear-gradient(90deg, var(--accent-primary), var(--accent-secondary))'}; height:100%; border-radius:var(--radius-full); transition: width 0.3s ease; ${hasOverperformance ? 'box-shadow: 0 0 10px rgba(239, 68, 68, 0.8);' : ''}"></div>
</div>
${hasOverperformance ? (() => {
const overImgSrc = (overImgName.startsWith('http://') || overImgName.startsWith('https://') || overImgName.startsWith('/')) ? overImgName : `img/${overImgName}`;
const imgWidth = Math.min(500, 180 + Math.round(overDelta * 10));
return `
<div style="font-size:0.72rem; color:var(--error); font-weight:700; line-height:1.3; text-transform:uppercase; margin-top:8px; border-top:1px solid var(--border-subtle); padding-top:8px; text-align:center; animation: pulse 1.5s infinite;">
Rilevata una overperformance allontanarsi <span style="font-size:1.4rem; display:block; font-weight:900; margin-top:2px;">IMMEDIATAMENTE</span>
</div>
<div style="margin-top:6px; text-align:center; position:relative; overflow:visible;">
<a href="${App.escapeHtml(overImgSrc)}" target="_blank" title="Apri immagine originale in una nuova scheda" style="display:inline-block; text-decoration:none;">
<img src="${App.escapeHtml(overImgSrc)}" alt="Overperformance Meme" style="width:${imgWidth}px; max-width:none; border-radius:var(--radius-md); box-shadow:var(--shadow-md); display:block; margin:0 auto; transition: width 0.3s ease; cursor:pointer;" />
</a>
</div>
${overPhraseText ? `
<div style="font-size:0.75rem; color:var(--text-primary); font-weight:600; line-height:1.35; font-style:italic; margin-top:8px; border-top:1px dashed var(--border-subtle); padding-top:6px; text-align:center;">
"${App.escapeHtml(overPhraseText)}"
</div>
` : ''}
`;
})() : `
<div style="font-size:0.72rem; color:var(--text-secondary); line-height:1.35; font-style:italic; margin-top:6px; border-top:1px solid var(--border-subtle); padding-top:6px; text-align:center;">
"${phrase}"
</div>
`}
</div>
`;
// Update sidebar counts as well
this.updateSidebarBadges();
} catch (err) {
console.warn('Failed to update daily timer:', err);
}
},
/** Fetch dashboard stats to update sidebar counts */
async updateSidebarBadges() {
try {
const stats = await this.api('/api/dashboard/stats');
const badge = document.getElementById('open-ticket-count');
if (badge) {
const isHidden = this.isBadgeHidden ? this.isBadgeHidden('open-ticket-count') : false;
if (isHidden) {
badge.classList.add('hidden-badge');
badge.textContent = '';
} else {
badge.classList.remove('hidden-badge');
badge.textContent = stats.total_open > 0 ? stats.total_open : '';
}
}
const myBadge = document.getElementById('my-ticket-count');
if (myBadge) {
const isHidden = this.isBadgeHidden ? this.isBadgeHidden('my-ticket-count') : false;
if (isHidden) {
myBadge.classList.add('hidden-badge');
myBadge.textContent = '';
} else {
myBadge.classList.remove('hidden-badge');
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
}
}
try {
const notes = await this.api('/api/notes');
const notesBadge = document.getElementById('temp-notes-count');
if (notesBadge) {
const isHidden = this.isBadgeHidden ? this.isBadgeHidden('temp-notes-count') : false;
if (isHidden) {
notesBadge.classList.add('hidden-badge');
notesBadge.textContent = '';
} else {
notesBadge.classList.remove('hidden-badge');
notesBadge.textContent = (notes && notes.length > 0) ? notes.length : '';
}
}
} catch (_) {}
} catch (e) {
console.warn('Failed to update sidebar badges:', e);
}
},
/** Custom confirm dialog in the center of the screen */
confirm(title, message, options = {}) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
overlay.style.backdropFilter = 'blur(4px)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '99999';
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.2s ease';
const card = document.createElement('div');
card.style.background = 'var(--bg-card, #1e1e2e)';
card.style.border = '1px solid var(--border-subtle, #313244)';
card.style.borderRadius = 'var(--radius-lg, 12px)';
card.style.padding = 'var(--space-lg, 24px)';
card.style.width = '100%';
card.style.maxWidth = '400px';
card.style.boxShadow = 'var(--shadow-lg, 0 10px 30px rgba(0,0,0,0.5))';
card.style.transform = 'scale(0.9)';
card.style.transition = 'transform 0.2s ease';
card.className = 'confirm-dialog-card';
card.innerHTML = `
<h3 style="margin-top: 0; margin-bottom: var(--space-xs, 8px); color: var(--text-primary, #cdd6f4); font-size: 1.2rem; font-weight: 600;">${title}</h3>
<p style="margin-bottom: var(--space-lg, 24px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
<div style="display: flex; gap: var(--space-sm, 12px); justify-content: flex-end;">
<button id="confirm-btn-cancel" class="btn btn-ghost" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px);">${options.cancelText || 'Annulla'}</button>
<button id="confirm-btn-ok" class="btn ${options.confirmClass || 'btn-danger'}" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.confirmText || 'Conferma'}</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
// Trigger animations
requestAnimationFrame(() => {
overlay.style.opacity = '1';
card.style.transform = 'scale(1)';
});
const cleanUp = (result) => {
overlay.style.opacity = '0';
card.style.transform = 'scale(0.9)';
setTimeout(() => {
overlay.remove();
resolve(result);
}, 200);
};
const btnCancel = card.querySelector('#confirm-btn-cancel');
const btnOk = card.querySelector('#confirm-btn-ok');
btnCancel.addEventListener('click', () => cleanUp(false));
btnOk.addEventListener('click', () => cleanUp(true));
// Close on backdrop click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanUp(false);
});
});
},
/** Custom alert dialog in the center of the screen */
alert(title, message, options = {}) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
overlay.style.backdropFilter = 'blur(4px)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '99999';
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.2s ease';
const card = document.createElement('div');
card.style.background = 'var(--bg-card, #1e1e2e)';
card.style.border = '1px solid var(--border-subtle, #313244)';
card.style.borderRadius = 'var(--radius-lg, 12px)';
card.style.padding = 'var(--space-lg, 24px)';
card.style.width = '100%';
card.style.maxWidth = '400px';
card.style.boxShadow = 'var(--shadow-lg, 0 10px 30px rgba(0,0,0,0.5))';
card.style.transform = 'scale(0.9)';
card.style.transition = 'transform 0.2s ease';
card.className = 'alert-dialog-card';
card.innerHTML = `
<h3 style="margin-top: 0; margin-bottom: var(--space-xs, 8px); color: var(--text-primary, #cdd6f4); font-size: 1.2rem; font-weight: 600;">${title}</h3>
<p style="margin-bottom: var(--space-lg, 24px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
<div style="display: flex; justify-content: flex-end;">
<button id="alert-btn-ok" class="btn btn-primary" style="height: 36px; font-size: 0.9rem; padding: 0 20px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.okText || 'OK'}</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.style.opacity = '1';
card.style.transform = 'scale(1)';
});
const cleanUp = () => {
overlay.style.opacity = '0';
card.style.transform = 'scale(0.9)';
setTimeout(() => {
overlay.remove();
resolve();
}, 200);
};
card.querySelector('#alert-btn-ok').addEventListener('click', cleanUp);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanUp();
});
});
},
/** Custom prompt dialog in the center of the screen */
prompt(title, message, options = {}) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
overlay.style.backdropFilter = 'blur(4px)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '99999';
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.2s ease';
const card = document.createElement('div');
card.style.background = 'var(--bg-card, #1e1e2e)';
card.style.border = '1px solid var(--border-subtle, #313244)';
card.style.borderRadius = 'var(--radius-lg, 12px)';
card.style.padding = 'var(--space-lg, 24px)';
card.style.width = '100%';
card.style.maxWidth = '400px';
card.style.boxShadow = 'var(--shadow-lg, 0 10px 30px rgba(0,0,0,0.5))';
card.style.transform = 'scale(0.9)';
card.style.transition = 'transform 0.2s ease';
card.className = 'prompt-dialog-card';
card.innerHTML = `
<h3 style="margin-top: 0; margin-bottom: var(--space-xs, 8px); color: var(--text-primary, #cdd6f4); font-size: 1.2rem; font-weight: 600;">${title}</h3>
<p style="margin-bottom: var(--space-sm, 12px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
<input type="text" id="prompt-input-field" class="form-input" value="${options.defaultValue || ''}" placeholder="${options.placeholder || ''}" style="width: 100%; margin-bottom: var(--space-md, 16px); box-sizing: border-box;" />
<div style="display: flex; gap: var(--space-sm, 12px); justify-content: flex-end;">
<button id="prompt-btn-cancel" class="btn btn-ghost" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px);">${options.cancelText || 'Annulla'}</button>
<button id="prompt-btn-ok" class="btn btn-primary" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.confirmText || 'Salva'}</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
const input = card.querySelector('#prompt-input-field');
requestAnimationFrame(() => {
overlay.style.opacity = '1';
card.style.transform = 'scale(1)';
setTimeout(() => {
if (input) input.focus();
}, 50);
});
const cleanUp = (resultValue) => {
overlay.style.opacity = '0';
card.style.transform = 'scale(0.9)';
setTimeout(() => {
overlay.remove();
resolve(resultValue);
}, 200);
};
const btnCancel = card.querySelector('#prompt-btn-cancel');
const btnOk = card.querySelector('#prompt-btn-ok');
btnCancel.addEventListener('click', () => cleanUp(null));
btnOk.addEventListener('click', () => {
const val = input ? input.value : '';
cleanUp(val);
});
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
btnOk.click();
} else if (e.key === 'Escape') {
btnCancel.click();
}
});
}
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanUp(null);
});
});
},
/**
* Helper to enable arrow keys (Up/Down), Enter, and Escape navigation on autocomplete inputs.
* @param {HTMLInputElement} inputEl
* @param {HTMLElement} suggestionsEl
* @param {Function} onSelect Callback(itemElement) when an item is selected via Enter or Click
*/
setupAutocompleteKeyboardNavigation(inputEl, suggestionsEl, onSelect) {
if (!inputEl || !suggestionsEl) return;
inputEl.addEventListener('keydown', (e) => {
if (suggestionsEl.style.display === 'none' || !suggestionsEl.children.length) {
return;
}
const items = Array.from(suggestionsEl.querySelectorAll('.autocomplete-suggestion-item[data-id]'));
if (items.length === 0) return;
let currentIndex = items.findIndex(item => item.classList.contains('selected'));
if (e.key === 'ArrowDown') {
e.preventDefault();
if (currentIndex < items.length - 1) {
currentIndex++;
} else {
currentIndex = 0;
}
items.forEach((item, idx) => {
if (idx === currentIndex) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('selected');
}
});
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (currentIndex > 0) {
currentIndex--;
} else {
currentIndex = items.length - 1;
}
items.forEach((item, idx) => {
if (idx === currentIndex) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('selected');
}
});
} else if (e.key === 'Enter') {
if (currentIndex >= 0 && items[currentIndex]) {
e.preventDefault();
e.stopPropagation();
onSelect(items[currentIndex]);
}
} else if (e.key === 'Tab' || e.key === 'Escape') {
if (currentIndex >= 0 && items[currentIndex] && e.key === 'Tab') {
onSelect(items[currentIndex]);
}
suggestionsEl.style.display = 'none';
}
});
inputEl.addEventListener('blur', () => {
setTimeout(() => {
if (suggestionsEl) suggestionsEl.style.display = 'none';
}, 150);
});
},
};
// Start the app when DOM is ready
document.addEventListener('DOMContentLoaded', () => App.init());