feat: gestione gruppi di ticket. feat: miglioramento filtri ticket a mio carico e ticket. feat: possiblità di copiare il numero del tocket con pulsante copia

This commit is contained in:
2026-07-09 08:36:03 +02:00
parent 5b914eb198
commit a89605b888
14 changed files with 1674 additions and 182 deletions
+446 -157
View File
@@ -26,6 +26,16 @@ const Filters = {
}
},
customerCache: {},
presets: [],
selectedPresetId: null,
saveCustomerCache() {
try {
localStorage.setItem('otrs_turbo_customer_cache', JSON.stringify(this.customerCache));
} catch (e) { /* ignore */ }
},
// Dynamic state getter based on active mode
get state() {
return this.allStates[this.currentMode];
@@ -50,6 +60,11 @@ const Filters = {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.allStates.my.user_id = activeAgentId;
}
const savedCache = localStorage.getItem('otrs_turbo_customer_cache');
if (savedCache) {
this.customerCache = JSON.parse(savedCache);
}
} catch (e) { /* ignore */ }
},
@@ -79,37 +94,31 @@ const Filters = {
return params;
},
/** Helper to compute trigger button label text */
getStateMultiselectLabel(lookups) {
const selectedList = Array.isArray(this.state.state_id)
? this.state.state_id
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
/** Helper to compute trigger button label text for multiselect dropdowns */
getMultiselectLabel(selectedVal, itemsList, labelField = 'name', idField = 'id') {
const selectedList = Array.isArray(selectedVal)
? selectedVal
: (typeof selectedVal === 'string' && selectedVal ? selectedVal.split(',') : []);
if (selectedList.length === 0) {
return 'Tutti';
}
const selectedNames = (lookups.states || [])
.filter(s => selectedList.includes(String(s.id)))
.map(s => s.name);
if (selectedNames.length === (lookups.states || []).length) {
const selectedNames = (itemsList || [])
.filter(item => selectedList.includes(String(item[idField])))
.map(item => typeof labelField === 'function' ? labelField(item) : item[labelField]);
if (selectedNames.length === 0) {
return 'Tutti';
} else if (selectedNames.length <= 2) {
}
if (selectedNames.length <= 2) {
return selectedNames.join(', ');
} else {
return `${selectedNames.length} selezionati`;
}
},
/** Update label DOM element dynamically */
updateStateMultiselectLabel(lookups) {
const labelEl = document.getElementById('state-multiselect-label');
if (labelEl) {
labelEl.textContent = this.getStateMultiselectLabel(lookups);
}
},
/** 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
@@ -118,13 +127,22 @@ const Filters = {
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}`);
const selectedNames = selectedList.map(login => {
if (this.customerCache && this.customerCache[login]) {
return this.customerCache[login];
}
const found = (lookups.customerUsers || []).find(u => String(u.login) === String(login));
if (found) {
const fullName = `${found.last_name} ${found.first_name}`.trim();
this.customerCache[login] = fullName;
this.saveCustomerCache();
return fullName;
}
return login;
});
if (selectedNames.length === (lookups.customerUsers || []).length) {
return 'Tutti';
} else if (selectedNames.length <= 2) {
if (selectedNames.length <= 2) {
return selectedNames.join(', ');
} else {
return `${selectedNames.length} selezionati`;
@@ -145,72 +163,147 @@ const Filters = {
* @returns {string} HTML string
*/
renderBar(lookups) {
const makeOptions = (items, valueKey, labelKey, selectedVal) => {
return items.map(item => {
const val = item[valueKey];
const label = typeof labelKey === 'function' ? labelKey(item) : item[labelKey];
const sel = String(val) === String(selectedVal) ? 'selected' : '';
return `<option value="${val}" ${sel}>${label}</option>`;
}).join('');
};
const currentLabel = this.getStateMultiselectLabel(lookups);
const stateLabel = this.getMultiselectLabel(this.state.state_id, lookups.states);
const queueLabel = this.getMultiselectLabel(this.state.queue_id, lookups.queues);
const priorityLabel = this.getMultiselectLabel(this.state.priority_id, lookups.priorities);
const ownerLabel = this.getMultiselectLabel(this.state.user_id, lookups.users, u => `${u.first_name} ${u.last_name}`);
const customerUserLabel = this.getCustomerUserMultiselectLabel(lookups);
const presetOptions = (this.presets || []).map(p => {
const sel = String(p.id) === String(this.selectedPresetId) ? 'selected' : '';
return `<option value="${p.id}" ${sel}>${App.escapeHtml(p.name)}</option>`;
}).join('');
return `
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<span class="filter-label">Preset</span>
<div style="display:flex; gap:4px; align-items:center;">
<select class="form-select" id="filter-presets-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0; min-width:130px; border-color:var(--border-subtle);">
<option value="">-- Nessuno --</option>
${presetOptions}
</select>
<button class="btn btn-ghost btn-xs" id="btn-save-preset" style="height:28px; padding:0 8px; font-size:0.75rem;" title="Salva filtri attuali come preset">Salva</button>
<button class="btn btn-ghost btn-xs" id="btn-delete-preset" style="height:28px; padding:0 8px; font-size:0.75rem; color:var(--danger);" title="Elimina il preset selezionato">Elimina</button>
</div>
</div>
<!-- Stato -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Stato</span>
<div class="multiselect-dropdown" id="state-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="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:130px;">${App.escapeHtml(currentLabel)}</span>
<div class="multiselect-dropdown" id="state-multiselect-dropdown" style="min-width: 110px; max-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="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(stateLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="state-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 220px; 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;">
<div style="display:flex; flex-direction:column; gap:4px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
<input type="text" class="form-input ms-search" placeholder="Cerca..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.states || []).map(s => {
const selectedList = Array.isArray(this.state.state_id)
? this.state.state_id
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
const selectedList = String(this.state.state_id || '').split(',').filter(Boolean);
const isSelected = selectedList.includes(String(s.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="state-multiselect-item ${isSelected ? 'active' : ''}" data-value="${s.id}" 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}">
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${s.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
${App.escapeHtml(s.name)}
</div>
`;
}).join('')}
</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="state-multiselect-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs" id="state-multiselect-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</div>
<div class="filter-group">
<!-- Coda -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Coda</span>
<select class="filter-select" data-filter="queue_id" id="filter-queue">
<option value="">Tutte</option>
${makeOptions(lookups.queues || [], 'id', 'name', this.state.queue_id)}
</select>
<div class="multiselect-dropdown" id="queue-multiselect-dropdown" style="min-width: 120px; max-width: 150px; 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="queue-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(queueLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="queue-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" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:300px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.queues || []).map(q => {
const selectedList = String(this.state.queue_id || '').split(',').filter(Boolean);
const isSelected = selectedList.includes(String(q.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${q.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
${App.escapeHtml(q.name)}
</div>
`;
}).join('')}
</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 ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</div>
<div class="filter-group">
<!-- Priorità -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Priorità</span>
<select class="filter-select" data-filter="priority_id" id="filter-priority">
<option value="">Tutte</option>
${makeOptions(lookups.priorities || [], 'id', 'name', this.state.priority_id)}
</select>
<div class="multiselect-dropdown" id="priority-multiselect-dropdown" style="min-width: 90px; max-width: 120px; 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="priority-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(priorityLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="priority-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 200px; 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" class="form-input ms-search" placeholder="Cerca priorità..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.priorities || []).map(p => {
const selectedList = String(this.state.priority_id || '').split(',').filter(Boolean);
const isSelected = selectedList.includes(String(p.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${p.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
${App.escapeHtml(p.name)}
</div>
`;
}).join('')}
</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 ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</div>
<div class="filter-group">
<!-- Owner -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Owner</span>
<select class="filter-select" data-filter="user_id" id="filter-owner">
<option value="">Tutti</option>
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
</select>
<div class="multiselect-dropdown" id="owner-multiselect-dropdown" style="min-width: 120px; max-width: 150px; 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="owner-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(ownerLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="owner-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 240px; 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" class="form-input ms-search" placeholder="Cerca owner..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.users || []).map(u => {
const selectedList = String(this.state.user_id || '').split(',').filter(Boolean);
const isSelected = selectedList.includes(String(u.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${u.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}
</div>
`;
}).join('')}
</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 ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</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>
<div class="multiselect-dropdown" id="customer-user-multiselect-dropdown" style="min-width: 130px; max-width: 160px; 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;">${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;">
@@ -226,11 +319,11 @@ const Filters = {
</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;" />
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 170px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
</div>
<div class="filter-group">
<span class="filter-label">A Data/Ora</span>
<input type="datetime-local" class="filter-select" data-filter="date_to" id="filter-date-to" value="${this.state.date_to || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
<input type="datetime-local" class="filter-select" data-filter="date_to" id="filter-date-to" value="${this.state.date_to || ''}" style="width: 170px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
</div>
<div class="filters-actions">
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
@@ -239,7 +332,6 @@ const Filters = {
`;
},
/** Bind change events to filter selects */
bindEvents(onFilterChange) {
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
const selects = document.querySelectorAll('.filter-select[data-filter]');
@@ -247,73 +339,117 @@ const Filters = {
sel.disabled = false;
sel.addEventListener('change', (e) => {
this.selectedPresetId = null;
this.state[e.target.dataset.filter] = e.target.value;
this.save();
if (onFilterChange) onFilterChange();
});
});
// Multiselect dropdown toggle event
const dropdown = document.getElementById('state-multiselect-dropdown');
const popover = document.getElementById('state-multiselect-popover');
const okBtn = document.getElementById('state-multiselect-ok');
const clearBtn = document.getElementById('state-multiselect-clear');
// Helper to bind standard multiselect popover events
const bindStandardMultiselect = (filterKey, dropdownId, popoverId, labelElId, itemsList, labelField, idField = 'id') => {
const dropdown = document.getElementById(dropdownId);
const popover = document.getElementById(popoverId);
if (!dropdown || !popover) return;
if (dropdown && popover) {
const searchInput = popover.querySelector('.ms-search');
const itemsContainer = popover.querySelector('.ms-items-container');
const okBtn = popover.querySelector('.ms-ok');
const clearBtn = popover.querySelector('.ms-clear');
// Toggle popover visibility
dropdown.addEventListener('click', (e) => {
e.stopPropagation();
// Close all other popovers
document.querySelectorAll('.multiselect-popover').forEach(p => {
if (p !== popover) p.style.display = 'none';
});
const isOpen = popover.style.display === 'block';
popover.style.display = isOpen ? 'none' : 'block';
if (!isOpen && searchInput) {
searchInput.value = '';
searchInput.dispatchEvent(new Event('input'));
setTimeout(() => searchInput.focus(), 50);
}
});
popover.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => {
popover.style.display = 'none';
});
// Search matching items
if (searchInput && itemsContainer) {
searchInput.addEventListener('input', () => {
const q = searchInput.value.toLowerCase().trim();
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(q) ? 'block' : 'none';
});
});
}
// Bind item clicks
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const isActive = item.classList.toggle('active');
if (isActive) {
item.style.background = 'var(--accent-primary)';
item.style.color = '#fff';
} else {
// Bind selection clicks
if (itemsContainer) {
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const isActive = item.classList.toggle('active');
if (isActive) {
item.style.background = 'var(--accent-primary)';
item.style.color = '#fff';
} else {
item.style.background = '';
item.style.color = '';
}
});
});
}
// Apply selection (OK click)
if (okBtn) {
okBtn.addEventListener('click', () => {
const activeItems = itemsContainer.querySelectorAll('.ms-item.active');
const ids = Array.from(activeItems).map(item => item.dataset.value);
this.selectedPresetId = null;
this.state[filterKey] = ids.join(',');
this.save();
const labelEl = document.getElementById(labelElId);
if (labelEl) {
labelEl.textContent = this.getMultiselectLabel(this.state[filterKey], itemsList, labelField, idField);
}
popover.style.display = 'none';
if (onFilterChange) onFilterChange();
});
}
// Reset selection
if (clearBtn) {
clearBtn.addEventListener('click', () => {
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
});
this.selectedPresetId = null;
this.state[filterKey] = '';
this.save();
const labelEl = document.getElementById(labelElId);
if (labelEl) {
labelEl.textContent = 'Tutti';
}
popover.style.display = 'none';
if (onFilterChange) onFilterChange();
});
});
}
}
};
if (okBtn) {
okBtn.addEventListener('click', () => {
const activeItems = popover.querySelectorAll('.state-multiselect-item.active');
const ids = Array.from(activeItems).map(item => item.dataset.value);
this.state.state_id = ids.join(',');
this.save();
this.updateStateMultiselectLabel(App.lookups);
popover.style.display = 'none';
if (onFilterChange) onFilterChange();
});
}
if (clearBtn) {
clearBtn.addEventListener('click', () => {
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
});
this.state.state_id = '';
this.save();
this.updateStateMultiselectLabel(App.lookups);
popover.style.display = 'none';
if (onFilterChange) onFilterChange();
});
}
// Bind standard multiselects
bindStandardMultiselect('state_id', 'state-multiselect-dropdown', 'state-multiselect-popover', 'state-multiselect-label', App.lookups.states, 'name');
bindStandardMultiselect('queue_id', 'queue-multiselect-dropdown', 'queue-multiselect-popover', 'queue-multiselect-label', App.lookups.queues, 'name');
bindStandardMultiselect('priority_id', 'priority-multiselect-dropdown', 'priority-multiselect-popover', 'priority-multiselect-label', App.lookups.priorities, 'name');
bindStandardMultiselect('user_id', 'owner-multiselect-dropdown', 'owner-multiselect-popover', 'owner-multiselect-label', App.lookups.users, u => `${u.first_name} ${u.last_name}`);
// Customer User Multiselect Popover binding
const cuDropdown = document.getElementById('customer-user-multiselect-dropdown');
@@ -322,34 +458,62 @@ const Filters = {
const cuItemsContainer = document.getElementById('customer-user-items-container');
const cuOkBtn = document.getElementById('customer-user-multiselect-ok');
const cuClearBtn = document.getElementById('customer-user-multiselect-clear');
let activeSearchController = null;
const renderCustomerUserItems = () => {
const renderCustomerUserItems = (searchResults = null) => {
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);
});
const uniqueSelectedLogins = Array.from(new Set(selectedList)).filter(Boolean);
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('');
let html = '';
// 1. Show selected items at the top
if (uniqueSelectedLogins.length > 0) {
html += `<div style="font-size:0.72rem; font-weight:700; color:var(--accent-primary); text-transform:uppercase; padding: 2px var(--space-sm); border-bottom:1px solid var(--border-subtle); margin-bottom:4px;">Selezionati</div>`;
uniqueSelectedLogins.forEach(login => {
const displayName = this.customerCache[login] || login;
html += `
<div class="customer-user-multiselect-item active" data-value="${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; background: var(--accent-primary); color: #fff; margin-bottom: 2px;">
${App.escapeHtml(displayName)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(login)})</span>
</div>
`;
});
}
// 2. Show search results below
let listToRender = searchResults || App.lookups.customerUsers || [];
listToRender = listToRender.filter(u => !uniqueSelectedLogins.includes(String(u.login)));
if (listToRender.length > 0) {
if (uniqueSelectedLogins.length > 0) {
html += `<div style="font-size:0.72rem; font-weight:700; color:var(--text-secondary); text-transform:uppercase; padding: 4px var(--space-sm) 2px; border-bottom:1px solid var(--border-subtle); margin-top:6px; margin-bottom:4px;">Risultati</div>`;
}
listToRender.forEach(u => {
const displayName = `${u.last_name} ${u.first_name}`.trim() || u.login;
html += `
<div class="customer-user-multiselect-item" data-value="${u.login}" data-display-name="${displayName}" 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; margin-bottom: 2px;">
${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>
`;
});
} else if (uniqueSelectedLogins.length === 0) {
html = `<div style="text-align:center; padding:var(--space-md); color:var(--text-muted); font-size:0.8rem;">Cerca digitando sopra...</div>`;
}
cuItemsContainer.innerHTML = html;
// Bind clicks to items
cuItemsContainer.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const login = item.dataset.value;
const displayName = item.dataset.displayName || this.customerCache[login] || login;
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(',') : []);
@@ -357,16 +521,13 @@ const Filters = {
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.customerCache[login] = displayName;
this.saveCustomerCache();
}
this.state.customer_user_id = currentSelected.join(',');
renderCustomerUserItems(searchResults);
});
});
};
@@ -376,8 +537,9 @@ const Filters = {
e.stopPropagation();
// Close other popovers
const statePopover = document.getElementById('state-multiselect-popover');
if (statePopover) statePopover.style.display = 'none';
document.querySelectorAll('.multiselect-popover').forEach(p => {
if (p !== cuPopover) p.style.display = 'none';
});
const isOpen = cuPopover.style.display === 'block';
cuPopover.style.display = isOpen ? 'none' : 'block';
@@ -394,19 +556,38 @@ const Filters = {
cuPopover.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => {
cuPopover.style.display = 'none';
});
let cuSearchDebounce;
if (cuSearchInput) {
cuSearchInput.addEventListener('input', () => {
renderCustomerUserItems();
clearTimeout(cuSearchDebounce);
const q = cuSearchInput.value.trim();
cuSearchDebounce = setTimeout(async () => {
if (activeSearchController) activeSearchController.abort();
activeSearchController = new AbortController();
try {
cuItemsContainer.innerHTML = '<div style="display:flex; justify-content:center; padding:12px;"><div class="spinner" style="width:18px;height:18px;border-width:2px;"></div></div>';
const users = await fetch(`/api/customer-users/search?q=${encodeURIComponent(q)}`, {
signal: activeSearchController.signal
}).then(r => r.json());
renderCustomerUserItems(users);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Search failed:', err);
renderCustomerUserItems([]);
}
}
}, 300);
});
}
}
if (cuOkBtn) {
cuOkBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.save();
this.updateCustomerUserMultiselectLabel(App.lookups);
if (cuPopover) cuPopover.style.display = 'none';
@@ -416,6 +597,7 @@ const Filters = {
if (cuClearBtn) {
cuClearBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.state.customer_user_id = '';
this.save();
this.updateCustomerUserMultiselectLabel(App.lookups);
@@ -426,33 +608,30 @@ const Filters = {
});
}
// Close all popovers on backdrop click
document.addEventListener('click', () => {
document.querySelectorAll('.multiselect-popover').forEach(p => {
p.style.display = 'none';
});
});
const resetBtn = document.getElementById('filter-reset');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.reset();
if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.state.user_id = activeAgentId;
this.save();
}
selects.forEach(s => {
if (isMyTickets && s.dataset.filter === 'user_id') {
s.value = localStorage.getItem('activeAgentId') || '1';
} else {
s.value = '';
}
});
// Also clear state multiselect items and label
const statePopover = document.getElementById('state-multiselect-popover');
if (statePopover) {
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
});
}
this.updateStateMultiselectLabel(App.lookups);
// Reset highlight states in popovers
document.querySelectorAll('.ms-items-container .ms-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
});
// Also clear customer user multiselect items and label
const cuPopover = document.getElementById('customer-user-multiselect-popover');
@@ -465,11 +644,121 @@ const Filters = {
item.style.color = '';
});
}
// Recompute labels
document.getElementById('state-multiselect-label').textContent = 'Tutti';
document.getElementById('queue-multiselect-label').textContent = 'Tutti';
document.getElementById('priority-multiselect-label').textContent = 'Tutti';
document.getElementById('owner-multiselect-label').textContent = isMyTickets ? this.getMultiselectLabel(this.state.user_id, App.lookups.users, u => `${u.first_name} ${u.last_name}`) : 'Tutti';
this.updateCustomerUserMultiselectLabel(App.lookups);
if (onFilterChange) onFilterChange();
});
}
// Load and bind presets
const loadPresets = async () => {
try {
const agentId = localStorage.getItem('activeAgentId') || '1';
this.presets = await App.api(`/api/presets?page_mode=${this.currentMode}`);
const select = document.getElementById('filter-presets-select');
if (select) {
select.innerHTML = '<option value="">-- Nessuno --</option>' + this.presets.map(p => {
const sel = String(p.id) === String(this.selectedPresetId) ? 'selected' : '';
return `<option value="${p.id}" ${sel}>${App.escapeHtml(p.name)}</option>`;
}).join('');
}
} catch (err) {
console.warn('Failed to load presets:', err);
}
};
loadPresets();
const selectPresets = document.getElementById('filter-presets-select');
if (selectPresets) {
selectPresets.addEventListener('change', (e) => {
const presetId = e.target.value;
if (!presetId) {
this.selectedPresetId = null;
if (onFilterChange) onFilterChange();
return;
}
const preset = this.presets.find(p => String(p.id) === String(presetId));
if (preset) {
try {
const filters = JSON.parse(preset.filters_json);
this.state = Object.assign({
queue_id: '',
state_id: '',
priority_id: '',
user_id: '',
customer_user_id: '',
date_from: '',
date_to: ''
}, filters);
this.selectedPresetId = preset.id;
this.save();
if (onFilterChange) onFilterChange();
} catch (err) {
Toast.error('Errore nel caricamento del preset: ' + err.message);
}
}
});
}
const btnSavePreset = document.getElementById('btn-save-preset');
if (btnSavePreset) {
btnSavePreset.addEventListener('click', async () => {
const name = await App.prompt('Nuovo Preset', 'Inserisci il nome per questo preset di filtri:');
if (!name || !name.trim()) return;
try {
btnSavePreset.disabled = true;
const newPreset = await App.api('/api/presets', {
method: 'POST',
body: JSON.stringify({
name: name.trim(),
page_mode: this.currentMode,
filters: this.state
})
});
Toast.success('Preset salvato con successo!');
this.selectedPresetId = newPreset.id;
if (onFilterChange) onFilterChange();
} catch (err) {
Toast.error('Errore nel salvataggio del preset: ' + err.message);
btnSavePreset.disabled = false;
}
});
}
const btnDeletePreset = document.getElementById('btn-delete-preset');
if (btnDeletePreset) {
btnDeletePreset.addEventListener('click', async () => {
const select = document.getElementById('filter-presets-select');
const presetId = select ? select.value : '';
if (!presetId) {
Toast.warning('Seleziona prima un preset da eliminare');
return;
}
const ok = await App.confirm('Elimina Preset', 'Sei sicuro di voler eliminare questo preset?');
if (!ok) return;
try {
btnDeletePreset.disabled = true;
await App.api(`/api/presets/${presetId}`, { method: 'DELETE' });
Toast.success('Preset eliminato con successo');
this.selectedPresetId = null;
if (onFilterChange) onFilterChange();
} catch (err) {
Toast.error('Errore nell\'eliminazione del preset: ' + err.message);
btnDeletePreset.disabled = false;
}
});
}
},
};