501 lines
23 KiB
JavaScript
501 lines
23 KiB
JavaScript
/**
|
|
* Ticket Detail View
|
|
* Shows full ticket info with quick-edit dropdowns, article timeline, and add-note form.
|
|
*/
|
|
const TicketDetailView = {
|
|
ticketId: null,
|
|
htmlMode: false,
|
|
originalValues: {},
|
|
|
|
async render(id) {
|
|
this.ticketId = id;
|
|
this.htmlMode = localStorage.getItem('otrs_turbo_html_mode') === 'true';
|
|
const container = document.getElementById('view-container');
|
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
|
|
|
try {
|
|
await App.ensureLookups();
|
|
const data = await App.api(`/api/tickets/${id}`);
|
|
const { ticket, articles, attachments } = data;
|
|
const localISO = (dateStr) => {
|
|
if (!dateStr) return '';
|
|
const d = new Date(dateStr);
|
|
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
|
};
|
|
|
|
const totalTime = articles.reduce((sum, a) => {
|
|
const val = parseFloat(a.time_unit);
|
|
return sum + (isNaN(val) ? 0 : val);
|
|
}, 0);
|
|
|
|
this.originalValues = {
|
|
ticket_state_id: ticket.ticket_state_id,
|
|
ticket_priority_id: ticket.ticket_priority_id,
|
|
queue_id: ticket.queue_id,
|
|
user_id: ticket.user_id,
|
|
type_id: ticket.type_id,
|
|
};
|
|
|
|
container.innerHTML = `
|
|
<a class="back-link" onclick="history.back()">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
|
Torna alla lista
|
|
</a>
|
|
|
|
<div class="ticket-detail">
|
|
<div class="ticket-detail-main">
|
|
<!-- Header -->
|
|
<div class="card">
|
|
<div class="ticket-header">
|
|
<div class="ticket-header-info">
|
|
<div class="ticket-number">#${ticket.tn}</div>
|
|
<h2 class="ticket-detail-title">${App.escapeHtml(ticket.title || '(senza titolo)')}</h2>
|
|
<div class="ticket-meta-badges">
|
|
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
|
|
<span class="badge badge-priority" data-priority="${App.priorityIndex(ticket.priority_name)}">${ticket.priority_name}</span>
|
|
<span class="badge badge-queue">${ticket.queue_name}</span>
|
|
${ticket.lock_name === 'lock' ? '<span class="badge" style="background:var(--warning-bg);color:var(--warning);">🔒 Bloccato</span>' : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Edit -->
|
|
<div class="card">
|
|
<div class="card-title">Modifica Rapida</div>
|
|
<div class="quick-edit">
|
|
<div class="quick-edit-field">
|
|
<label class="quick-edit-label">Stato</label>
|
|
<select class="quick-edit-select" id="qe-state" data-field="ticket_state_id">
|
|
${(App.lookups.states || []).map(s =>
|
|
`<option value="${s.id}" ${s.id === ticket.ticket_state_id ? 'selected' : ''}>${s.name}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="quick-edit-field">
|
|
<label class="quick-edit-label">Priorità</label>
|
|
<select class="quick-edit-select" id="qe-priority" data-field="ticket_priority_id">
|
|
${(App.lookups.priorities || []).map(p =>
|
|
`<option value="${p.id}" ${p.id === ticket.ticket_priority_id ? 'selected' : ''}>${p.name}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="quick-edit-field">
|
|
<label class="quick-edit-label">Coda</label>
|
|
<select class="quick-edit-select" id="qe-queue" data-field="queue_id">
|
|
${(App.lookups.queues || []).map(q =>
|
|
`<option value="${q.id}" ${q.id === ticket.queue_id ? 'selected' : ''}>${q.name}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="quick-edit-field">
|
|
<label class="quick-edit-label">Owner</label>
|
|
<select class="quick-edit-select" id="qe-owner" data-field="user_id">
|
|
${(App.lookups.users || []).map(u =>
|
|
`<option value="${u.id}" ${u.id === ticket.user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
${(App.lookups.types || []).length > 0 ? `
|
|
<div class="quick-edit-field">
|
|
<label class="quick-edit-label">Tipo</label>
|
|
<select class="quick-edit-select" id="qe-type" data-field="type_id">
|
|
<option value="">—</option>
|
|
${App.lookups.types.map(t =>
|
|
`<option value="${t.id}" ${t.id === ticket.type_id ? 'selected' : ''}>${t.name}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
<div style="margin-top:var(--space-md);display:flex;gap:var(--space-sm);justify-content:flex-end;">
|
|
<button class="btn btn-ghost btn-sm" id="qe-reset">Reset</button>
|
|
<button class="btn btn-primary btn-sm" id="qe-save" disabled>Salva Modifiche</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Note Form -->
|
|
<div class="add-note-form" style="margin-bottom: var(--space-lg);">
|
|
<div class="card-title">Aggiungi Nota</div>
|
|
<div style="display:flex; gap:var(--space-md); margin-bottom:var(--space-md);">
|
|
<input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto (opzionale)" style="flex:1; margin-bottom:0;" />
|
|
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0;" />
|
|
</div>
|
|
<textarea class="note-textarea" id="note-body" placeholder="Scrivi una nota interna..."></textarea>
|
|
<div style="display:flex;gap:var(--space-sm);justify-content:flex-end;">
|
|
<button class="btn btn-primary btn-sm" id="note-send">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
|
Invia Nota
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Articles Timeline -->
|
|
<div>
|
|
<div class="article-header-wrapper">
|
|
<div class="card-title" style="margin-bottom:0;">Articoli & Note (${articles.length})</div>
|
|
<label class="html-toggle-container">
|
|
<input type="checkbox" class="html-toggle-input" id="html-toggle" ${this.htmlMode ? 'checked' : ''} />
|
|
<span>Visualizza HTML</span>
|
|
</label>
|
|
</div>
|
|
<div class="articles-timeline">
|
|
${articles.length > 0 ? articles.map(a => {
|
|
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('</') || a.a_body.includes('/>');
|
|
const displayBody = this.htmlMode && hasHtml
|
|
? `<iframe srcdoc="${a.a_body.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
|
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
|
|
|
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
|
|
|
|
return `
|
|
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
|
<div class="article-header">
|
|
<div class="article-sender">
|
|
<span class="article-sender-badge ${(a.sender_type || 'system').toLowerCase()}">${a.sender_type || 'System'}</span>
|
|
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
|
|
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
|
|
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
|
|
</div>
|
|
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
|
${a.time_unit ? `<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ ${parseFloat(a.time_unit)} min</span>` : ''}
|
|
|
|
<div class="retrodate-container" data-article-id="${a.article_id}">
|
|
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
|
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
|
|
<div class="retrodate-editor" style="display:none;">
|
|
<input type="datetime-local" class="retrodate-input input-article-date" value="${localISO(a.create_time)}" />
|
|
<div class="retrodate-actions">
|
|
<button class="retrodate-btn-action save btn-save-article-date" title="Salva">✓</button>
|
|
<button class="retrodate-btn-action cancel btn-cancel-article-date" title="Annulla">✗</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
|
|
${displayBody}
|
|
|
|
${articleAttachments.length > 0 ? `
|
|
<div class="article-attachments">
|
|
${articleAttachments.map(att => `
|
|
<a href="/api/tickets/attachments/${att.id}" class="attachment-badge" target="_blank" download="${att.filename}">
|
|
<span>📎</span>
|
|
<strong>${App.escapeHtml(att.filename)}</strong>
|
|
<span class="attachment-size">(${Math.round(att.content_size / 1024)} KB)</span>
|
|
</a>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}).join('') : `
|
|
<div class="empty-state" style="padding:var(--space-lg);">
|
|
<div class="empty-state-icon">💬</div>
|
|
<div class="empty-state-text">Nessun articolo</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar -->
|
|
<div class="ticket-sidebar">
|
|
<div class="sidebar-panel">
|
|
<div class="sidebar-panel-title">Dettagli</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Numero</span>
|
|
<span class="meta-value" style="font-family:monospace;">${ticket.tn}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Creato</span>
|
|
<span class="meta-value">
|
|
<div class="retrodate-container" id="ticket-retrodate-container">
|
|
<span id="ticket-date-text">${App.formatDateTime(ticket.create_time)}</span>
|
|
<button class="retrodate-btn-edit" id="btn-edit-ticket-date" title="Retrodata Creazione Ticket">✏️</button>
|
|
<div class="retrodate-editor" id="ticket-date-editor" style="display:none;">
|
|
<input type="datetime-local" class="retrodate-input" id="input-ticket-date" value="${localISO(ticket.create_time)}" />
|
|
<div class="retrodate-actions">
|
|
<button class="retrodate-btn-action save" id="btn-save-ticket-date" title="Salva">✓</button>
|
|
<button class="retrodate-btn-action cancel" id="btn-cancel-ticket-date" title="Annulla">✗</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Modificato</span>
|
|
<span class="meta-value">${App.formatDateTime(ticket.change_time)}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Lock</span>
|
|
<span class="meta-value">${ticket.lock_name || 'unlock'}</span>
|
|
</div>
|
|
${ticket.type_name ? `
|
|
<div class="meta-row">
|
|
<span class="meta-label">Tipo</span>
|
|
<span class="meta-value">${ticket.type_name}</span>
|
|
</div>
|
|
` : ''}
|
|
${ticket.responsible_first ? `
|
|
<div class="meta-row">
|
|
<span class="meta-label">Responsabile</span>
|
|
<span class="meta-value">${ticket.responsible_first} ${ticket.responsible_last}</span>
|
|
</div>
|
|
` : ''}
|
|
${totalTime > 0 ? `
|
|
<div class="meta-row">
|
|
<span class="meta-label">Tempo Totale</span>
|
|
<span class="meta-value" style="font-weight:bold;color:var(--info);">⏱ ${totalTime} min</span>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
|
|
${ticket.customer_user_id || ticket.customer_id ? `
|
|
<div class="sidebar-panel">
|
|
<div class="sidebar-panel-title">Cliente</div>
|
|
${ticket.customer_first ? `
|
|
<div class="meta-row">
|
|
<span class="meta-label">Nome</span>
|
|
<span class="meta-value">${ticket.customer_first} ${ticket.customer_last}</span>
|
|
</div>
|
|
` : ''}
|
|
${ticket.customer_email ? `
|
|
<div class="meta-row">
|
|
<span class="meta-label">Email</span>
|
|
<span class="meta-value" style="font-size:0.78rem;">${ticket.customer_email}</span>
|
|
</div>
|
|
` : ''}
|
|
${ticket.customer_phone ? `
|
|
<div class="meta-row">
|
|
<span class="meta-label">Telefono</span>
|
|
<span class="meta-value">${ticket.customer_phone}</span>
|
|
</div>
|
|
` : ''}
|
|
${ticket.customer_id ? `
|
|
<div class="meta-row">
|
|
<span class="meta-label">Customer ID</span>
|
|
<span class="meta-value" style="font-size:0.78rem;">${ticket.customer_id}</span>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
` : ''}
|
|
|
|
${ticket.escalation_time > 0 ? `
|
|
<div class="sidebar-panel" style="border-color: rgba(239,68,68,0.3);">
|
|
<div class="sidebar-panel-title" style="color:var(--error);">⚠ Escalation</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Tempo</span>
|
|
<span class="meta-value" style="color:var(--error);">${new Date(ticket.escalation_time * 1000).toLocaleString('it-IT')}</span>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.bindEvents();
|
|
|
|
} catch (err) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">⚠️</div>
|
|
<div class="empty-state-text">Errore caricamento ticket</div>
|
|
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
|
<button class="btn btn-ghost" style="margin-top:var(--space-md);" onclick="history.back()">Torna indietro</button>
|
|
</div>
|
|
`;
|
|
}
|
|
},
|
|
|
|
bindEvents() {
|
|
// Quick-edit change detection
|
|
const selects = document.querySelectorAll('.quick-edit-select');
|
|
const saveBtn = document.getElementById('qe-save');
|
|
const resetBtn = document.getElementById('qe-reset');
|
|
|
|
const checkChanges = () => {
|
|
let hasChanges = false;
|
|
selects.forEach(sel => {
|
|
const field = sel.dataset.field;
|
|
const original = String(this.originalValues[field] || '');
|
|
const current = sel.value;
|
|
const changed = current !== original;
|
|
sel.classList.toggle('changed', changed);
|
|
if (changed) hasChanges = true;
|
|
});
|
|
saveBtn.disabled = !hasChanges;
|
|
};
|
|
|
|
selects.forEach(sel => sel.addEventListener('change', checkChanges));
|
|
|
|
// Reset quick-edit
|
|
resetBtn.addEventListener('click', () => {
|
|
selects.forEach(sel => {
|
|
sel.value = this.originalValues[sel.dataset.field] || '';
|
|
sel.classList.remove('changed');
|
|
});
|
|
saveBtn.disabled = true;
|
|
});
|
|
|
|
// Save quick-edit
|
|
saveBtn.addEventListener('click', async () => {
|
|
const updates = {};
|
|
selects.forEach(sel => {
|
|
const field = sel.dataset.field;
|
|
const val = sel.value ? parseInt(sel.value) : null;
|
|
if (val !== null && val !== this.originalValues[field]) {
|
|
updates[field] = val;
|
|
}
|
|
});
|
|
|
|
if (Object.keys(updates).length === 0) return;
|
|
|
|
try {
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = 'Salvando...';
|
|
const res = await App.api(`/api/tickets/${this.ticketId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(updates),
|
|
});
|
|
Toast.success(res.message || 'Ticket aggiornato!');
|
|
// Refresh the view
|
|
this.render(this.ticketId);
|
|
} catch (err) {
|
|
Toast.error('Errore: ' + err.message);
|
|
saveBtn.disabled = false;
|
|
saveBtn.textContent = 'Salva Modifiche';
|
|
}
|
|
});
|
|
|
|
// Send note
|
|
const noteSendBtn = document.getElementById('note-send');
|
|
noteSendBtn.addEventListener('click', async () => {
|
|
const body = document.getElementById('note-body').value.trim();
|
|
const subject = document.getElementById('note-subject').value.trim();
|
|
const time_unit = document.getElementById('note-time-units').value.trim();
|
|
|
|
if (!body) {
|
|
Toast.warning('Scrivi qualcosa prima di inviare');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
noteSendBtn.disabled = true;
|
|
noteSendBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;"></div> Invio...';
|
|
|
|
const res = await App.api(`/api/tickets/${this.ticketId}/articles`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ subject, body, time_unit }),
|
|
});
|
|
|
|
Toast.success(res.message || 'Nota aggiunta!');
|
|
this.render(this.ticketId);
|
|
} catch (err) {
|
|
Toast.error('Errore: ' + err.message);
|
|
noteSendBtn.disabled = false;
|
|
noteSendBtn.innerHTML = 'Invia Nota';
|
|
}
|
|
});
|
|
|
|
// Retrodate Ticket Event Listeners
|
|
const btnEditTicketDate = document.getElementById('btn-edit-ticket-date');
|
|
const ticketDateEditor = document.getElementById('ticket-date-editor');
|
|
const ticketDateText = document.getElementById('ticket-date-text');
|
|
const btnSaveTicketDate = document.getElementById('btn-save-ticket-date');
|
|
const btnCancelTicketDate = document.getElementById('btn-cancel-ticket-date');
|
|
const inputTicketDate = document.getElementById('input-ticket-date');
|
|
|
|
if (btnEditTicketDate) {
|
|
btnEditTicketDate.addEventListener('click', () => {
|
|
btnEditTicketDate.style.display = 'none';
|
|
ticketDateText.style.display = 'none';
|
|
ticketDateEditor.style.display = 'flex';
|
|
});
|
|
|
|
btnCancelTicketDate.addEventListener('click', () => {
|
|
ticketDateEditor.style.display = 'none';
|
|
btnEditTicketDate.style.display = 'inline-flex';
|
|
ticketDateText.style.display = 'inline';
|
|
});
|
|
|
|
btnSaveTicketDate.addEventListener('click', async () => {
|
|
const val = inputTicketDate.value;
|
|
if (!val) {
|
|
Toast.warning('Scegli una data/ora valida.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
btnSaveTicketDate.disabled = true;
|
|
await App.api(`/api/tickets/${this.ticketId}/retrodata-ticket`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ create_time: val })
|
|
});
|
|
Toast.success('Data creazione ticket aggiornata!');
|
|
this.render(this.ticketId);
|
|
} catch (err) {
|
|
Toast.error('Errore retrodatazione ticket: ' + err.message);
|
|
btnSaveTicketDate.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Retrodate Articles Event Listeners
|
|
document.querySelectorAll('.retrodate-container[data-article-id]').forEach(container => {
|
|
const articleId = container.dataset.articleId;
|
|
const btnEdit = container.querySelector('.btn-edit-article-date');
|
|
const editor = container.querySelector('.retrodate-editor');
|
|
const timeText = container.querySelector('.article-time');
|
|
const btnSave = container.querySelector('.btn-save-article-date');
|
|
const btnCancel = container.querySelector('.btn-cancel-article-date');
|
|
const input = container.querySelector('.input-article-date');
|
|
|
|
if (btnEdit) {
|
|
btnEdit.addEventListener('click', () => {
|
|
btnEdit.style.display = 'none';
|
|
timeText.style.display = 'none';
|
|
editor.style.display = 'flex';
|
|
});
|
|
|
|
btnCancel.addEventListener('click', () => {
|
|
editor.style.display = 'none';
|
|
btnEdit.style.display = 'inline-flex';
|
|
timeText.style.display = 'inline';
|
|
});
|
|
|
|
btnSave.addEventListener('click', async () => {
|
|
const val = input.value;
|
|
if (!val) {
|
|
Toast.warning('Scegli una data/ora valida.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
btnSave.disabled = true;
|
|
await App.api(`/api/tickets/articles/${articleId}/retrodata-article`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ create_time: val })
|
|
});
|
|
Toast.success('Data creazione articolo aggiornata!');
|
|
this.render(this.ticketId);
|
|
} catch (err) {
|
|
Toast.error('Errore retrodatazione articolo: ' + err.message);
|
|
btnSave.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
// HTML Mode Toggle Listener
|
|
const htmlToggle = document.getElementById('html-toggle');
|
|
if (htmlToggle) {
|
|
htmlToggle.addEventListener('change', (e) => {
|
|
const isChecked = e.target.checked;
|
|
localStorage.setItem('otrs_turbo_html_mode', isChecked);
|
|
this.htmlMode = isChecked;
|
|
this.render(this.ticketId);
|
|
});
|
|
}
|
|
},
|
|
};
|