fix: correzione doppia consuntivazione note. fix: utenti ldap. feat: ricerca per customer useers. feat: possiblità di rimuovere le note
This commit is contained in:
+75
-4
@@ -186,8 +186,8 @@ const App = {
|
||||
if (cached) {
|
||||
try {
|
||||
this.lookups = JSON.parse(cached);
|
||||
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined) {
|
||||
throw new Error('Outdated config cache (missing autoTimeMinHour)');
|
||||
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;
|
||||
@@ -198,16 +198,17 @@ const App = {
|
||||
}
|
||||
|
||||
try {
|
||||
const [queues, states, priorities, users, types, config] = await Promise.all([
|
||||
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 };
|
||||
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) {
|
||||
@@ -576,6 +577,76 @@ const App = {
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Start the app when DOM is ready
|
||||
|
||||
@@ -11,6 +11,7 @@ const Filters = {
|
||||
state_id: '',
|
||||
priority_id: '',
|
||||
user_id: '',
|
||||
customer_user_id: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
},
|
||||
@@ -19,6 +20,7 @@ const Filters = {
|
||||
state_id: '',
|
||||
priority_id: '',
|
||||
user_id: '',
|
||||
customer_user_id: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
}
|
||||
@@ -60,7 +62,7 @@ const Filters = {
|
||||
|
||||
/** Reset all filters */
|
||||
reset() {
|
||||
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', date_from: '', date_to: '' };
|
||||
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', customer_user_id: '', date_from: '', date_to: '' };
|
||||
if (this.currentMode === 'my') {
|
||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
this.state.user_id = activeAgentId;
|
||||
@@ -107,6 +109,36 @@ const Filters = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Helper to compute trigger button label text for customer users */
|
||||
getCustomerUserMultiselectLabel(lookups) {
|
||||
const selectedList = Array.isArray(this.state.customer_user_id)
|
||||
? this.state.customer_user_id
|
||||
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||
|
||||
if (selectedList.length === 0) {
|
||||
return 'Tutti';
|
||||
}
|
||||
const selectedNames = (lookups.customerUsers || [])
|
||||
.filter(u => selectedList.includes(String(u.login)))
|
||||
.map(u => `${u.last_name} ${u.first_name}`);
|
||||
|
||||
if (selectedNames.length === (lookups.customerUsers || []).length) {
|
||||
return 'Tutti';
|
||||
} else if (selectedNames.length <= 2) {
|
||||
return selectedNames.join(', ');
|
||||
} else {
|
||||
return `${selectedNames.length} selezionati`;
|
||||
}
|
||||
},
|
||||
|
||||
/** Update customer user label DOM element dynamically */
|
||||
updateCustomerUserMultiselectLabel(lookups) {
|
||||
const labelEl = document.getElementById('customer-user-multiselect-label');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = this.getCustomerUserMultiselectLabel(lookups);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render filter bar HTML.
|
||||
* @param {Object} lookups - { queues, states, priorities, users }
|
||||
@@ -123,6 +155,7 @@ const Filters = {
|
||||
};
|
||||
|
||||
const currentLabel = this.getStateMultiselectLabel(lookups);
|
||||
const customerUserLabel = this.getCustomerUserMultiselectLabel(lookups);
|
||||
|
||||
return `
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
@@ -174,6 +207,23 @@ const Filters = {
|
||||
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Utente Cliente</span>
|
||||
<div class="multiselect-dropdown" id="customer-user-multiselect-dropdown" style="min-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||
<span class="multiselect-label" id="customer-user-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:180px;">${App.escapeHtml(customerUserLabel)}</span>
|
||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||
</div>
|
||||
<div class="multiselect-popover" id="customer-user-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||
<input type="text" id="customer-user-search-input" placeholder="Cerca utente..." style="width:100%; padding:6px 8px; font-size:0.8rem; background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); margin-bottom:6px; box-sizing:border-box; color:var(--text-primary); font-family:inherit;" autocomplete="off" />
|
||||
<div id="customer-user-items-container" style="display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||
<!-- Populated dynamically -->
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||
<button type="button" class="btn btn-ghost btn-xs" id="customer-user-multiselect-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||
<button type="button" class="btn btn-primary btn-xs" id="customer-user-multiselect-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Da Data/Ora</span>
|
||||
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
@@ -265,6 +315,117 @@ const Filters = {
|
||||
});
|
||||
}
|
||||
|
||||
// Customer User Multiselect Popover binding
|
||||
const cuDropdown = document.getElementById('customer-user-multiselect-dropdown');
|
||||
const cuPopover = document.getElementById('customer-user-multiselect-popover');
|
||||
const cuSearchInput = document.getElementById('customer-user-search-input');
|
||||
const cuItemsContainer = document.getElementById('customer-user-items-container');
|
||||
const cuOkBtn = document.getElementById('customer-user-multiselect-ok');
|
||||
const cuClearBtn = document.getElementById('customer-user-multiselect-clear');
|
||||
|
||||
const renderCustomerUserItems = () => {
|
||||
if (!cuItemsContainer) return;
|
||||
const q = (cuSearchInput ? cuSearchInput.value : '').toLowerCase().trim();
|
||||
const selectedList = Array.isArray(this.state.customer_user_id)
|
||||
? this.state.customer_user_id
|
||||
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||
|
||||
const filtered = (App.lookups.customerUsers || []).filter(u => {
|
||||
const fullName = `${u.last_name} ${u.first_name} (${u.login})`.toLowerCase();
|
||||
return fullName.includes(q);
|
||||
});
|
||||
|
||||
cuItemsContainer.innerHTML = filtered.map(u => {
|
||||
const isSelected = selectedList.includes(String(u.login));
|
||||
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||
return `
|
||||
<div class="customer-user-multiselect-item ${isSelected ? 'active' : ''}" data-value="${u.login}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||
${App.escapeHtml(u.last_name)} ${App.escapeHtml(u.first_name)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(u.login)})</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Bind clicks to items
|
||||
cuItemsContainer.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const login = item.dataset.value;
|
||||
const currentSelected = Array.isArray(this.state.customer_user_id)
|
||||
? [...this.state.customer_user_id]
|
||||
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||
|
||||
const idx = currentSelected.indexOf(login);
|
||||
if (idx > -1) {
|
||||
currentSelected.splice(idx, 1);
|
||||
item.classList.remove('active');
|
||||
item.style.background = '';
|
||||
item.style.color = '';
|
||||
} else {
|
||||
currentSelected.push(login);
|
||||
item.classList.add('active');
|
||||
item.style.background = 'var(--accent-primary)';
|
||||
item.style.color = '#fff';
|
||||
}
|
||||
this.state.customer_user_id = currentSelected.join(',');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (cuDropdown && cuPopover) {
|
||||
cuDropdown.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Close other popovers
|
||||
const statePopover = document.getElementById('state-multiselect-popover');
|
||||
if (statePopover) statePopover.style.display = 'none';
|
||||
|
||||
const isOpen = cuPopover.style.display === 'block';
|
||||
cuPopover.style.display = isOpen ? 'none' : 'block';
|
||||
if (!isOpen) {
|
||||
if (cuSearchInput) {
|
||||
cuSearchInput.value = '';
|
||||
}
|
||||
renderCustomerUserItems();
|
||||
setTimeout(() => {
|
||||
if (cuSearchInput) cuSearchInput.focus();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
cuPopover.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
cuPopover.style.display = 'none';
|
||||
});
|
||||
|
||||
if (cuSearchInput) {
|
||||
cuSearchInput.addEventListener('input', () => {
|
||||
renderCustomerUserItems();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (cuOkBtn) {
|
||||
cuOkBtn.addEventListener('click', () => {
|
||||
this.save();
|
||||
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||
if (cuPopover) cuPopover.style.display = 'none';
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
}
|
||||
|
||||
if (cuClearBtn) {
|
||||
cuClearBtn.addEventListener('click', () => {
|
||||
this.state.customer_user_id = '';
|
||||
this.save();
|
||||
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||
if (cuSearchInput) cuSearchInput.value = '';
|
||||
renderCustomerUserItems();
|
||||
if (cuPopover) cuPopover.style.display = 'none';
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
}
|
||||
|
||||
const resetBtn = document.getElementById('filter-reset');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', () => {
|
||||
@@ -282,7 +443,7 @@ const Filters = {
|
||||
}
|
||||
});
|
||||
|
||||
// Also clear multiselect items and label
|
||||
// Also clear state multiselect items and label
|
||||
const statePopover = document.getElementById('state-multiselect-popover');
|
||||
if (statePopover) {
|
||||
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => {
|
||||
@@ -293,6 +454,19 @@ const Filters = {
|
||||
}
|
||||
this.updateStateMultiselectLabel(App.lookups);
|
||||
|
||||
// Also clear customer user multiselect items and label
|
||||
const cuPopover = document.getElementById('customer-user-multiselect-popover');
|
||||
if (cuPopover) {
|
||||
const searchInput = cuPopover.querySelector('#customer-user-search-input');
|
||||
if (searchInput) searchInput.value = '';
|
||||
cuPopover.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
item.style.background = '';
|
||||
item.style.color = '';
|
||||
});
|
||||
}
|
||||
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ const TicketDetailView = {
|
||||
? `<iframe srcdoc="${a.a_body.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
||||
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
||||
|
||||
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id && att.filename !== 'file-1');
|
||||
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id && att.filename !== 'file-1' && att.filename !== 'file-2');
|
||||
|
||||
return `
|
||||
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
||||
@@ -218,6 +218,7 @@ const TicketDetailView = {
|
||||
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
|
||||
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
|
||||
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
|
||||
<button class="btn-delete-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Elimina Articolo">🗑️</button>
|
||||
</div>
|
||||
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
||||
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
|
||||
@@ -827,6 +828,32 @@ const TicketDetailView = {
|
||||
p.style.display = 'none';
|
||||
});
|
||||
});
|
||||
// Delete Article Event Listeners
|
||||
document.querySelectorAll('.btn-delete-article').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const articleId = btn.dataset.articleId;
|
||||
const ok = await App.confirm(
|
||||
"Elimina Articolo",
|
||||
"Sei sicuro di voler eliminare questa nota/articolo? Tutti i file allegati e i tempi ad esso associati verranno rimossi permanentemente.",
|
||||
{ confirmText: 'Elimina', cancelText: 'Annulla' }
|
||||
);
|
||||
if (ok) {
|
||||
try {
|
||||
btn.disabled = true;
|
||||
await App.api(`/api/tickets/articles/${articleId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
Toast.success('Articolo/nota eliminato con successo!');
|
||||
this.render(this.ticketId);
|
||||
} catch (err) {
|
||||
Toast.error('Errore durante l\'eliminazione: ' + err.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// HTML Mode Toggle Listener
|
||||
const htmlToggle = document.getElementById('html-toggle');
|
||||
if (htmlToggle) {
|
||||
|
||||
Reference in New Issue
Block a user