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
+5
View File
@@ -0,0 +1,5 @@
---
trigger: always_on
---
Non usare le notifiche native dei browser.
+30
View File
@@ -47,6 +47,36 @@ db.exec(`
) )
`); `);
db.exec(`
CREATE TABLE IF NOT EXISTS ticket_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
descrizione TEXT,
master_ticket_id INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS ticket_group_members (
group_id INTEGER NOT NULL,
ticket_id INTEGER NOT NULL,
PRIMARY KEY (group_id, ticket_id),
FOREIGN KEY (group_id) REFERENCES ticket_groups (id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS filter_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
name TEXT NOT NULL,
page_mode TEXT NOT NULL,
filters_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
try { try {
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`); db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
} catch (e) { } catch (e) {
+22 -9
View File
@@ -60,6 +60,17 @@
<span class="nav-badge" id="my-ticket-count"></span> <span class="nav-badge" id="my-ticket-count"></span>
</a> </a>
</li> </li>
<li>
<a href="#/tickets/groups" class="nav-link" data-view="ticket-groups" id="nav-ticket-groups">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Gruppi Ticket</span>
</a>
</li>
<li> <li>
<a href="#/tickets/new" class="nav-link" data-view="new-ticket" id="nav-new-ticket"> <a href="#/tickets/new" class="nav-link" data-view="new-ticket" id="nav-new-ticket">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -80,6 +91,16 @@
<span>Apertura Massiva</span> <span>Apertura Massiva</span>
</a> </a>
</li> </li>
<hr>
<li>
<a href="#/signatures" class="nav-link" data-view="signatures" id="nav-signatures">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
<span>Firme Email</span>
</a>
</li>
<li> <li>
<a href="#/activity" class="nav-link" data-view="activity" id="nav-activity"> <a href="#/activity" class="nav-link" data-view="activity" id="nav-activity">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -89,15 +110,6 @@
<span>Storico Attività Turbo</span> <span>Storico Attività Turbo</span>
</a> </a>
</li> </li>
<li>
<a href="#/signatures" class="nav-link" data-view="signatures" id="nav-signatures">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
<span>Firme Email</span>
</a>
</li>
</ul> </ul>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -186,6 +198,7 @@
<script src="/js/views/activityLog.js"></script> <script src="/js/views/activityLog.js"></script>
<script src="/js/views/emailCompose.js"></script> <script src="/js/views/emailCompose.js"></script>
<script src="/js/views/signatures.js"></script> <script src="/js/views/signatures.js"></script>
<script src="/js/views/ticketGroups.js"></script>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
+92
View File
@@ -142,6 +142,11 @@ const App = {
titleEl.textContent = 'Apertura Massiva Ticket'; titleEl.textContent = 'Apertura Massiva Ticket';
TicketBulkView.render(); 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') { } else if (hash === '#/activity') {
document.getElementById('nav-activity')?.classList.add('active'); document.getElementById('nav-activity')?.classList.add('active');
titleEl.textContent = 'Storico Attività'; titleEl.textContent = 'Storico Attività';
@@ -721,6 +726,93 @@ const App = {
}); });
}); });
}, },
/** 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);
});
});
},
}; };
// Start the app when DOM is ready // Start the app when DOM is ready
+444 -155
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 // Dynamic state getter based on active mode
get state() { get state() {
return this.allStates[this.currentMode]; return this.allStates[this.currentMode];
@@ -50,6 +60,11 @@ const Filters = {
const activeAgentId = localStorage.getItem('activeAgentId') || '1'; const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.allStates.my.user_id = activeAgentId; this.allStates.my.user_id = activeAgentId;
} }
const savedCache = localStorage.getItem('otrs_turbo_customer_cache');
if (savedCache) {
this.customerCache = JSON.parse(savedCache);
}
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}, },
@@ -79,37 +94,31 @@ const Filters = {
return params; return params;
}, },
/** Helper to compute trigger button label text */ /** Helper to compute trigger button label text for multiselect dropdowns */
getStateMultiselectLabel(lookups) { getMultiselectLabel(selectedVal, itemsList, labelField = 'name', idField = 'id') {
const selectedList = Array.isArray(this.state.state_id) const selectedList = Array.isArray(selectedVal)
? this.state.state_id ? selectedVal
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []); : (typeof selectedVal === 'string' && selectedVal ? selectedVal.split(',') : []);
if (selectedList.length === 0) { if (selectedList.length === 0) {
return 'Tutti'; 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'; return 'Tutti';
} else if (selectedNames.length <= 2) { }
if (selectedNames.length <= 2) {
return selectedNames.join(', '); return selectedNames.join(', ');
} else { } else {
return `${selectedNames.length} selezionati`; 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) { getCustomerUserMultiselectLabel(lookups) {
const selectedList = Array.isArray(this.state.customer_user_id) const selectedList = Array.isArray(this.state.customer_user_id)
? this.state.customer_user_id ? this.state.customer_user_id
@@ -118,13 +127,22 @@ const Filters = {
if (selectedList.length === 0) { if (selectedList.length === 0) {
return 'Tutti'; 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) { const selectedNames = selectedList.map(login => {
return 'Tutti'; if (this.customerCache && this.customerCache[login]) {
} else if (selectedNames.length <= 2) { 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 <= 2) {
return selectedNames.join(', '); return selectedNames.join(', ');
} else { } else {
return `${selectedNames.length} selezionati`; return `${selectedNames.length} selezionati`;
@@ -145,72 +163,147 @@ const Filters = {
* @returns {string} HTML string * @returns {string} HTML string
*/ */
renderBar(lookups) { renderBar(lookups) {
const makeOptions = (items, valueKey, labelKey, selectedVal) => { const stateLabel = this.getMultiselectLabel(this.state.state_id, lookups.states);
return items.map(item => { const queueLabel = this.getMultiselectLabel(this.state.queue_id, lookups.queues);
const val = item[valueKey]; const priorityLabel = this.getMultiselectLabel(this.state.priority_id, lookups.priorities);
const label = typeof labelKey === 'function' ? labelKey(item) : item[labelKey]; const ownerLabel = this.getMultiselectLabel(this.state.user_id, lookups.users, u => `${u.first_name} ${u.last_name}`);
const sel = String(val) === String(selectedVal) ? 'selected' : '';
return `<option value="${val}" ${sel}>${label}</option>`;
}).join('');
};
const currentLabel = this.getStateMultiselectLabel(lookups);
const customerUserLabel = this.getCustomerUserMultiselectLabel(lookups); 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 ` return `
<div class="filters-bar" id="filters-bar"> <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;"> <div class="filter-group" style="position:relative;">
<span class="filter-label">Stato</span> <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;"> <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; max-width:130px;">${App.escapeHtml(currentLabel)}</span> <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> <span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div> </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 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 => { ${(lookups.states || []).map(s => {
const selectedList = Array.isArray(this.state.state_id) const selectedList = String(this.state.state_id || '').split(',').filter(Boolean);
? this.state.state_id
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
const isSelected = selectedList.includes(String(s.id)); const isSelected = selectedList.includes(String(s.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : ''; const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return ` 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)} ${App.escapeHtml(s.name)}
</div> </div>
`; `;
}).join('')} }).join('')}
</div> </div>
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;"> <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-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" 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-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div> </div>
</div> </div>
</div> </div>
<div class="filter-group">
<!-- Coda -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Coda</span> <span class="filter-label">Coda</span>
<select class="filter-select" data-filter="queue_id" id="filter-queue"> <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;">
<option value="">Tutte</option> <span class="multiselect-label" id="queue-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(queueLabel)}</span>
${makeOptions(lookups.queues || [], 'id', 'name', this.state.queue_id)} <span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</select> </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>
<div class="filter-group">
<!-- Priorità -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Priorità</span> <span class="filter-label">Priorità</span>
<select class="filter-select" data-filter="priority_id" id="filter-priority"> <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;">
<option value="">Tutte</option> <span class="multiselect-label" id="priority-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(priorityLabel)}</span>
${makeOptions(lookups.priorities || [], 'id', 'name', this.state.priority_id)} <span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</select> </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>
<div class="filter-group">
<!-- Owner -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Owner</span> <span class="filter-label">Owner</span>
<select class="filter-select" data-filter="user_id" id="filter-owner"> <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;">
<option value="">Tutti</option> <span class="multiselect-label" id="owner-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(ownerLabel)}</span>
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)} <span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</select> </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>
<div class="filter-group" style="position:relative;"> <div class="filter-group" style="position:relative;">
<span class="filter-label">Utente Cliente</span> <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;"> <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; max-width:180px;">${App.escapeHtml(customerUserLabel)}</span> <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> <span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div> </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;"> <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>
<div class="filter-group"> <div class="filter-group">
<span class="filter-label">Da Data/Ora</span> <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>
<div class="filter-group"> <div class="filter-group">
<span class="filter-label">A Data/Ora</span> <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>
<div class="filters-actions"> <div class="filters-actions">
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button> <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) { bindEvents(onFilterChange) {
const isMyTickets = window.location.hash.startsWith('#/tickets/my'); const isMyTickets = window.location.hash.startsWith('#/tickets/my');
const selects = document.querySelectorAll('.filter-select[data-filter]'); const selects = document.querySelectorAll('.filter-select[data-filter]');
@@ -247,73 +339,117 @@ const Filters = {
sel.disabled = false; sel.disabled = false;
sel.addEventListener('change', (e) => { sel.addEventListener('change', (e) => {
this.selectedPresetId = null;
this.state[e.target.dataset.filter] = e.target.value; this.state[e.target.dataset.filter] = e.target.value;
this.save(); this.save();
if (onFilterChange) onFilterChange(); if (onFilterChange) onFilterChange();
}); });
}); });
// Multiselect dropdown toggle event // Helper to bind standard multiselect popover events
const dropdown = document.getElementById('state-multiselect-dropdown'); const bindStandardMultiselect = (filterKey, dropdownId, popoverId, labelElId, itemsList, labelField, idField = 'id') => {
const popover = document.getElementById('state-multiselect-popover'); const dropdown = document.getElementById(dropdownId);
const okBtn = document.getElementById('state-multiselect-ok'); const popover = document.getElementById(popoverId);
const clearBtn = document.getElementById('state-multiselect-clear'); 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) => { dropdown.addEventListener('click', (e) => {
e.stopPropagation(); 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'; const isOpen = popover.style.display === 'block';
popover.style.display = isOpen ? 'none' : '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()); popover.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => { // Search matching items
popover.style.display = 'none'; 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 // Bind selection clicks
popover.querySelectorAll('.state-multiselect-item').forEach(item => { if (itemsContainer) {
item.addEventListener('click', (e) => { itemsContainer.querySelectorAll('.ms-item').forEach(item => {
e.stopPropagation(); item.addEventListener('click', (e) => {
const isActive = item.classList.toggle('active'); e.stopPropagation();
if (isActive) { const isActive = item.classList.toggle('active');
item.style.background = 'var(--accent-primary)'; if (isActive) {
item.style.color = '#fff'; item.style.background = 'var(--accent-primary)';
} else { 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.background = '';
item.style.color = ''; 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) { // Bind standard multiselects
okBtn.addEventListener('click', () => { bindStandardMultiselect('state_id', 'state-multiselect-dropdown', 'state-multiselect-popover', 'state-multiselect-label', App.lookups.states, 'name');
const activeItems = popover.querySelectorAll('.state-multiselect-item.active'); bindStandardMultiselect('queue_id', 'queue-multiselect-dropdown', 'queue-multiselect-popover', 'queue-multiselect-label', App.lookups.queues, 'name');
const ids = Array.from(activeItems).map(item => item.dataset.value); bindStandardMultiselect('priority_id', 'priority-multiselect-dropdown', 'priority-multiselect-popover', 'priority-multiselect-label', App.lookups.priorities, 'name');
this.state.state_id = ids.join(','); bindStandardMultiselect('user_id', 'owner-multiselect-dropdown', 'owner-multiselect-popover', 'owner-multiselect-label', App.lookups.users, u => `${u.first_name} ${u.last_name}`);
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();
});
}
// Customer User Multiselect Popover binding // Customer User Multiselect Popover binding
const cuDropdown = document.getElementById('customer-user-multiselect-dropdown'); const cuDropdown = document.getElementById('customer-user-multiselect-dropdown');
@@ -323,33 +459,61 @@ const Filters = {
const cuOkBtn = document.getElementById('customer-user-multiselect-ok'); const cuOkBtn = document.getElementById('customer-user-multiselect-ok');
const cuClearBtn = document.getElementById('customer-user-multiselect-clear'); const cuClearBtn = document.getElementById('customer-user-multiselect-clear');
const renderCustomerUserItems = () => { let activeSearchController = null;
const renderCustomerUserItems = (searchResults = null) => {
if (!cuItemsContainer) return; if (!cuItemsContainer) return;
const q = (cuSearchInput ? cuSearchInput.value : '').toLowerCase().trim();
const selectedList = Array.isArray(this.state.customer_user_id) const selectedList = Array.isArray(this.state.customer_user_id)
? 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(',') : []); : (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 uniqueSelectedLogins = Array.from(new Set(selectedList)).filter(Boolean);
const fullName = `${u.last_name} ${u.first_name} (${u.login})`.toLowerCase();
return fullName.includes(q);
});
cuItemsContainer.innerHTML = filtered.map(u => { let html = '';
const isSelected = selectedList.includes(String(u.login));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : ''; // 1. Show selected items at the top
return ` if (uniqueSelectedLogins.length > 0) {
<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}"> 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>`;
${App.escapeHtml(u.last_name)} ${App.escapeHtml(u.first_name)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(u.login)})</span> uniqueSelectedLogins.forEach(login => {
</div> const displayName = this.customerCache[login] || login;
`; html += `
}).join(''); <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 // Bind clicks to items
cuItemsContainer.querySelectorAll('.customer-user-multiselect-item').forEach(item => { cuItemsContainer.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const login = item.dataset.value; const login = item.dataset.value;
const displayName = item.dataset.displayName || this.customerCache[login] || login;
const currentSelected = Array.isArray(this.state.customer_user_id) const currentSelected = Array.isArray(this.state.customer_user_id)
? [...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(',') : []); : (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); const idx = currentSelected.indexOf(login);
if (idx > -1) { if (idx > -1) {
currentSelected.splice(idx, 1); currentSelected.splice(idx, 1);
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
} else { } else {
currentSelected.push(login); currentSelected.push(login);
item.classList.add('active'); this.customerCache[login] = displayName;
item.style.background = 'var(--accent-primary)'; this.saveCustomerCache();
item.style.color = '#fff';
} }
this.state.customer_user_id = currentSelected.join(','); this.state.customer_user_id = currentSelected.join(',');
renderCustomerUserItems(searchResults);
}); });
}); });
}; };
@@ -376,8 +537,9 @@ const Filters = {
e.stopPropagation(); e.stopPropagation();
// Close other popovers // Close other popovers
const statePopover = document.getElementById('state-multiselect-popover'); document.querySelectorAll('.multiselect-popover').forEach(p => {
if (statePopover) statePopover.style.display = 'none'; if (p !== cuPopover) p.style.display = 'none';
});
const isOpen = cuPopover.style.display === 'block'; const isOpen = cuPopover.style.display === 'block';
cuPopover.style.display = isOpen ? 'none' : 'block'; cuPopover.style.display = isOpen ? 'none' : 'block';
@@ -394,19 +556,38 @@ const Filters = {
cuPopover.addEventListener('click', (e) => e.stopPropagation()); cuPopover.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => { let cuSearchDebounce;
cuPopover.style.display = 'none';
});
if (cuSearchInput) { if (cuSearchInput) {
cuSearchInput.addEventListener('input', () => { 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) { if (cuOkBtn) {
cuOkBtn.addEventListener('click', () => { cuOkBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.save(); this.save();
this.updateCustomerUserMultiselectLabel(App.lookups); this.updateCustomerUserMultiselectLabel(App.lookups);
if (cuPopover) cuPopover.style.display = 'none'; if (cuPopover) cuPopover.style.display = 'none';
@@ -416,6 +597,7 @@ const Filters = {
if (cuClearBtn) { if (cuClearBtn) {
cuClearBtn.addEventListener('click', () => { cuClearBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.state.customer_user_id = ''; this.state.customer_user_id = '';
this.save(); this.save();
this.updateCustomerUserMultiselectLabel(App.lookups); 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'); const resetBtn = document.getElementById('filter-reset');
if (resetBtn) { if (resetBtn) {
resetBtn.addEventListener('click', () => { resetBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.reset(); this.reset();
if (isMyTickets) { if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1'; const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.state.user_id = activeAgentId; this.state.user_id = activeAgentId;
this.save(); 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 // Reset highlight states in popovers
const statePopover = document.getElementById('state-multiselect-popover'); document.querySelectorAll('.ms-items-container .ms-item').forEach(item => {
if (statePopover) { item.classList.remove('active');
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => { item.style.background = '';
item.classList.remove('active'); item.style.color = '';
item.style.background = ''; });
item.style.color = '';
});
}
this.updateStateMultiselectLabel(App.lookups);
// Also clear customer user multiselect items and label // Also clear customer user multiselect items and label
const cuPopover = document.getElementById('customer-user-multiselect-popover'); const cuPopover = document.getElementById('customer-user-multiselect-popover');
@@ -465,11 +644,121 @@ const Filters = {
item.style.color = ''; 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); this.updateCustomerUserMultiselectLabel(App.lookups);
if (onFilterChange) onFilterChange(); 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;
}
});
}
}, },
}; };
+2 -1
View File
@@ -212,7 +212,8 @@ const SignaturesView = {
}, },
async deleteSig(id) { async deleteSig(id) {
if (!confirm('Eliminare questa firma?')) return; const ok = await App.confirm('Elimina Firma', 'Sei sicuro di voler eliminare questa firma?');
if (!ok) return;
try { try {
await App.api(`/api/email/signatures/${id}`, { method: 'DELETE' }); await App.api(`/api/email/signatures/${id}`, { method: 'DELETE' });
Toast.success('Firma eliminata'); Toast.success('Firma eliminata');
+12 -6
View File
@@ -135,7 +135,7 @@ const TicketCreateView = {
<label class="form-label">Coda <span class="required">*</span></label> <label class="form-label">Coda <span class="required">*</span></label>
<input type="text" class="form-input" id="create-queue-search" placeholder="Cerca coda..." autocomplete="off" /> <input type="text" class="form-input" id="create-queue-search" placeholder="Cerca coda..." autocomplete="off" />
<input type="hidden" id="create-queue" /> <input type="hidden" id="create-queue" />
<div id="queue-suggestions" class="autocomplete-suggestions" style="display:none;"></div> <div id="queue-suggestions" class="autocomplete-suggestions" style="display:none; width: 450px; max-width: 600px; z-index: 1005;"></div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -567,11 +567,17 @@ const TicketCreateView = {
return; return;
} }
queueSuggestionsDiv.innerHTML = queues.map(q => ` queueSuggestionsDiv.innerHTML = queues.map(q => {
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}"> const parts = q.name.split('::');
<strong>${App.escapeHtml(q.name)}</strong> const lastPart = parts[parts.length - 1];
</div> const parentPath = parts.length > 1 ? parts.slice(0, -1).join(' ') + ' ' : '';
`).join(''); return `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}" style="padding: 6px 12px; font-size: 0.78rem; line-height: 1.25; display: flex; flex-direction: column;">
<span style="font-size: 0.65rem; opacity: 0.6; margin-bottom: 2px;">${App.escapeHtml(parentPath)}</span>
<strong>${App.escapeHtml(lastPart)}</strong>
</div>
`;
}).join('');
queueSuggestionsDiv.style.display = 'block'; queueSuggestionsDiv.style.display = 'block';
// Bind click // Bind click
+80 -2
View File
@@ -41,6 +41,13 @@ const TicketDetailView = {
await App.ensureLookups(); await App.ensureLookups();
const data = await App.api(`/api/tickets/${id}`); const data = await App.api(`/api/tickets/${id}`);
const { ticket, articles, attachments } = data; const { ticket, articles, attachments } = data;
let groupsData = { asMaster: [], asMember: [] };
try {
groupsData = await App.api(`/api/groups/by-ticket/${id}`);
} catch (gErr) {
console.warn('Failed to load group associations for ticket', gErr);
}
const localISO = (dateStr) => { const localISO = (dateStr) => {
if (!dateStr) return ''; if (!dateStr) return '';
const d = new Date(dateStr); const d = new Date(dateStr);
@@ -380,6 +387,38 @@ const TicketDetailView = {
</div> </div>
</div> </div>
` : ''} ` : ''}
<!-- Panel per i Gruppi Ticket -->
<div class="sidebar-panel" id="ticket-groups-panel">
<div class="sidebar-panel-title">Gruppi Ticket</div>
<div id="ticket-groups-list" style="margin-bottom: var(--space-sm);">
${groupsData.asMaster.length === 0 && groupsData.asMember.length === 0 ? `
<div style="font-size:0.78rem; color:var(--text-muted); padding:4px 0;">Nessun gruppo associato</div>
` : ''}
${groupsData.asMaster.map(g => `
<div style="font-size:0.8rem; margin-bottom:4px;">
<span style="color:var(--accent-primary); font-weight:bold;">👑 Master in:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</div>
`).join('')}
${groupsData.asMember.map(g => `
<div style="font-size:0.8rem; margin-bottom:4px;">
<span style="color:var(--text-secondary); font-weight:bold;">🔗 Membro di:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</div>
`).join('')}
</div>
<div style="border-top:1px solid var(--border-subtle); padding-top:var(--space-xs); margin-top:var(--space-xs);">
<label class="quick-edit-label">Associa a Gruppo</label>
<div style="display:flex; gap:4px; margin-top:4px;">
<select class="form-select" id="group-association-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0; flex:1;">
<option value="">-- Seleziona --</option>
</select>
<button class="btn btn-primary btn-sm" id="btn-associate-to-group" style="height:28px; padding:0 8px; font-size:0.75rem;">+ Aggiungi</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
`; `;
@@ -419,7 +458,7 @@ const TicketDetailView = {
this.noteQuill = null; this.noteQuill = null;
} }
this.bindEvents(ticket, articles, container); this.bindEvents(ticket, articles, container, groupsData);
} catch (err) { } catch (err) {
container.innerHTML = ` container.innerHTML = `
@@ -433,7 +472,7 @@ const TicketDetailView = {
} }
}, },
bindEvents(ticket, articles, container) { bindEvents(ticket, articles, container, groupsData) {
// Quick-edit change detection // Quick-edit change detection
const fields = document.querySelectorAll('.quick-edit-select, #qe-customer-user-id, #qe-customer-id'); const fields = document.querySelectorAll('.quick-edit-select, #qe-customer-user-id, #qe-customer-id');
const saveBtn = document.getElementById('qe-save'); const saveBtn = document.getElementById('qe-save');
@@ -931,5 +970,44 @@ const TicketDetailView = {
this.render(this.ticketId); this.render(this.ticketId);
}); });
} }
// Populate Group Association dropdown
App.api('/api/groups').then(allGroups => {
const select = document.getElementById('group-association-select');
if (select) {
allGroups.forEach(g => {
const isMember = groupsData.asMember.some(m => m.id === g.id);
const isMaster = groupsData.asMaster.some(m => m.id === g.id);
if (!isMember && !isMaster) {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = g.nome;
select.appendChild(opt);
}
});
}
}).catch(err => console.warn('Failed to load groups for association dropdown', err));
const btnAssociate = document.getElementById('btn-associate-to-group');
if (btnAssociate) {
btnAssociate.addEventListener('click', async () => {
const select = document.getElementById('group-association-select');
const groupId = select.value;
if (!groupId) return;
try {
btnAssociate.disabled = true;
await App.api(`/api/groups/${groupId}/tickets`, {
method: 'POST',
body: JSON.stringify({ ticket_identifier: this.ticketId })
});
Toast.success('Ticket associato al gruppo!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore associazione: ' + err.message);
btnAssociate.disabled = false;
}
});
}
}, },
}; };
+579
View File
@@ -0,0 +1,579 @@
/**
* ticketGroups.js
* SPA View for managing Ticket Groups (mimics master/slave relationships).
*/
const TicketGroupsView = {
groups: [],
selectedGroupId: null,
selectedGroupData: null,
selectedTicketIds: new Set(), // For bulk actions (complying with no-checkboxes rule)
async render() {
const container = document.getElementById('view-container');
container.innerHTML = `
<div class="ticket-groups-container" style="display: flex; gap: var(--space-lg); min-height: calc(100vh - 120px); padding: var(--space-lg) 0;">
<!-- Left Sidebar: Groups List -->
<div class="groups-sidebar card" style="flex: 0 0 320px; display: flex; flex-direction: column; padding: var(--space-md); max-height: calc(100vh - 120px); overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 700; color: var(--text-primary);">🗂️ Gruppi Ticket</h3>
<button class="btn btn-primary btn-sm" id="btn-create-group" style="padding: 4px 10px; font-size: 0.8rem;">+ Nuovo</button>
</div>
<div id="groups-list-container" style="display: flex; flex-direction: column; gap: var(--space-xs); flex: 1;">
<div class="spinner-container" style="display: flex; justify-content: center; padding: var(--space-lg);">
<div class="spinner" style="width: 24px; height: 24px;"></div>
</div>
</div>
</div>
<!-- Right Content: Group Details -->
<div class="group-details-pane card" id="group-details-pane" style="flex: 1; padding: var(--space-lg); display: flex; flex-direction: column; max-height: calc(100vh - 120px); overflow-y: auto;">
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
<h4>Nessun gruppo selezionato</h4>
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni master/slave.</p>
</div>
</div>
</div>
<!-- Create/Edit Group Modal -->
<div id="group-modal" style="display: none; position: fixed; inset: 0; z-index: 8000; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); align-items: center; justify-content: center;">
<div class="card" style="width: min(480px, 94vw); max-height: 90vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); padding: 0;">
<div style="padding: 16px 20px; border-bottom: 1px solid var(--border-subtle); display: flex; justify-content: space-between; align-items: center;">
<h4 id="group-modal-title" style="margin: 0; font-size: 1.1rem; font-weight: 600;">Nuovo Gruppo</h4>
<button id="group-modal-close" style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);">✕</button>
</div>
<div style="padding: 20px; display: flex; flex-direction: column; gap: var(--space-md); overflow-y: auto;">
<div>
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">Nome Gruppo</label>
<input type="text" id="group-name-input" class="form-input" placeholder="es. Disservizio Mail Server" style="margin-top: 4px;" />
</div>
<div>
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">Descrizione</label>
<textarea id="group-desc-input" class="form-input" placeholder="Breve descrizione o scopo di questo gruppo..." style="margin-top: 4px; min-height: 80px; resize: vertical;"></textarea>
</div>
<div>
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">ID Ticket Master (Opzionale)</label>
<input type="text" id="group-master-input" class="form-input" placeholder="ID o Numero ticket (es. 12345)" style="margin-top: 4px;" />
<span style="font-size: 0.75rem; color: var(--text-muted);">Il ticket master funge da riferimento principale.</span>
</div>
</div>
<div style="padding: 12px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: var(--space-sm);">
<button class="btn btn-ghost btn-sm" id="btn-group-modal-cancel">Annulla</button>
<button class="btn btn-primary btn-sm" id="btn-group-modal-save">Salva</button>
</div>
</div>
</div>
`;
// Bind event listeners
document.getElementById('btn-create-group').addEventListener('click', () => this.openGroupModal());
document.getElementById('group-modal-close').addEventListener('click', () => this.closeGroupModal());
document.getElementById('btn-group-modal-cancel').addEventListener('click', () => this.closeGroupModal());
document.getElementById('btn-group-modal-save').addEventListener('click', () => this.saveGroup());
// Restore state from localStorage if available
const savedGroupId = localStorage.getItem('otrs_selected_group_id');
if (savedGroupId) {
this.selectedGroupId = parseInt(savedGroupId, 10);
}
await this.loadGroups();
if (this.selectedGroupId) {
this.selectGroup(this.selectedGroupId);
}
},
async loadGroups() {
try {
this.groups = await App.api('/api/groups');
this.renderGroupsList();
} catch (err) {
Toast.error('Errore nel caricamento dei gruppi: ' + err.message);
}
},
renderGroupsList() {
const listContainer = document.getElementById('groups-list-container');
if (!listContainer) return;
if (this.groups.length === 0) {
listContainer.innerHTML = `
<div style="text-align: center; padding: var(--space-lg); color: var(--text-muted); font-size: 0.85rem;">
Nessun gruppo presente
</div>
`;
return;
}
listContainer.innerHTML = this.groups.map(group => {
const isActive = group.id === this.selectedGroupId;
return `
<div class="group-item" data-id="${group.id}" style="
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
background: ${isActive ? 'var(--bg-tertiary)' : 'transparent'};
border: 1px solid ${isActive ? 'var(--accent-primary)' : 'transparent'};
">
<div style="font-weight: 600; font-size: 0.9rem; color: var(--text-primary); margin-bottom: 2px;">
${App.escapeHtml(group.nome)}
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin-bottom: 4px;">
${App.escapeHtml(group.descrizione || 'Nessuna descrizione')}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; color: var(--text-muted);">
<span>Master: ${group.master_ticket_id ? '#' + group.master_ticket_id : 'Nessuno'}</span>
<span style="background: var(--bg-secondary); padding: 1px 6px; border-radius: 10px;">${group.member_count} ticket</span>
</div>
</div>
`;
}).join('');
// Bind click events to items
listContainer.querySelectorAll('.group-item').forEach(item => {
item.addEventListener('click', () => {
const id = parseInt(item.dataset.id, 10);
this.selectGroup(id);
});
});
},
async selectGroup(id) {
this.selectedGroupId = id;
localStorage.setItem('otrs_selected_group_id', id);
this.selectedTicketIds.clear();
this.renderGroupsList();
const detailPane = document.getElementById('group-details-pane');
detailPane.innerHTML = `
<div style="display: flex; justify-content: center; align-items: center; flex: 1;">
<div class="spinner"></div>
</div>
`;
try {
this.selectedGroupData = await App.api(`/api/groups/${id}`);
this.renderGroupDetails();
} catch (err) {
Toast.error('Errore nel caricamento del dettaglio gruppo: ' + err.message);
detailPane.innerHTML = `
<div style="color: var(--danger); text-align: center; padding: var(--space-xl);">
Errore nel caricamento dei dati: ${err.message}
</div>
`;
}
},
renderGroupDetails() {
const detailPane = document.getElementById('group-details-pane');
if (!detailPane || !this.selectedGroupData) return;
const { group, masterTicket, memberTickets } = this.selectedGroupData;
let masterTicketHtml = '';
if (masterTicket) {
masterTicketHtml = `
<div class="card" style="border: 1px solid var(--accent-primary); background: var(--bg-secondary); padding: var(--space-md);">
<div style="display: flex; gap: var(--space-md); align-items: flex-start;">
<div style="flex: 1;">
<div style="font-weight: 700; font-size: 1rem; margin-bottom: var(--space-xs); display: flex; align-items: center; gap: 6px;">
<span class="copy-ticket-btn" data-tn="${masterTicket.tn}" style="cursor: pointer; font-size: 0.85rem; display: inline-flex; align-items: center;" title="Copia numero ticket">📋</span>
<a href="#/tickets/${masterTicket.id}" style="color: var(--text-primary); text-decoration: none; border-bottom: 1px dashed var(--text-muted);">
#${masterTicket.tn}${App.escapeHtml(masterTicket.title)}
</a>
</div>
<div style="display: flex; gap: var(--space-sm); align-items: center; flex-wrap: wrap; margin-top: var(--space-xs);">
<span class="badge badge-state" data-state-type="${(masterTicket.state_type || '').toLowerCase()}">${masterTicket.state_name}</span>
<span class="badge" style="background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.75rem;">Coda: ${masterTicket.queue_name}</span>
<span style="font-size: 0.78rem; color: var(--text-muted);">Proprietario: ${masterTicket.owner_first ? `${masterTicket.owner_first} ${masterTicket.owner_last}` : masterTicket.owner_login}</span>
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-xs); flex-shrink: 0;">
<span style="font-size: 0.7rem; font-weight: 700; color: var(--accent-primary); text-transform: uppercase; border: 1px solid var(--accent-primary); padding: 2px 6px; border-radius: var(--radius-sm); white-space: nowrap;">👑 Master Ticket</span>
<button class="btn btn-ghost btn-sm" id="btn-unlink-master" style="color: var(--danger); font-size: 0.8rem; height: 32px; padding: 4px 8px;" title="Rimuovi ruolo master">Scollega Master</button>
</div>
</div>
</div>
`;
} else {
masterTicketHtml = `
<div style="border: 1px dashed var(--border-subtle); padding: var(--space-md); border-radius: var(--radius-md); text-align: center; color: var(--text-muted); font-size: 0.85rem;">
Nessun master ticket assegnato. Imposta un ticket come master usando il tasto Modifica Gruppo o associa un ID/Numero valido.
</div>
`;
}
let membersListHtml = '';
if (memberTickets.length === 0) {
membersListHtml = `
<div style="text-align: center; padding: var(--space-xl); border: 1px dashed var(--border-subtle); border-radius: var(--radius-md); color: var(--text-muted); font-size: 0.88rem;">
Nessun ticket slave/membro associato a questo gruppo.
</div>
`;
} else {
membersListHtml = `
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
${memberTickets.map(t => {
const isSelected = this.selectedTicketIds.has(t.id);
return `
<div class="member-ticket-card" data-ticket-id="${t.id}" style="
border: 1px solid ${isSelected ? 'var(--accent-primary)' : 'var(--border-subtle)'};
background: ${isSelected ? 'var(--bg-tertiary)' : 'var(--bg-card)'};
padding: var(--space-md);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: space-between;
">
<div style="flex: 1;" onclick="event.stopPropagation();">
<div style="font-weight: 600; font-size: 0.9rem; margin-bottom: 2px; display: flex; align-items: center; gap: 6px;">
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.85rem; display: inline-flex; align-items: center;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
<a href="#/tickets/${t.id}" style="color: var(--text-primary); text-decoration: none;">
#${t.tn}${App.escapeHtml(t.title)}
</a>
</div>
<div style="display: flex; gap: var(--space-sm); align-items: center; flex-wrap: wrap; margin-top: 4px; font-size: 0.75rem;">
<span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}" style="font-size: 0.7rem; padding: 2px 6px;">${t.state_name}</span>
<span style="color: var(--text-muted);">Coda: ${t.queue_name}</span>
<span style="color: var(--text-muted);">Proprietario: ${t.owner_first ? `${t.owner_first} ${t.owner_last}` : t.owner_login}</span>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-remove-member" data-ticket-id="${t.id}" style="color: var(--danger); padding: 4px 8px; font-size: 0.8rem;" onclick="event.stopPropagation();">Rimuovi</button>
</div>
`;
}).join('')}
</div>
`;
}
detailPane.innerHTML = `
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-subtle); padding-bottom: var(--space-md); margin-bottom: var(--space-md);">
<div>
<h2 style="margin: 0; font-size: 1.3rem; font-weight: 700; color: var(--text-primary);">${App.escapeHtml(group.nome)}</h2>
<p style="margin: var(--space-xs) 0 0; font-size: 0.88rem; color: var(--text-secondary);">${App.escapeHtml(group.descrizione || 'Nessuna descrizione per questo gruppo.')}</p>
</div>
<div style="display: flex; gap: var(--space-sm);">
<button class="btn btn-ghost btn-sm" id="btn-edit-group" style="padding: 6px 12px; font-size: 0.82rem;">Modifica</button>
<button class="btn btn-ghost btn-sm" id="btn-delete-group" style="padding: 6px 12px; font-size: 0.82rem; color: var(--danger);">Elimina Gruppo</button>
</div>
</div>
<!-- Master Ticket Section -->
<div style="margin-bottom: var(--space-lg);">
<h4 style="margin: 0 0 var(--space-xs); font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Ticket Master</h4>
${masterTicketHtml}
</div>
<!-- Quick Add Section -->
<div class="card" style="background: var(--bg-tertiary); padding: var(--space-md); margin-bottom: var(--space-lg);">
<h4 style="margin: 0 0 var(--space-xs); font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Associa Nuovo Ticket Slave</h4>
<div style="display: flex; gap: var(--space-sm);">
<input type="text" id="quick-add-ticket-input" class="form-input" placeholder="Inserisci ID ticket o Numero ticket (es. 202310... o 12345)" style="margin: 0; font-size: 0.88rem; flex: 1;" />
<button class="btn btn-primary btn-sm" id="btn-quick-add-ticket">Associa Ticket</button>
</div>
</div>
<!-- Slave / Members Section -->
<div style="flex: 1; display: flex; flex-direction: column;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-xs);">
<h4 style="margin: 0; font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Ticket Membri (${memberTickets.length})</h4>
${this.selectedTicketIds.size > 0 ? `
<div style="display:flex; gap:8px;">
<button class="btn btn-ghost btn-sm" id="btn-bulk-copy" style="font-size: 0.8rem; padding: 4px 8px;">Copia selezionati (${this.selectedTicketIds.size})</button>
<button class="btn btn-ghost btn-sm" id="btn-bulk-remove" style="color: var(--danger); font-size: 0.8rem; padding: 4px 8px;">Rimuovi selezionati (${this.selectedTicketIds.size})</button>
</div>
` : `
<span style="font-size: 0.75rem; color: var(--text-muted);">Clicca sulle schede per selezionare in blocco</span>
`}
</div>
<div style="flex: 1; overflow-y: auto;">
${membersListHtml}
</div>
</div>
`;
// Bind actions
document.getElementById('btn-edit-group').addEventListener('click', () => this.openGroupModal(group));
document.getElementById('btn-delete-group').addEventListener('click', () => this.deleteGroup(group.id));
if (masterTicket) {
document.getElementById('btn-unlink-master').addEventListener('click', () => this.unlinkMaster());
}
document.getElementById('btn-quick-add-ticket').addEventListener('click', () => this.addTicketToGroup());
document.getElementById('quick-add-ticket-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.addTicketToGroup();
});
// Individual removal buttons
detailPane.querySelectorAll('.btn-remove-member').forEach(btn => {
btn.addEventListener('click', () => {
const ticketId = parseInt(btn.dataset.ticketId, 10);
this.removeTicketFromGroup(ticketId);
});
});
// Multi-select cards logic (No-checkboxes rule)
detailPane.querySelectorAll('.member-ticket-card').forEach(card => {
card.addEventListener('click', () => {
const ticketId = parseInt(card.dataset.ticketId, 10);
if (this.selectedTicketIds.has(ticketId)) {
this.selectedTicketIds.delete(ticketId);
} else {
this.selectedTicketIds.add(ticketId);
}
this.renderGroupDetails();
});
});
// Bulk actions
const btnBulkRemove = document.getElementById('btn-bulk-remove');
if (btnBulkRemove) {
btnBulkRemove.addEventListener('click', () => this.bulkRemoveTickets());
}
const btnBulkCopy = document.getElementById('btn-bulk-copy');
if (btnBulkCopy) {
btnBulkCopy.addEventListener('click', (e) => {
e.stopPropagation();
const selectedTns = [];
this.selectedTicketIds.forEach(id => {
const ticket = memberTickets.find(t => t.id === id);
if (ticket && ticket.tn) {
selectedTns.push(ticket.tn);
}
});
if (selectedTns.length > 0) {
const textToCopy = selectedTns.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
Toast.success(`${selectedTns.length} numeri ticket copiati!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
}
// Individual copy buttons
detailPane.querySelectorAll('.copy-ticket-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tn = btn.dataset.tn;
if (tn) {
navigator.clipboard.writeText(tn).then(() => {
Toast.success(`Numero ticket ${tn} copiato!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
},
openGroupModal(group = null) {
const modal = document.getElementById('group-modal');
const title = document.getElementById('group-modal-title');
const nameInput = document.getElementById('group-name-input');
const descInput = document.getElementById('group-desc-input');
const masterInput = document.getElementById('group-master-input');
if (group) {
title.textContent = 'Modifica Gruppo';
nameInput.value = group.nome || '';
descInput.value = group.descrizione || '';
masterInput.value = group.master_ticket_id || '';
modal.dataset.editId = group.id;
} else {
title.textContent = 'Nuovo Gruppo';
nameInput.value = '';
descInput.value = '';
masterInput.value = '';
delete modal.dataset.editId;
}
modal.style.display = 'flex';
nameInput.focus();
},
closeGroupModal() {
document.getElementById('group-modal').style.display = 'none';
},
async saveGroup() {
const modal = document.getElementById('group-modal');
const editId = modal.dataset.editId;
const nome = document.getElementById('group-name-input').value.trim();
const descrizione = document.getElementById('group-desc-input').value.trim();
const masterVal = document.getElementById('group-master-input').value.trim();
if (!nome) {
Toast.error('Il nome del gruppo è richiesto.');
return;
}
let masterTicketId = null;
if (masterVal) {
// Validate master ticket input (ID or Number)
try {
const res = await App.api(`/api/tickets?search=${masterVal}&per_page=1`);
if (res && res.tickets && res.tickets.length > 0) {
masterTicketId = res.tickets[0].id;
} else {
// Check if it's a direct database ID by querying search directly
if (/^\d+$/.test(masterVal)) {
masterTicketId = parseInt(masterVal, 10);
} else {
Toast.error('Ticket master non trovato.');
return;
}
}
} catch (err) {
if (/^\d+$/.test(masterVal)) {
masterTicketId = parseInt(masterVal, 10);
} else {
Toast.error('Errore nella verifica del ticket master: ' + err.message);
return;
}
}
}
const payload = { nome, descrizione, master_ticket_id: masterTicketId };
try {
if (editId) {
await App.api(`/api/groups/${editId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
Toast.success('Gruppo aggiornato con successo');
} else {
const newGroup = await App.api('/api/groups', {
method: 'POST',
body: JSON.stringify(payload)
});
this.selectedGroupId = newGroup.id;
Toast.success('Gruppo creato con successo');
}
this.closeGroupModal();
await this.loadGroups();
if (this.selectedGroupId) {
await this.selectGroup(this.selectedGroupId);
}
} catch (err) {
Toast.error('Errore nel salvataggio: ' + err.message);
}
},
async deleteGroup(id) {
const ok = await App.confirm('Elimina Gruppo', 'Sei sicuro di voler eliminare questo gruppo? Le associazioni dei ticket verranno rimosse, ma i ticket non saranno modificati.');
if (!ok) {
return;
}
try {
await App.api(`/api/groups/${id}`, { method: 'DELETE' });
Toast.success('Gruppo eliminato con successo');
this.selectedGroupId = null;
this.selectedGroupData = null;
localStorage.removeItem('otrs_selected_group_id');
await this.loadGroups();
document.getElementById('group-details-pane').innerHTML = `
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
<h4>Nessun gruppo selezionato</h4>
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni.</p>
</div>
`;
} catch (err) {
Toast.error('Errore nella cancellazione: ' + err.message);
}
},
async unlinkMaster() {
if (!this.selectedGroupData) return;
const { group } = this.selectedGroupData;
try {
await App.api(`/api/groups/${group.id}`, {
method: 'PUT',
body: JSON.stringify({
nome: group.nome,
descrizione: group.descrizione,
master_ticket_id: null
})
});
Toast.success('Ticket master scollegato con successo');
await this.selectGroup(group.id);
} catch (err) {
Toast.error('Errore nello scollegamento: ' + err.message);
}
},
async addTicketToGroup() {
const input = document.getElementById('quick-add-ticket-input');
const val = input.value.trim();
if (!val) return;
try {
await App.api(`/api/groups/${this.selectedGroupId}/tickets`, {
method: 'POST',
body: JSON.stringify({ ticket_identifier: val })
});
Toast.success('Ticket associato correttamente');
input.value = '';
await this.selectGroup(this.selectedGroupId);
} catch (err) {
Toast.error('Errore nell\'associazione: ' + err.message);
}
},
async removeTicketFromGroup(ticketId) {
try {
await App.api(`/api/groups/${this.selectedGroupId}/tickets/${ticketId}`, {
method: 'DELETE'
});
Toast.success('Ticket rimosso dal gruppo');
await this.selectGroup(this.selectedGroupId);
} catch (err) {
Toast.error('Errore nella rimozione: ' + err.message);
}
},
async bulkRemoveTickets() {
if (this.selectedTicketIds.size === 0) return;
const ok = await App.confirm('Rimuovi Ticket', `Vuoi rimuovere i ${this.selectedTicketIds.size} ticket selezionati da questo gruppo?`);
if (!ok) return;
let successCount = 0;
let failCount = 0;
for (const ticketId of this.selectedTicketIds) {
try {
await App.api(`/api/groups/${this.selectedGroupId}/tickets/${ticketId}`, {
method: 'DELETE'
});
successCount++;
} catch (err) {
failCount++;
}
}
if (successCount > 0) {
Toast.success(`${successCount} ticket rimossi con successo.`);
}
if (failCount > 0) {
Toast.error(`Impossibile rimuovere ${failCount} ticket.`);
}
this.selectedTicketIds.clear();
await this.selectGroup(this.selectedGroupId);
}
};
+44 -1
View File
@@ -77,6 +77,7 @@ const TicketListView = {
<div style="display:flex; align-items:center; gap:var(--space-sm);"> <div style="display:flex; align-items:center; gap:var(--space-sm);">
<span class="batch-count" id="batch-count">0 selezionati</span> <span class="batch-count" id="batch-count">0 selezionati</span>
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button> <button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
</div> </div>
<div class="filter-group"> <div class="filter-group">
<span class="filter-label">Stato</span> <span class="filter-label">Stato</span>
@@ -139,6 +140,7 @@ const TicketListView = {
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;"> <tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;">
<td> <td>
<span class="ticket-tn" style="display:inline-flex; align-items:center;"> <span class="ticket-tn" style="display:inline-flex; align-items:center;">
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a> <a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
${data.otrsBaseUrl ? ` ${data.otrsBaseUrl ? `
<a href="${data.otrsBaseUrl}index.pl?Action=AgentTicketZoom;TicketID=${t.id}" target="_blank" title="Apri in OTRS" onclick="event.stopPropagation()" style="display:inline-flex; align-items:center; text-decoration:none;"> <a href="${data.otrsBaseUrl}index.pl?Action=AgentTicketZoom;TicketID=${t.id}" target="_blank" title="Apri in OTRS" onclick="event.stopPropagation()" style="display:inline-flex; align-items:center; text-decoration:none;">
@@ -302,6 +304,47 @@ const TicketListView = {
batchMerge.addEventListener('click', () => this.mergeBatch()); batchMerge.addEventListener('click', () => this.mergeBatch());
} }
// Batch Copy Ticket Numbers
const batchCopyTns = document.getElementById('batch-copy-tns');
if (batchCopyTns) {
batchCopyTns.addEventListener('click', (e) => {
e.stopPropagation();
const selectedTns = [];
this.selectedIds.forEach(id => {
const row = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${id}"]`);
if (row) {
const tnLink = row.querySelector('.ticket-tn-link');
if (tnLink) {
selectedTns.push(tnLink.textContent.trim());
}
}
});
if (selectedTns.length > 0) {
const textToCopy = selectedTns.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
Toast.success(`${selectedTns.length} numeri ticket copiati!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
}
// Individual copy buttons
document.querySelectorAll('.copy-ticket-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tn = btn.dataset.tn;
if (tn) {
navigator.clipboard.writeText(tn).then(() => {
Toast.success(`Numero ticket ${tn} copiato!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
// Batch select all visible // Batch select all visible
const batchSelectAll = document.getElementById('batch-select-all'); const batchSelectAll = document.getElementById('batch-select-all');
if (batchSelectAll) { if (batchSelectAll) {
@@ -531,7 +574,7 @@ const TicketListView = {
} }
}); });
const confirmed = confirm(`Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}? Questa azione sposterà tutti gli articoli e tempi consultivati.`); const confirmed = await App.confirm('Unione Ticket', `Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}? Questa azione sposterà tutti gli articoli e tempi consultivati.`);
if (!confirmed) return; if (!confirmed) return;
try { try {
+241
View File
@@ -0,0 +1,241 @@
const express = require('express');
const router = express.Router();
const pool = require('../db');
const { db } = require('../activityDb');
// Helper to fetch details of a list of tickets from OTRS DB
async function fetchTicketsDetails(ticketIds) {
if (!ticketIds || ticketIds.length === 0) return [];
try {
const placeholders = ticketIds.map((_, i) => `$${i + 1}`).join(', ');
const query = `
SELECT
t.id, t.tn, t.title,
t.queue_id, q.name AS queue_name,
t.ticket_state_id, ts.name AS state_name, tst.name AS state_type,
t.ticket_priority_id, tp.name AS priority_name,
t.user_id, u.first_name AS owner_first, u.last_name AS owner_last, u.login AS owner_login
FROM ticket t
JOIN queue q ON t.queue_id = q.id
JOIN ticket_state ts ON t.ticket_state_id = ts.id
JOIN ticket_state_type tst ON ts.type_id = tst.id
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
LEFT JOIN users u ON t.user_id = u.id
WHERE t.id IN (${placeholders})
`;
const res = await pool.query(query, ticketIds);
return res.rows;
} catch (err) {
console.error('Error fetching ticket details from OTRS DB:', err);
return [];
}
}
// GET /api/groups - List all groups with member counts
router.get('/', (req, res) => {
try {
const groups = db.prepare(`
SELECT g.*,
(SELECT COUNT(*) FROM ticket_group_members WHERE group_id = g.id) AS member_count
FROM ticket_groups g
ORDER BY g.nome ASC
`).all();
res.json(groups);
} catch (err) {
res.status(500).json({ error: 'Errore nel recupero dei gruppi', message: err.message });
}
});
// GET /api/groups/by-ticket/:ticket_id - Get groups associated to a specific ticket
router.get('/by-ticket/:ticket_id', (req, res) => {
try {
const { ticket_id } = req.params;
const ticketIdNum = parseInt(ticket_id, 10);
if (isNaN(ticketIdNum)) {
return res.status(400).json({ error: 'ID ticket non valido' });
}
const asMaster = db.prepare('SELECT id, nome, descrizione FROM ticket_groups WHERE master_ticket_id = ?').all(ticketIdNum);
const asMember = db.prepare(`
SELECT g.id, g.nome, g.descrizione
FROM ticket_groups g
JOIN ticket_group_members m ON g.id = m.group_id
WHERE m.ticket_id = ?
`).all(ticketIdNum);
res.json({ asMaster, asMember });
} catch (err) {
res.status(500).json({ error: 'Errore nel recupero dei gruppi del ticket', message: err.message });
}
});
// POST /api/groups - Create a new group
router.post('/', (req, res) => {
try {
const { nome, descrizione, master_ticket_id } = req.body;
if (!nome) {
return res.status(400).json({ error: 'Il nome del gruppo è obbligatorio' });
}
const masterId = master_ticket_id ? parseInt(master_ticket_id, 10) : null;
const info = db.prepare(`
INSERT INTO ticket_groups (nome, descrizione, master_ticket_id)
VALUES (?, ?, ?)
`).run(nome, descrizione || '', masterId);
res.json({ id: info.lastInsertRowid, nome, descrizione, master_ticket_id: masterId });
} catch (err) {
res.status(500).json({ error: 'Errore nella creazione del gruppo', message: err.message });
}
});
// GET /api/groups/:id - Detail of a single group
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const group = db.prepare('SELECT * FROM ticket_groups WHERE id = ?').get(id);
if (!group) {
return res.status(404).json({ error: 'Gruppo non trovato' });
}
// Get members list
const memberRows = db.prepare('SELECT ticket_id FROM ticket_group_members WHERE group_id = ?').all(id);
const memberIds = memberRows.map(r => r.ticket_id);
// Fetch details of master and member tickets from OTRS
let masterTicket = null;
if (group.master_ticket_id) {
const details = await fetchTicketsDetails([group.master_ticket_id]);
if (details.length > 0) {
masterTicket = details[0];
}
}
let memberTickets = [];
if (memberIds.length > 0) {
memberTickets = await fetchTicketsDetails(memberIds);
}
res.json({
group,
masterTicket,
memberTickets
});
} catch (err) {
res.status(500).json({ error: 'Errore nel recupero dei dettagli del gruppo', message: err.message });
}
});
// PUT /api/groups/:id - Update group info
router.put('/:id', (req, res) => {
try {
const { id } = req.params;
const { nome, descrizione, master_ticket_id } = req.body;
if (!nome) {
return res.status(400).json({ error: 'Il nome del gruppo è obbligatorio' });
}
const masterId = master_ticket_id ? parseInt(master_ticket_id, 10) : null;
const info = db.prepare(`
UPDATE ticket_groups
SET nome = ?, descrizione = ?, master_ticket_id = ?
WHERE id = ?
`).run(nome, descrizione || '', masterId, id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Gruppo non trovato o nessuna modifica' });
}
res.json({ id: parseInt(id, 10), nome, descrizione, master_ticket_id: masterId });
} catch (err) {
res.status(500).json({ error: 'Errore nell\'aggiornamento del gruppo', message: err.message });
}
});
// DELETE /api/groups/:id - Delete group
router.delete('/:id', (req, res) => {
try {
const { id } = req.params;
const info = db.prepare('DELETE FROM ticket_groups WHERE id = ?').run(id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Gruppo non trovato' });
}
res.json({ success: true, message: 'Gruppo eliminato con successo' });
} catch (err) {
res.status(500).json({ error: 'Errore nella cancellazione del gruppo', message: err.message });
}
});
// POST /api/groups/:id/tickets - Add ticket(s) to group
router.post('/:id/tickets', async (req, res) => {
try {
const { id } = req.params;
const { ticket_identifier } = req.body; // Can be ticket ID or ticket number (tn)
if (!ticket_identifier) {
return res.status(400).json({ error: 'Identificativo ticket obbligatorio' });
}
const cleanIdentifier = String(ticket_identifier).trim();
let queryTicketSql;
let queryParams;
if (/^\d+$/.test(cleanIdentifier)) {
// It's a number - check if it matches id or tn
queryTicketSql = 'SELECT id, tn, title FROM ticket WHERE id = $1 OR tn = $2';
queryParams = [parseInt(cleanIdentifier, 10), cleanIdentifier];
} else {
// Check tn
queryTicketSql = 'SELECT id, tn, title FROM ticket WHERE tn = $1';
queryParams = [cleanIdentifier];
}
const otrsRes = await pool.query(queryTicketSql, queryParams);
if (otrsRes.rows.length === 0) {
return res.status(404).json({ error: `Ticket con identificativo '${cleanIdentifier}' non trovato` });
}
const ticket = otrsRes.rows[0];
// Check if group exists
const group = db.prepare('SELECT id FROM ticket_groups WHERE id = ?').get(id);
if (!group) {
return res.status(404).json({ error: 'Gruppo non trovato' });
}
// Insert to membership
try {
db.prepare(`
INSERT INTO ticket_group_members (group_id, ticket_id)
VALUES (?, ?)
`).run(id, ticket.id);
} catch (dbErr) {
if (dbErr.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
return res.status(409).json({ error: 'Il ticket appartiene già a questo gruppo' });
}
throw dbErr;
}
res.json({ success: true, ticket });
} catch (err) {
res.status(500).json({ error: 'Errore nell\'associazione del ticket', message: err.message });
}
});
// DELETE /api/groups/:id/tickets/:ticket_id - Remove ticket from group
router.delete('/:id/tickets/:ticket_id', (req, res) => {
try {
const { id, ticket_id } = req.params;
const info = db.prepare('DELETE FROM ticket_group_members WHERE group_id = ? AND ticket_id = ?').run(id, ticket_id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Associazione non trovata' });
}
res.json({ success: true, message: 'Ticket rimosso dal gruppo' });
} catch (err) {
res.status(500).json({ error: 'Errore nella rimozione del ticket', message: err.message });
}
});
module.exports = router;
+72
View File
@@ -0,0 +1,72 @@
const express = require('express');
const router = express.Router();
const { db } = require('../activityDb');
// GET /api/presets - Get all presets for the active agent and page mode
router.get('/', (req, res) => {
try {
const agentId = parseInt(req.headers['x-agent-id'] || '1', 10);
const { page_mode } = req.query; // 'general' or 'my'
if (!page_mode) {
return res.status(400).json({ error: 'Il parametro page_mode è obbligatorio' });
}
const presets = db.prepare(`
SELECT * FROM filter_presets
WHERE agent_id = ? AND page_mode = ?
ORDER BY name ASC
`).all(agentId, page_mode);
res.json(presets);
} catch (err) {
res.status(500).json({ error: 'Errore nel caricamento dei preset', message: err.message });
}
});
// POST /api/presets - Save a new filter preset
router.post('/', (req, res) => {
try {
const agentId = parseInt(req.headers['x-agent-id'] || '1', 10);
const { name, page_mode, filters } = req.body;
if (!name || !page_mode || !filters) {
return res.status(400).json({ error: 'I campi name, page_mode e filters sono obbligatori' });
}
const filtersJson = typeof filters === 'string' ? filters : JSON.stringify(filters);
const info = db.prepare(`
INSERT INTO filter_presets (agent_id, name, page_mode, filters_json)
VALUES (?, ?, ?, ?)
`).run(agentId, name, page_mode, filtersJson);
res.json({
id: info.lastInsertRowid,
agent_id: agentId,
name,
page_mode,
filters_json: filtersJson
});
} catch (err) {
res.status(500).json({ error: 'Errore nel salvataggio del preset', message: err.message });
}
});
// DELETE /api/presets/:id - Delete a preset
router.delete('/:id', (req, res) => {
try {
const { id } = req.params;
const info = db.prepare('DELETE FROM filter_presets WHERE id = ?').run(id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Preset non trovato' });
}
res.json({ success: true, message: 'Preset eliminato con successo' });
} catch (err) {
res.status(500).json({ error: 'Errore nella rimozione del preset', message: err.message });
}
});
module.exports = router;
+45 -6
View File
@@ -123,8 +123,21 @@ router.get('/', async (req, res) => {
} }
if (queue_id) { if (queue_id) {
conditions.push(`t.queue_id = $${paramIdx++}`); let queueIds = [];
params.push(parseInt(queue_id)); if (Array.isArray(queue_id)) {
queueIds = queue_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof queue_id === 'string') {
queueIds = queue_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(queue_id);
if (!isNaN(parsed)) queueIds.push(parsed);
}
if (queueIds.length > 0) {
const placeholders = queueIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.queue_id IN (${placeholders})`);
params.push(...queueIds);
}
} }
if (state_id) { if (state_id) {
let stateIds = []; let stateIds = [];
@@ -144,12 +157,38 @@ router.get('/', async (req, res) => {
} }
} }
if (priority_id) { if (priority_id) {
conditions.push(`t.ticket_priority_id = $${paramIdx++}`); let priorityIds = [];
params.push(parseInt(priority_id)); if (Array.isArray(priority_id)) {
priorityIds = priority_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof priority_id === 'string') {
priorityIds = priority_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(priority_id);
if (!isNaN(parsed)) priorityIds.push(parsed);
}
if (priorityIds.length > 0) {
const placeholders = priorityIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.ticket_priority_id IN (${placeholders})`);
params.push(...priorityIds);
}
} }
if (user_id) { if (user_id) {
conditions.push(`t.user_id = $${paramIdx++}`); let userIds = [];
params.push(parseInt(user_id)); if (Array.isArray(user_id)) {
userIds = user_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof user_id === 'string') {
userIds = user_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(user_id);
if (!isNaN(parsed)) userIds.push(parsed);
}
if (userIds.length > 0) {
const placeholders = userIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.user_id IN (${placeholders})`);
params.push(...userIds);
}
} }
if (type_id) { if (type_id) {
conditions.push(`t.type_id = $${paramIdx++}`); conditions.push(`t.type_id = $${paramIdx++}`);
+4
View File
@@ -8,6 +8,8 @@ const lookupsRouter = require('./routes/lookups');
const dashboardRouter = require('./routes/dashboard'); const dashboardRouter = require('./routes/dashboard');
const activityRouter = require('./routes/activity'); const activityRouter = require('./routes/activity');
const emailRouter = require('./routes/email'); const emailRouter = require('./routes/email');
const groupsRouter = require('./routes/groups');
const presetsRouter = require('./routes/presets');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -25,6 +27,8 @@ app.use('/api', lookupsRouter);
app.use('/api/dashboard', dashboardRouter); app.use('/api/dashboard', dashboardRouter);
app.use('/api/attivita', activityRouter); app.use('/api/attivita', activityRouter);
app.use('/api/email', emailRouter); app.use('/api/email', emailRouter);
app.use('/api/groups', groupsRouter);
app.use('/api/presets', presetsRouter);
// SPA fallback — serve index.html for all non-API routes // SPA fallback — serve index.html for all non-API routes
app.get('*', (req, res) => { app.get('*', (req, res) => {