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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Non usare le notifiche native dei browser.
|
||||
@@ -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 {
|
||||
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
|
||||
} catch (e) {
|
||||
|
||||
+22
-9
@@ -60,6 +60,17 @@
|
||||
<span class="nav-badge" id="my-ticket-count"></span>
|
||||
</a>
|
||||
</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>
|
||||
<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">
|
||||
@@ -80,6 +91,16 @@
|
||||
<span>Apertura Massiva</span>
|
||||
</a>
|
||||
</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>
|
||||
<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">
|
||||
@@ -89,15 +110,6 @@
|
||||
<span>Storico Attività Turbo</span>
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
@@ -186,6 +198,7 @@
|
||||
<script src="/js/views/activityLog.js"></script>
|
||||
<script src="/js/views/emailCompose.js"></script>
|
||||
<script src="/js/views/signatures.js"></script>
|
||||
<script src="/js/views/ticketGroups.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -142,6 +142,11 @@ const App = {
|
||||
titleEl.textContent = 'Apertura Massiva Ticket';
|
||||
TicketBulkView.render();
|
||||
|
||||
} else if (hash === '#/tickets/groups') {
|
||||
document.getElementById('nav-ticket-groups')?.classList.add('active');
|
||||
titleEl.textContent = 'Gruppi ticket';
|
||||
TicketGroupsView.render();
|
||||
|
||||
} else if (hash === '#/activity') {
|
||||
document.getElementById('nav-activity')?.classList.add('active');
|
||||
titleEl.textContent = 'Storico Attività';
|
||||
@@ -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
|
||||
|
||||
+444
-155
@@ -26,6 +26,16 @@ const Filters = {
|
||||
}
|
||||
},
|
||||
|
||||
customerCache: {},
|
||||
presets: [],
|
||||
selectedPresetId: null,
|
||||
|
||||
saveCustomerCache() {
|
||||
try {
|
||||
localStorage.setItem('otrs_turbo_customer_cache', JSON.stringify(this.customerCache));
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
// Dynamic state getter based on active mode
|
||||
get state() {
|
||||
return this.allStates[this.currentMode];
|
||||
@@ -50,6 +60,11 @@ const Filters = {
|
||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
this.allStates.my.user_id = activeAgentId;
|
||||
}
|
||||
|
||||
const savedCache = localStorage.getItem('otrs_turbo_customer_cache');
|
||||
if (savedCache) {
|
||||
this.customerCache = JSON.parse(savedCache);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
@@ -79,37 +94,31 @@ const Filters = {
|
||||
return params;
|
||||
},
|
||||
|
||||
/** Helper to compute trigger button label text */
|
||||
getStateMultiselectLabel(lookups) {
|
||||
const selectedList = Array.isArray(this.state.state_id)
|
||||
? this.state.state_id
|
||||
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
|
||||
/** Helper to compute trigger button label text for multiselect dropdowns */
|
||||
getMultiselectLabel(selectedVal, itemsList, labelField = 'name', idField = 'id') {
|
||||
const selectedList = Array.isArray(selectedVal)
|
||||
? selectedVal
|
||||
: (typeof selectedVal === 'string' && selectedVal ? selectedVal.split(',') : []);
|
||||
|
||||
if (selectedList.length === 0) {
|
||||
return 'Tutti';
|
||||
}
|
||||
const selectedNames = (lookups.states || [])
|
||||
.filter(s => selectedList.includes(String(s.id)))
|
||||
.map(s => s.name);
|
||||
|
||||
if (selectedNames.length === (lookups.states || []).length) {
|
||||
const selectedNames = (itemsList || [])
|
||||
.filter(item => selectedList.includes(String(item[idField])))
|
||||
.map(item => typeof labelField === 'function' ? labelField(item) : item[labelField]);
|
||||
|
||||
if (selectedNames.length === 0) {
|
||||
return 'Tutti';
|
||||
} else if (selectedNames.length <= 2) {
|
||||
}
|
||||
|
||||
if (selectedNames.length <= 2) {
|
||||
return selectedNames.join(', ');
|
||||
} else {
|
||||
return `${selectedNames.length} selezionati`;
|
||||
}
|
||||
},
|
||||
|
||||
/** Update label DOM element dynamically */
|
||||
updateStateMultiselectLabel(lookups) {
|
||||
const labelEl = document.getElementById('state-multiselect-label');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = this.getStateMultiselectLabel(lookups);
|
||||
}
|
||||
},
|
||||
|
||||
/** Helper to compute trigger button label text for customer users */
|
||||
getCustomerUserMultiselectLabel(lookups) {
|
||||
const selectedList = Array.isArray(this.state.customer_user_id)
|
||||
? this.state.customer_user_id
|
||||
@@ -118,13 +127,22 @@ const Filters = {
|
||||
if (selectedList.length === 0) {
|
||||
return 'Tutti';
|
||||
}
|
||||
const selectedNames = (lookups.customerUsers || [])
|
||||
.filter(u => selectedList.includes(String(u.login)))
|
||||
.map(u => `${u.last_name} ${u.first_name}`);
|
||||
|
||||
if (selectedNames.length === (lookups.customerUsers || []).length) {
|
||||
return 'Tutti';
|
||||
} else if (selectedNames.length <= 2) {
|
||||
const selectedNames = selectedList.map(login => {
|
||||
if (this.customerCache && this.customerCache[login]) {
|
||||
return this.customerCache[login];
|
||||
}
|
||||
const found = (lookups.customerUsers || []).find(u => String(u.login) === String(login));
|
||||
if (found) {
|
||||
const fullName = `${found.last_name} ${found.first_name}`.trim();
|
||||
this.customerCache[login] = fullName;
|
||||
this.saveCustomerCache();
|
||||
return fullName;
|
||||
}
|
||||
return login;
|
||||
});
|
||||
|
||||
if (selectedNames.length <= 2) {
|
||||
return selectedNames.join(', ');
|
||||
} else {
|
||||
return `${selectedNames.length} selezionati`;
|
||||
@@ -145,72 +163,147 @@ const Filters = {
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
renderBar(lookups) {
|
||||
const makeOptions = (items, valueKey, labelKey, selectedVal) => {
|
||||
return items.map(item => {
|
||||
const val = item[valueKey];
|
||||
const label = typeof labelKey === 'function' ? labelKey(item) : item[labelKey];
|
||||
const sel = String(val) === String(selectedVal) ? 'selected' : '';
|
||||
return `<option value="${val}" ${sel}>${label}</option>`;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
const currentLabel = this.getStateMultiselectLabel(lookups);
|
||||
const stateLabel = this.getMultiselectLabel(this.state.state_id, lookups.states);
|
||||
const queueLabel = this.getMultiselectLabel(this.state.queue_id, lookups.queues);
|
||||
const priorityLabel = this.getMultiselectLabel(this.state.priority_id, lookups.priorities);
|
||||
const ownerLabel = this.getMultiselectLabel(this.state.user_id, lookups.users, u => `${u.first_name} ${u.last_name}`);
|
||||
const customerUserLabel = this.getCustomerUserMultiselectLabel(lookups);
|
||||
|
||||
const presetOptions = (this.presets || []).map(p => {
|
||||
const sel = String(p.id) === String(this.selectedPresetId) ? 'selected' : '';
|
||||
return `<option value="${p.id}" ${sel}>${App.escapeHtml(p.name)}</option>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Preset</span>
|
||||
<div style="display:flex; gap:4px; align-items:center;">
|
||||
<select class="form-select" id="filter-presets-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0; min-width:130px; border-color:var(--border-subtle);">
|
||||
<option value="">-- Nessuno --</option>
|
||||
${presetOptions}
|
||||
</select>
|
||||
<button class="btn btn-ghost btn-xs" id="btn-save-preset" style="height:28px; padding:0 8px; font-size:0.75rem;" title="Salva filtri attuali come preset">Salva</button>
|
||||
<button class="btn btn-ghost btn-xs" id="btn-delete-preset" style="height:28px; padding:0 8px; font-size:0.75rem; color:var(--danger);" title="Elimina il preset selezionato">Elimina</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stato -->
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Stato</span>
|
||||
<div class="multiselect-dropdown" id="state-multiselect-dropdown" style="min-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||
<span class="multiselect-label" id="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:130px;">${App.escapeHtml(currentLabel)}</span>
|
||||
<div class="multiselect-dropdown" id="state-multiselect-dropdown" style="min-width: 110px; max-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||
<span class="multiselect-label" id="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(stateLabel)}</span>
|
||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||
</div>
|
||||
<div class="multiselect-popover" id="state-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 220px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||
<div style="display:flex; flex-direction:column; gap:4px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||
<input type="text" class="form-input ms-search" placeholder="Cerca..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||
${(lookups.states || []).map(s => {
|
||||
const selectedList = Array.isArray(this.state.state_id)
|
||||
? this.state.state_id
|
||||
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
|
||||
const selectedList = String(this.state.state_id || '').split(',').filter(Boolean);
|
||||
const isSelected = selectedList.includes(String(s.id));
|
||||
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||
return `
|
||||
<div class="state-multiselect-item ${isSelected ? 'active' : ''}" data-value="${s.id}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${s.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||
${App.escapeHtml(s.name)}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||
<button type="button" class="btn btn-ghost btn-xs" id="state-multiselect-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||
<button type="button" class="btn btn-primary btn-xs" id="state-multiselect-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
|
||||
<!-- Coda -->
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Coda</span>
|
||||
<select class="filter-select" data-filter="queue_id" id="filter-queue">
|
||||
<option value="">Tutte</option>
|
||||
${makeOptions(lookups.queues || [], 'id', 'name', this.state.queue_id)}
|
||||
</select>
|
||||
<div class="multiselect-dropdown" id="queue-multiselect-dropdown" style="min-width: 120px; max-width: 150px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||
<span class="multiselect-label" id="queue-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(queueLabel)}</span>
|
||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||
</div>
|
||||
<div class="multiselect-popover" id="queue-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||
<input type="text" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:300px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||
${(lookups.queues || []).map(q => {
|
||||
const selectedList = String(this.state.queue_id || '').split(',').filter(Boolean);
|
||||
const isSelected = selectedList.includes(String(q.id));
|
||||
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||
return `
|
||||
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${q.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||
${App.escapeHtml(q.name)}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
|
||||
<!-- Priorità -->
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Priorità</span>
|
||||
<select class="filter-select" data-filter="priority_id" id="filter-priority">
|
||||
<option value="">Tutte</option>
|
||||
${makeOptions(lookups.priorities || [], 'id', 'name', this.state.priority_id)}
|
||||
</select>
|
||||
<div class="multiselect-dropdown" id="priority-multiselect-dropdown" style="min-width: 90px; max-width: 120px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||
<span class="multiselect-label" id="priority-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(priorityLabel)}</span>
|
||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||
</div>
|
||||
<div class="multiselect-popover" id="priority-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 200px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||
<input type="text" class="form-input ms-search" placeholder="Cerca priorità..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||
${(lookups.priorities || []).map(p => {
|
||||
const selectedList = String(this.state.priority_id || '').split(',').filter(Boolean);
|
||||
const isSelected = selectedList.includes(String(p.id));
|
||||
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||
return `
|
||||
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${p.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||
${App.escapeHtml(p.name)}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
|
||||
<!-- Owner -->
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Owner</span>
|
||||
<select class="filter-select" data-filter="user_id" id="filter-owner">
|
||||
<option value="">Tutti</option>
|
||||
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
||||
</select>
|
||||
<div class="multiselect-dropdown" id="owner-multiselect-dropdown" style="min-width: 120px; max-width: 150px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||
<span class="multiselect-label" id="owner-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(ownerLabel)}</span>
|
||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||
</div>
|
||||
<div class="multiselect-popover" id="owner-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 240px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||
<input type="text" class="form-input ms-search" placeholder="Cerca owner..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||
${(lookups.users || []).map(u => {
|
||||
const selectedList = String(this.state.user_id || '').split(',').filter(Boolean);
|
||||
const isSelected = selectedList.includes(String(u.id));
|
||||
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||
return `
|
||||
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${u.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||
${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Utente Cliente</span>
|
||||
<div class="multiselect-dropdown" id="customer-user-multiselect-dropdown" style="min-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||
<span class="multiselect-label" id="customer-user-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:180px;">${App.escapeHtml(customerUserLabel)}</span>
|
||||
<div class="multiselect-dropdown" id="customer-user-multiselect-dropdown" style="min-width: 130px; max-width: 160px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||
<span class="multiselect-label" id="customer-user-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(customerUserLabel)}</span>
|
||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||
</div>
|
||||
<div class="multiselect-popover" id="customer-user-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||
@@ -226,11 +319,11 @@ const Filters = {
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Da Data/Ora</span>
|
||||
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 170px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">A Data/Ora</span>
|
||||
<input type="datetime-local" class="filter-select" data-filter="date_to" id="filter-date-to" value="${this.state.date_to || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
<input type="datetime-local" class="filter-select" data-filter="date_to" id="filter-date-to" value="${this.state.date_to || ''}" style="width: 170px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
</div>
|
||||
<div class="filters-actions">
|
||||
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
|
||||
@@ -239,7 +332,6 @@ const Filters = {
|
||||
`;
|
||||
},
|
||||
|
||||
/** Bind change events to filter selects */
|
||||
bindEvents(onFilterChange) {
|
||||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||||
const selects = document.querySelectorAll('.filter-select[data-filter]');
|
||||
@@ -247,73 +339,117 @@ const Filters = {
|
||||
sel.disabled = false;
|
||||
|
||||
sel.addEventListener('change', (e) => {
|
||||
this.selectedPresetId = null;
|
||||
this.state[e.target.dataset.filter] = e.target.value;
|
||||
this.save();
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
});
|
||||
|
||||
// Multiselect dropdown toggle event
|
||||
const dropdown = document.getElementById('state-multiselect-dropdown');
|
||||
const popover = document.getElementById('state-multiselect-popover');
|
||||
const okBtn = document.getElementById('state-multiselect-ok');
|
||||
const clearBtn = document.getElementById('state-multiselect-clear');
|
||||
// Helper to bind standard multiselect popover events
|
||||
const bindStandardMultiselect = (filterKey, dropdownId, popoverId, labelElId, itemsList, labelField, idField = 'id') => {
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
const popover = document.getElementById(popoverId);
|
||||
if (!dropdown || !popover) return;
|
||||
|
||||
if (dropdown && popover) {
|
||||
const searchInput = popover.querySelector('.ms-search');
|
||||
const itemsContainer = popover.querySelector('.ms-items-container');
|
||||
const okBtn = popover.querySelector('.ms-ok');
|
||||
const clearBtn = popover.querySelector('.ms-clear');
|
||||
|
||||
// Toggle popover visibility
|
||||
dropdown.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Close all other popovers
|
||||
document.querySelectorAll('.multiselect-popover').forEach(p => {
|
||||
if (p !== popover) p.style.display = 'none';
|
||||
});
|
||||
|
||||
const isOpen = popover.style.display === 'block';
|
||||
popover.style.display = isOpen ? 'none' : 'block';
|
||||
if (!isOpen && searchInput) {
|
||||
searchInput.value = '';
|
||||
searchInput.dispatchEvent(new Event('input'));
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
popover.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
popover.style.display = 'none';
|
||||
});
|
||||
// Search matching items
|
||||
if (searchInput && itemsContainer) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
|
||||
const text = item.textContent.toLowerCase();
|
||||
item.style.display = text.includes(q) ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Bind item clicks
|
||||
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isActive = item.classList.toggle('active');
|
||||
if (isActive) {
|
||||
item.style.background = 'var(--accent-primary)';
|
||||
item.style.color = '#fff';
|
||||
} else {
|
||||
// Bind selection clicks
|
||||
if (itemsContainer) {
|
||||
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isActive = item.classList.toggle('active');
|
||||
if (isActive) {
|
||||
item.style.background = 'var(--accent-primary)';
|
||||
item.style.color = '#fff';
|
||||
} else {
|
||||
item.style.background = '';
|
||||
item.style.color = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply selection (OK click)
|
||||
if (okBtn) {
|
||||
okBtn.addEventListener('click', () => {
|
||||
const activeItems = itemsContainer.querySelectorAll('.ms-item.active');
|
||||
const ids = Array.from(activeItems).map(item => item.dataset.value);
|
||||
this.selectedPresetId = null;
|
||||
this.state[filterKey] = ids.join(',');
|
||||
this.save();
|
||||
|
||||
const labelEl = document.getElementById(labelElId);
|
||||
if (labelEl) {
|
||||
labelEl.textContent = this.getMultiselectLabel(this.state[filterKey], itemsList, labelField, idField);
|
||||
}
|
||||
popover.style.display = 'none';
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
}
|
||||
|
||||
// Reset selection
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
item.style.background = '';
|
||||
item.style.color = '';
|
||||
});
|
||||
this.selectedPresetId = null;
|
||||
this.state[filterKey] = '';
|
||||
this.save();
|
||||
|
||||
const labelEl = document.getElementById(labelElId);
|
||||
if (labelEl) {
|
||||
labelEl.textContent = 'Tutti';
|
||||
}
|
||||
popover.style.display = 'none';
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (okBtn) {
|
||||
okBtn.addEventListener('click', () => {
|
||||
const activeItems = popover.querySelectorAll('.state-multiselect-item.active');
|
||||
const ids = Array.from(activeItems).map(item => item.dataset.value);
|
||||
this.state.state_id = ids.join(',');
|
||||
this.save();
|
||||
this.updateStateMultiselectLabel(App.lookups);
|
||||
popover.style.display = 'none';
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
item.style.background = '';
|
||||
item.style.color = '';
|
||||
});
|
||||
this.state.state_id = '';
|
||||
this.save();
|
||||
this.updateStateMultiselectLabel(App.lookups);
|
||||
popover.style.display = 'none';
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
}
|
||||
// Bind standard multiselects
|
||||
bindStandardMultiselect('state_id', 'state-multiselect-dropdown', 'state-multiselect-popover', 'state-multiselect-label', App.lookups.states, 'name');
|
||||
bindStandardMultiselect('queue_id', 'queue-multiselect-dropdown', 'queue-multiselect-popover', 'queue-multiselect-label', App.lookups.queues, 'name');
|
||||
bindStandardMultiselect('priority_id', 'priority-multiselect-dropdown', 'priority-multiselect-popover', 'priority-multiselect-label', App.lookups.priorities, 'name');
|
||||
bindStandardMultiselect('user_id', 'owner-multiselect-dropdown', 'owner-multiselect-popover', 'owner-multiselect-label', App.lookups.users, u => `${u.first_name} ${u.last_name}`);
|
||||
|
||||
// Customer User Multiselect Popover binding
|
||||
const cuDropdown = document.getElementById('customer-user-multiselect-dropdown');
|
||||
@@ -323,33 +459,61 @@ const Filters = {
|
||||
const cuOkBtn = document.getElementById('customer-user-multiselect-ok');
|
||||
const cuClearBtn = document.getElementById('customer-user-multiselect-clear');
|
||||
|
||||
const renderCustomerUserItems = () => {
|
||||
let activeSearchController = null;
|
||||
|
||||
const renderCustomerUserItems = (searchResults = null) => {
|
||||
if (!cuItemsContainer) return;
|
||||
const q = (cuSearchInput ? cuSearchInput.value : '').toLowerCase().trim();
|
||||
|
||||
const selectedList = Array.isArray(this.state.customer_user_id)
|
||||
? this.state.customer_user_id
|
||||
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||
|
||||
const filtered = (App.lookups.customerUsers || []).filter(u => {
|
||||
const fullName = `${u.last_name} ${u.first_name} (${u.login})`.toLowerCase();
|
||||
return fullName.includes(q);
|
||||
});
|
||||
const uniqueSelectedLogins = Array.from(new Set(selectedList)).filter(Boolean);
|
||||
|
||||
cuItemsContainer.innerHTML = filtered.map(u => {
|
||||
const isSelected = selectedList.includes(String(u.login));
|
||||
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||
return `
|
||||
<div class="customer-user-multiselect-item ${isSelected ? 'active' : ''}" data-value="${u.login}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||
${App.escapeHtml(u.last_name)} ${App.escapeHtml(u.first_name)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(u.login)})</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
let html = '';
|
||||
|
||||
// 1. Show selected items at the top
|
||||
if (uniqueSelectedLogins.length > 0) {
|
||||
html += `<div style="font-size:0.72rem; font-weight:700; color:var(--accent-primary); text-transform:uppercase; padding: 2px var(--space-sm); border-bottom:1px solid var(--border-subtle); margin-bottom:4px;">Selezionati</div>`;
|
||||
uniqueSelectedLogins.forEach(login => {
|
||||
const displayName = this.customerCache[login] || login;
|
||||
html += `
|
||||
<div class="customer-user-multiselect-item active" data-value="${login}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; background: var(--accent-primary); color: #fff; margin-bottom: 2px;">
|
||||
${App.escapeHtml(displayName)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(login)})</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Show search results below
|
||||
let listToRender = searchResults || App.lookups.customerUsers || [];
|
||||
listToRender = listToRender.filter(u => !uniqueSelectedLogins.includes(String(u.login)));
|
||||
|
||||
if (listToRender.length > 0) {
|
||||
if (uniqueSelectedLogins.length > 0) {
|
||||
html += `<div style="font-size:0.72rem; font-weight:700; color:var(--text-secondary); text-transform:uppercase; padding: 4px var(--space-sm) 2px; border-bottom:1px solid var(--border-subtle); margin-top:6px; margin-bottom:4px;">Risultati</div>`;
|
||||
}
|
||||
listToRender.forEach(u => {
|
||||
const displayName = `${u.last_name} ${u.first_name}`.trim() || u.login;
|
||||
html += `
|
||||
<div class="customer-user-multiselect-item" data-value="${u.login}" data-display-name="${displayName}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; margin-bottom: 2px;">
|
||||
${App.escapeHtml(u.last_name)} ${App.escapeHtml(u.first_name)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(u.login)})</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else if (uniqueSelectedLogins.length === 0) {
|
||||
html = `<div style="text-align:center; padding:var(--space-md); color:var(--text-muted); font-size:0.8rem;">Cerca digitando sopra...</div>`;
|
||||
}
|
||||
|
||||
cuItemsContainer.innerHTML = html;
|
||||
|
||||
// Bind clicks to items
|
||||
cuItemsContainer.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const login = item.dataset.value;
|
||||
const displayName = item.dataset.displayName || this.customerCache[login] || login;
|
||||
|
||||
const currentSelected = Array.isArray(this.state.customer_user_id)
|
||||
? [...this.state.customer_user_id]
|
||||
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||
@@ -357,16 +521,13 @@ const Filters = {
|
||||
const idx = currentSelected.indexOf(login);
|
||||
if (idx > -1) {
|
||||
currentSelected.splice(idx, 1);
|
||||
item.classList.remove('active');
|
||||
item.style.background = '';
|
||||
item.style.color = '';
|
||||
} else {
|
||||
currentSelected.push(login);
|
||||
item.classList.add('active');
|
||||
item.style.background = 'var(--accent-primary)';
|
||||
item.style.color = '#fff';
|
||||
this.customerCache[login] = displayName;
|
||||
this.saveCustomerCache();
|
||||
}
|
||||
this.state.customer_user_id = currentSelected.join(',');
|
||||
renderCustomerUserItems(searchResults);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -376,8 +537,9 @@ const Filters = {
|
||||
e.stopPropagation();
|
||||
|
||||
// Close other popovers
|
||||
const statePopover = document.getElementById('state-multiselect-popover');
|
||||
if (statePopover) statePopover.style.display = 'none';
|
||||
document.querySelectorAll('.multiselect-popover').forEach(p => {
|
||||
if (p !== cuPopover) p.style.display = 'none';
|
||||
});
|
||||
|
||||
const isOpen = cuPopover.style.display === 'block';
|
||||
cuPopover.style.display = isOpen ? 'none' : 'block';
|
||||
@@ -394,19 +556,38 @@ const Filters = {
|
||||
|
||||
cuPopover.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
cuPopover.style.display = 'none';
|
||||
});
|
||||
|
||||
let cuSearchDebounce;
|
||||
if (cuSearchInput) {
|
||||
cuSearchInput.addEventListener('input', () => {
|
||||
renderCustomerUserItems();
|
||||
clearTimeout(cuSearchDebounce);
|
||||
const q = cuSearchInput.value.trim();
|
||||
|
||||
cuSearchDebounce = setTimeout(async () => {
|
||||
if (activeSearchController) activeSearchController.abort();
|
||||
activeSearchController = new AbortController();
|
||||
|
||||
try {
|
||||
cuItemsContainer.innerHTML = '<div style="display:flex; justify-content:center; padding:12px;"><div class="spinner" style="width:18px;height:18px;border-width:2px;"></div></div>';
|
||||
|
||||
const users = await fetch(`/api/customer-users/search?q=${encodeURIComponent(q)}`, {
|
||||
signal: activeSearchController.signal
|
||||
}).then(r => r.json());
|
||||
|
||||
renderCustomerUserItems(users);
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Search failed:', err);
|
||||
renderCustomerUserItems([]);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (cuOkBtn) {
|
||||
cuOkBtn.addEventListener('click', () => {
|
||||
this.selectedPresetId = null;
|
||||
this.save();
|
||||
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||
if (cuPopover) cuPopover.style.display = 'none';
|
||||
@@ -416,6 +597,7 @@ const Filters = {
|
||||
|
||||
if (cuClearBtn) {
|
||||
cuClearBtn.addEventListener('click', () => {
|
||||
this.selectedPresetId = null;
|
||||
this.state.customer_user_id = '';
|
||||
this.save();
|
||||
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||
@@ -426,33 +608,30 @@ const Filters = {
|
||||
});
|
||||
}
|
||||
|
||||
// Close all popovers on backdrop click
|
||||
document.addEventListener('click', () => {
|
||||
document.querySelectorAll('.multiselect-popover').forEach(p => {
|
||||
p.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
const resetBtn = document.getElementById('filter-reset');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', () => {
|
||||
this.selectedPresetId = null;
|
||||
this.reset();
|
||||
if (isMyTickets) {
|
||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
this.state.user_id = activeAgentId;
|
||||
this.save();
|
||||
}
|
||||
selects.forEach(s => {
|
||||
if (isMyTickets && s.dataset.filter === 'user_id') {
|
||||
s.value = localStorage.getItem('activeAgentId') || '1';
|
||||
} else {
|
||||
s.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Also clear state multiselect items and label
|
||||
const statePopover = document.getElementById('state-multiselect-popover');
|
||||
if (statePopover) {
|
||||
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
item.style.background = '';
|
||||
item.style.color = '';
|
||||
});
|
||||
}
|
||||
this.updateStateMultiselectLabel(App.lookups);
|
||||
// Reset highlight states in popovers
|
||||
document.querySelectorAll('.ms-items-container .ms-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
item.style.background = '';
|
||||
item.style.color = '';
|
||||
});
|
||||
|
||||
// Also clear customer user multiselect items and label
|
||||
const cuPopover = document.getElementById('customer-user-multiselect-popover');
|
||||
@@ -465,11 +644,121 @@ const Filters = {
|
||||
item.style.color = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Recompute labels
|
||||
document.getElementById('state-multiselect-label').textContent = 'Tutti';
|
||||
document.getElementById('queue-multiselect-label').textContent = 'Tutti';
|
||||
document.getElementById('priority-multiselect-label').textContent = 'Tutti';
|
||||
document.getElementById('owner-multiselect-label').textContent = isMyTickets ? this.getMultiselectLabel(this.state.user_id, App.lookups.users, u => `${u.first_name} ${u.last_name}`) : 'Tutti';
|
||||
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
}
|
||||
|
||||
// Load and bind presets
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
const agentId = localStorage.getItem('activeAgentId') || '1';
|
||||
this.presets = await App.api(`/api/presets?page_mode=${this.currentMode}`);
|
||||
const select = document.getElementById('filter-presets-select');
|
||||
if (select) {
|
||||
select.innerHTML = '<option value="">-- Nessuno --</option>' + this.presets.map(p => {
|
||||
const sel = String(p.id) === String(this.selectedPresetId) ? 'selected' : '';
|
||||
return `<option value="${p.id}" ${sel}>${App.escapeHtml(p.name)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load presets:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadPresets();
|
||||
|
||||
const selectPresets = document.getElementById('filter-presets-select');
|
||||
if (selectPresets) {
|
||||
selectPresets.addEventListener('change', (e) => {
|
||||
const presetId = e.target.value;
|
||||
if (!presetId) {
|
||||
this.selectedPresetId = null;
|
||||
if (onFilterChange) onFilterChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = this.presets.find(p => String(p.id) === String(presetId));
|
||||
if (preset) {
|
||||
try {
|
||||
const filters = JSON.parse(preset.filters_json);
|
||||
this.state = Object.assign({
|
||||
queue_id: '',
|
||||
state_id: '',
|
||||
priority_id: '',
|
||||
user_id: '',
|
||||
customer_user_id: '',
|
||||
date_from: '',
|
||||
date_to: ''
|
||||
}, filters);
|
||||
this.selectedPresetId = preset.id;
|
||||
this.save();
|
||||
if (onFilterChange) onFilterChange();
|
||||
} catch (err) {
|
||||
Toast.error('Errore nel caricamento del preset: ' + err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const btnSavePreset = document.getElementById('btn-save-preset');
|
||||
if (btnSavePreset) {
|
||||
btnSavePreset.addEventListener('click', async () => {
|
||||
const name = await App.prompt('Nuovo Preset', 'Inserisci il nome per questo preset di filtri:');
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
try {
|
||||
btnSavePreset.disabled = true;
|
||||
const newPreset = await App.api('/api/presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
page_mode: this.currentMode,
|
||||
filters: this.state
|
||||
})
|
||||
});
|
||||
Toast.success('Preset salvato con successo!');
|
||||
this.selectedPresetId = newPreset.id;
|
||||
if (onFilterChange) onFilterChange();
|
||||
} catch (err) {
|
||||
Toast.error('Errore nel salvataggio del preset: ' + err.message);
|
||||
btnSavePreset.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const btnDeletePreset = document.getElementById('btn-delete-preset');
|
||||
if (btnDeletePreset) {
|
||||
btnDeletePreset.addEventListener('click', async () => {
|
||||
const select = document.getElementById('filter-presets-select');
|
||||
const presetId = select ? select.value : '';
|
||||
if (!presetId) {
|
||||
Toast.warning('Seleziona prima un preset da eliminare');
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await App.confirm('Elimina Preset', 'Sei sicuro di voler eliminare questo preset?');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
btnDeletePreset.disabled = true;
|
||||
await App.api(`/api/presets/${presetId}`, { method: 'DELETE' });
|
||||
Toast.success('Preset eliminato con successo');
|
||||
this.selectedPresetId = null;
|
||||
if (onFilterChange) onFilterChange();
|
||||
} catch (err) {
|
||||
Toast.error('Errore nell\'eliminazione del preset: ' + err.message);
|
||||
btnDeletePreset.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -212,7 +212,8 @@ const SignaturesView = {
|
||||
},
|
||||
|
||||
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 {
|
||||
await App.api(`/api/email/signatures/${id}`, { method: 'DELETE' });
|
||||
Toast.success('Firma eliminata');
|
||||
|
||||
@@ -135,7 +135,7 @@ const TicketCreateView = {
|
||||
<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="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 class="form-group">
|
||||
@@ -567,11 +567,17 @@ const TicketCreateView = {
|
||||
return;
|
||||
}
|
||||
|
||||
queueSuggestionsDiv.innerHTML = queues.map(q => `
|
||||
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}">
|
||||
<strong>${App.escapeHtml(q.name)}</strong>
|
||||
</div>
|
||||
`).join('');
|
||||
queueSuggestionsDiv.innerHTML = queues.map(q => {
|
||||
const parts = q.name.split('::');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const parentPath = parts.length > 1 ? parts.slice(0, -1).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';
|
||||
|
||||
// Bind click
|
||||
|
||||
@@ -41,6 +41,13 @@ const TicketDetailView = {
|
||||
await App.ensureLookups();
|
||||
const data = await App.api(`/api/tickets/${id}`);
|
||||
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) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
@@ -380,6 +387,38 @@ const TicketDetailView = {
|
||||
</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>
|
||||
`;
|
||||
@@ -419,7 +458,7 @@ const TicketDetailView = {
|
||||
this.noteQuill = null;
|
||||
}
|
||||
|
||||
this.bindEvents(ticket, articles, container);
|
||||
this.bindEvents(ticket, articles, container, groupsData);
|
||||
|
||||
} catch (err) {
|
||||
container.innerHTML = `
|
||||
@@ -433,7 +472,7 @@ const TicketDetailView = {
|
||||
}
|
||||
},
|
||||
|
||||
bindEvents(ticket, articles, container) {
|
||||
bindEvents(ticket, articles, container, groupsData) {
|
||||
// Quick-edit change detection
|
||||
const fields = document.querySelectorAll('.quick-edit-select, #qe-customer-user-id, #qe-customer-id');
|
||||
const saveBtn = document.getElementById('qe-save');
|
||||
@@ -931,5 +970,44 @@ const TicketDetailView = {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -77,6 +77,7 @@ const TicketListView = {
|
||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<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-copy-tns" style="margin-left: 8px;">Copia numeri</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<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;">
|
||||
<td>
|
||||
<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>
|
||||
${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;">
|
||||
@@ -302,6 +304,47 @@ const TicketListView = {
|
||||
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
|
||||
const batchSelectAll = document.getElementById('batch-select-all');
|
||||
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;
|
||||
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -123,8 +123,21 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
|
||||
if (queue_id) {
|
||||
conditions.push(`t.queue_id = $${paramIdx++}`);
|
||||
params.push(parseInt(queue_id));
|
||||
let queueIds = [];
|
||||
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) {
|
||||
let stateIds = [];
|
||||
@@ -144,12 +157,38 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
}
|
||||
if (priority_id) {
|
||||
conditions.push(`t.ticket_priority_id = $${paramIdx++}`);
|
||||
params.push(parseInt(priority_id));
|
||||
let priorityIds = [];
|
||||
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) {
|
||||
conditions.push(`t.user_id = $${paramIdx++}`);
|
||||
params.push(parseInt(user_id));
|
||||
let userIds = [];
|
||||
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) {
|
||||
conditions.push(`t.type_id = $${paramIdx++}`);
|
||||
|
||||
@@ -8,6 +8,8 @@ const lookupsRouter = require('./routes/lookups');
|
||||
const dashboardRouter = require('./routes/dashboard');
|
||||
const activityRouter = require('./routes/activity');
|
||||
const emailRouter = require('./routes/email');
|
||||
const groupsRouter = require('./routes/groups');
|
||||
const presetsRouter = require('./routes/presets');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -25,6 +27,8 @@ app.use('/api', lookupsRouter);
|
||||
app.use('/api/dashboard', dashboardRouter);
|
||||
app.use('/api/attivita', activityRouter);
|
||||
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
|
||||
app.get('*', (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user