Files
otrs-turbo/public/js/views/ticketGroups.js
T

580 lines
26 KiB
JavaScript

/**
* 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);
}
};