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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user