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

1307 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Ticket Detail View
* Shows full ticket info with quick-edit dropdowns, article timeline, and add-note form.
*/
const TicketDetailView = {
ticketId: null,
htmlMode: false,
originalValues: {},
noteAttachments: [],
saveDraft() {
if (!this.ticketId) return;
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
const subject = document.getElementById('note-subject') ? document.getElementById('note-subject').value.trim() : '';
const time_unit = document.getElementById('note-time-units') ? document.getElementById('note-time-units').value.trim() : '';
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0) {
App.saveDraft(this.ticketId, {
type: 'note',
body,
subject,
time_unit,
attachments: [...this.noteAttachments]
});
} else {
App.clearDraft(this.ticketId, 'note');
}
},
updateNoteAttachmentList() {
const listEl = document.getElementById('note-file-list');
if (!listEl) return;
listEl.innerHTML = this.noteAttachments.map((att, idx) => `
<div class="upload-file-item">
<span>📎</span>
<strong>${App.escapeHtml(att.filename)}</strong>
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs);">(${Math.round(att.content.length * 0.75 / 1024)} KB)</span>
<button type="button" class="btn-remove" data-idx="${idx}">✕</button>
</div>
`).join('');
// Bind remove buttons
listEl.querySelectorAll('.btn-remove').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx, 10);
this.noteAttachments.splice(idx, 1);
this.updateNoteAttachmentList();
});
});
},
openImageLightbox(src) {
let overlay = document.getElementById('image-lightbox-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'image-lightbox-overlay';
overlay.style.cssText = `
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
opacity: 0;
transition: opacity 0.2s ease;
`;
overlay.innerHTML = `
<img id="image-lightbox-img" style="max-width: 90vw; max-height: 90vh; border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); cursor: default; transition: transform 0.2s ease;" />
`;
overlay.addEventListener('click', () => {
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 200);
});
overlay.querySelector('img').addEventListener('click', (e) => {
e.stopPropagation();
});
document.body.appendChild(overlay);
}
const imgEl = overlay.querySelector('img');
imgEl.src = src;
overlay.offsetHeight;
overlay.style.opacity = '1';
},
async render(id) {
this.ticketId = id;
this.noteAttachments = [];
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;
// No automatic tab creation here anymore
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);
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);
// Display ticket number in the topbar with OTRS link if available
const titleEl = document.getElementById('page-title');
if (titleEl) {
if (data.otrsWebUrl) {
titleEl.innerHTML = `<a href="${data.otrsWebUrl}" target="_blank" style="color:inherit; text-decoration:none; display:inline-flex; align-items:center; gap:6px;" title="Apri in OTRS">Ticket #${ticket.tn || id} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;opacity:0.75;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg></a>`;
} else {
titleEl.innerHTML = `Ticket #${ticket.tn || id}`;
}
}
this.originalValues = {
ticket_state_id: ticket.ticket_state_id,
ticket_priority_id: ticket.ticket_priority_id,
queue_id: ticket.queue_id,
queue_name: ticket.queue_name,
user_id: ticket.user_id,
responsible_user_id: ticket.responsible_user_id,
type_id: ticket.type_id,
customer_id: ticket.customer_id,
customer_user_id: ticket.customer_user_id,
customer_first: ticket.customer_first,
customer_last: ticket.customer_last,
};
const hasEmailDraft = App.getDraft(id, 'email');
const emailBtnText = hasEmailDraft ? 'Continua mail' : 'Invia Email';
container.innerHTML = `
<a class="back-link" onclick="window.location.hash = App.lastListView || '#/tickets'">
<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">
${data.otrsWebUrl ? `
<a href="${data.otrsWebUrl}" target="_blank" style="color:inherit; text-decoration:none; border-bottom:1px dashed transparent; transition:border-bottom 0.1s ease;" onmouseover="this.style.borderBottom='1px dashed var(--text-primary)'" onmouseout="this.style.borderBottom='transparent'" title="Apri in OTRS">
${App.escapeHtml(ticket.title || '(senza titolo)')}
</a>
` : 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" style="position:relative;">
<label class="quick-edit-label">Coda</label>
<input type="text" class="quick-edit-select" id="qe-queue-search" placeholder="Cerca coda..." autocomplete="off" value="${App.escapeHtml(ticket.queue_name || '')}" style="background-image: none; cursor: text;" />
<input type="hidden" id="qe-queue" data-field="queue_id" value="${ticket.queue_id || ''}" />
<div id="qe-queue-suggestions" class="autocomplete-suggestions" style="display:none; width: 800px; max-width: 800px; z-index: 1005;"></div>
</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>
<div class="quick-edit-field">
<label class="quick-edit-label">Responsabile</label>
<select class="quick-edit-select" id="qe-responsible" data-field="responsible_user_id">
<option value="">—</option>
${(App.lookups.users || []).map(u =>
`<option value="${u.id}" ${u.id === ticket.responsible_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 class="quick-edit-field" style="position:relative;">
<label class="quick-edit-label">Utente Cliente</label>
<input type="text" class="form-input" id="qe-customer-search" placeholder="Cerca cliente..." autocomplete="off" value="${ticket.customer_first ? `${ticket.customer_first} ${ticket.customer_last}` : (ticket.customer_user_id || '')}" style="padding: 6px 12px; height: 32px; font-size: 0.85rem;" />
<input type="hidden" id="qe-customer-user-id" data-field="customer_user_id" value="${ticket.customer_user_id || ''}" />
<input type="hidden" id="qe-customer-id" data-field="customer_id" value="${ticket.customer_id || ''}" />
<div id="qe-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%;"></div>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Tempo (minuti)</label>
<input type="number" step="any" min="0" class="form-input" id="qe-time-unit" placeholder="minuti" style="padding: 6px 12px; height: 32px; font-size: 0.85rem;" />
</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>
</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;" />
</div>
<div id="note-body-container" style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-md);">
<div id="note-body-editor" style="min-height: 180px; font-family: inherit; font-size: 0.95rem; border: none; color: var(--text-primary);"></div>
</div>
<!-- Attachments List for note -->
<div class="upload-file-list" id="note-file-list" style="margin-bottom: var(--space-md);"></div>
<div style="display:flex; gap:var(--space-md); justify-content:space-between; align-items:center; margin-top:var(--space-md);">
<div>
<input type="file" id="note-attachments" multiple style="display:none;" />
<button type="button" class="btn btn-ghost btn-sm" id="btn-note-add-attachments" style="height:32px; padding: 4px 10px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px;">📎 Allega file</button>
</div>
<div style="display:flex; gap:var(--space-md); align-items:center;">
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
<button class="btn btn-ghost btn-sm" id="btn-open-email-compose" style="height:32px; display:flex; align-items:center; gap:var(--space-xs); border-color:var(--accent-secondary); color:var(--accent-secondary);"
data-ticket-id="${ticket.id}" data-ticket-tn="${ticket.tn}" data-ticket-title="${App.escapeHtml(ticket.title)}" data-customer-email="${App.escapeHtml(ticket.customer_email || '')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><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>
${emailBtnText}
</button>
<button class="btn btn-primary btn-sm" id="note-send" style="height:32px; display:flex; align-items:center; gap:var(--space-xs);">
<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>
</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('/>');
let processedBody = a.a_body || '';
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
if (hasHtml) {
articleAttachments.forEach(att => {
if (att.content_id) {
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
if (cleanCid) {
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`cid:<?${escapedCid}>?`, 'gi');
processedBody = processedBody.replace(regex, `/api/tickets/attachments/${att.id}`);
}
}
if (att.filename) {
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
processedBody = processedBody.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
}
});
}
const displayBody = this.htmlMode && hasHtml
? `<iframe srcdoc="${processedBody.replace(/"/g, '&quot;')}" 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 visibleAttachments = articleAttachments.filter(att => att.filename !== 'file-1' && att.filename !== 'file-2');
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>` : ''}
<button class="btn-delete-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Elimina Articolo">🗑️</button>
<button class="btn-email-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Rispondi via email (Quota questo articolo)">✉️</button>
</div>
<div style="display:flex; gap: var(--space-sm); align-items:center;">
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
${a.time_unit ? `
<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ <span class="time-value-display">${parseFloat(a.time_unit)}</span> min</span>
` : ''}
<button class="retrodate-btn-edit btn-edit-article-time" style="background:none; border:none; cursor:pointer; font-size:0.75rem; padding: 2px 4px; display:inline-flex; align-items:center;" title="${a.time_unit ? 'Modifica Tempo' : 'Aggiungi Tempo'}">
${a.time_unit ? '✏️' : '⏱+✏️'}
</button>
<div class="time-editor-popover" style="display:none; position:absolute; right:0; top: 100%; background:var(--bg-card); border:1px solid var(--border-subtle); padding:var(--space-xs); border-radius:var(--radius-sm); z-index:100; box-shadow:var(--shadow-md); margin-top:4px;">
<input type="number" step="any" min="0" class="form-input input-article-time" value="${a.time_unit ? parseFloat(a.time_unit) : 0}" style="width:70px; height:24px; padding:2px 6px; font-size:0.8rem; margin-bottom:4px; display:block;" />
<div style="display:flex; gap:4px; justify-content:flex-end;">
<button class="btn btn-primary btn-sm btn-save-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✓</button>
<button class="btn btn-ghost btn-sm btn-cancel-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✗</button>
</div>
</div>
</div>
<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}
${visibleAttachments.length > 0 ? `
<div class="article-attachments">
${visibleAttachments.map(att => `
<a href="/api/tickets/attachments/${att.id}" class="attachment-badge" target="_blank" download="${att.filename}" data-is-image="${(att.content_type || '').startsWith('image/')}">
<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">Stato</span>
<span class="meta-value"><span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span></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>
` : ''}
<div class="meta-row">
<span class="meta-label">Owner</span>
<span class="meta-value">${ticket.owner_first ? `${ticket.owner_first} ${ticket.owner_last}` : (ticket.owner_login || '—')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Responsabile</span>
<span class="meta-value">${ticket.responsible_first ? `${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>
` : ''}
<!-- 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>
`;
if (window.Quill) {
this.noteQuill = new Quill('#note-body-editor', {
theme: 'snow',
placeholder: 'Scrivi una nota interna...',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['link', 'image'],
['clean']
],
keyboard: {
bindings: {
tab: {
key: 'Tab',
handler: function() {
return true;
}
}
}
}
}
});
// Prevent tab navigation on toolbar items
const toolbar = container.querySelector('.ql-toolbar');
if (toolbar) {
toolbar.querySelectorAll('button, select, span[role="button"], input').forEach(el => {
el.setAttribute('tabindex', '-1');
});
}
} else {
this.noteQuill = null;
}
// Restore Note Draft
const noteDraft = App.getDraft(id, 'note');
if (noteDraft) {
if (this.noteQuill && noteDraft.body) {
this.noteQuill.root.innerHTML = noteDraft.body;
}
if (document.getElementById('note-subject')) {
document.getElementById('note-subject').value = noteDraft.subject || '';
}
if (document.getElementById('note-time-units')) {
document.getElementById('note-time-units').value = noteDraft.time_unit || '';
}
this.noteAttachments = noteDraft.attachments || [];
this.updateNoteAttachmentList();
}
this.bindEvents(ticket, articles, container, groupsData);
} 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(ticket, articles, container, groupsData) {
// Quick-edit change detection
const fields = document.querySelectorAll('.quick-edit-select:not(#qe-queue-search), #qe-queue, #qe-customer-user-id, #qe-customer-id');
const saveBtn = document.getElementById('qe-save');
const resetBtn = document.getElementById('qe-reset');
const timeUnitInput = document.getElementById('qe-time-unit');
const checkChanges = () => {
let hasChanges = false;
fields.forEach(el => {
const field = el.dataset.field;
const original = String(this.originalValues[field] || '');
const current = el.value;
const changed = current !== original;
if (el.id === 'qe-queue') {
const searchInput = document.getElementById('qe-queue-search');
if (searchInput) searchInput.classList.toggle('changed', changed);
} else {
el.classList.toggle('changed', changed);
}
if (changed) hasChanges = true;
});
const timeVal = parseFloat(timeUnitInput ? timeUnitInput.value : '0') || 0;
if (timeVal > 0) {
hasChanges = true;
}
saveBtn.disabled = !hasChanges;
};
fields.forEach(el => el.addEventListener('change', checkChanges));
if (timeUnitInput) {
timeUnitInput.addEventListener('input', checkChanges);
timeUnitInput.addEventListener('change', checkChanges);
}
// Queue Autocomplete inside Quick Edit
const queueSearchInput = document.getElementById('qe-queue-search');
const queueSuggestionsDiv = document.getElementById('qe-queue-suggestions');
const queueIdInput = document.getElementById('qe-queue');
let queueDebounce;
if (queueSearchInput) {
queueSearchInput.addEventListener('input', () => {
clearTimeout(queueDebounce);
const q = queueSearchInput.value.trim();
queueDebounce = setTimeout(async () => {
try {
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
if (queueSearchInput.value.trim() !== q) {
return;
}
if (queues.length === 0) {
queueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessuna coda trovata</div>';
queueSuggestionsDiv.style.display = 'block';
return;
}
queueSuggestionsDiv.innerHTML = queues.map(q => {
const displayName = q.name.replace(/::/g, ' ');
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;">
${App.escapeHtml(displayName)}
</div>
`;
}).join('');
queueSuggestionsDiv.style.display = 'block';
// Bind click/mousedown
queueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
item.addEventListener('mousedown', (e) => {
e.preventDefault();
queueSearchInput.value = item.dataset.name;
queueIdInput.value = item.dataset.id;
queueSuggestionsDiv.style.display = 'none';
queueIdInput.dispatchEvent(new Event('change'));
});
}
});
} catch (err) {
console.error(err);
}
}, 150);
});
queueSearchInput.addEventListener('focus', () => {
queueSearchInput.value = '';
queueIdInput.value = '';
queueSearchInput.dispatchEvent(new Event('input'));
});
queueSearchInput.addEventListener('blur', () => {
setTimeout(() => { queueSuggestionsDiv.style.display = 'none'; }, 150);
});
}
// Customer User Autocomplete inside Quick Edit
const customerSearchInput = document.getElementById('qe-customer-search');
const customerSuggestionsDiv = document.getElementById('qe-customer-suggestions');
const customerUserIdInput = document.getElementById('qe-customer-user-id');
const customerIdInput = document.getElementById('qe-customer-id');
let customerDebounce;
if (customerSearchInput) {
customerSearchInput.addEventListener('input', () => {
clearTimeout(customerDebounce);
const q = customerSearchInput.value.trim();
customerDebounce = setTimeout(async () => {
try {
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
console.log('[Frontend LDAP Search Detail] Users returned:', users);
// Prevent race conditions: discard results if the input value has changed
if (customerSearchInput.value.trim() !== q) {
console.log('[Frontend LDAP Search Detail] Discarding stale results for query:', q);
return;
}
if (users.length === 0) {
customerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
customerSuggestionsDiv.style.display = 'block';
return;
}
customerSuggestionsDiv.innerHTML = users.map(u => `
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login || '')}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml((u.first_name + ' ' + u.last_name).trim() || u.login || '')}">
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login || '')} | Azienda: ${App.escapeHtml(u.customer_id || '—')})</span>
</div>
`).join('');
customerSuggestionsDiv.style.display = 'block';
// Use mousedown instead of click to fire before blur event
customerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.login) {
item.addEventListener('mousedown', (e) => {
e.preventDefault(); // prevent input from losing focus before value is set
customerSearchInput.value = item.dataset.name;
customerUserIdInput.value = item.dataset.login;
customerIdInput.value = item.dataset.customerId || '';
customerSuggestionsDiv.style.display = 'none';
customerUserIdInput.dispatchEvent(new Event('change'));
customerIdInput.dispatchEvent(new Event('change'));
});
}
});
} catch (err) {
console.error(err);
}
}, 300);
});
customerSearchInput.addEventListener('focus', () => {
if (!customerUserIdInput.value) {
customerSearchInput.dispatchEvent(new Event('input'));
}
});
customerSearchInput.addEventListener('blur', () => {
setTimeout(() => { customerSuggestionsDiv.style.display = 'none'; }, 150);
});
}
// Reset quick-edit
resetBtn.addEventListener('click', () => {
fields.forEach(el => {
el.value = this.originalValues[el.dataset.field] || '';
el.classList.remove('changed');
});
if (queueSearchInput) {
queueSearchInput.classList.remove('changed');
queueSearchInput.value = this.originalValues['queue_name'] || '';
}
if (customerSearchInput) {
const first = this.originalValues['customer_first'] || '';
const last = this.originalValues['customer_last'] || '';
customerSearchInput.value = first ? `${first} ${last}` : (this.originalValues['customer_user_id'] || '');
}
if (timeUnitInput) {
timeUnitInput.value = '';
}
saveBtn.disabled = true;
});
// Save quick-edit
saveBtn.addEventListener('click', async () => {
const queueSearch = queueSearchInput ? queueSearchInput.value.trim() : '';
if (queueSearchInput && !queueIdInput.value) {
Toast.warning('Seleziona una coda valida dall\'elenco.');
return;
}
const customerSearch = customerSearchInput ? customerSearchInput.value.trim() : '';
if (customerSearch && !customerUserIdInput.value) {
customerUserIdInput.value = customerSearch;
customerIdInput.value = customerSearch;
}
const updates = {};
fields.forEach(el => {
const field = el.dataset.field;
let val;
if (field === 'customer_id' || field === 'customer_user_id') {
val = el.value || null;
} else {
val = el.value ? parseInt(el.value) : null;
}
const origVal = this.originalValues[field] || null;
if (val !== origVal) {
updates[field] = val;
}
});
if (timeUnitInput && timeUnitInput.value.trim()) {
const timeVal = parseFloat(timeUnitInput.value.trim());
if (!isNaN(timeVal) && timeVal > 0) {
updates.time_unit = timeVal;
}
}
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 () => {
let body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
if (body === '<p><br></p>') {
body = '';
}
let subject = document.getElementById('note-subject').value.trim();
const time_unit = document.getElementById('note-time-units').value.trim();
// If body is empty but subject is provided, fill body with subject text
if (!body && subject) {
body = `<p>${App.escapeHtml(subject)}</p>`;
if (this.noteQuill) {
this.noteQuill.root.innerHTML = body;
}
}
// If subject is empty but body is provided, fill subject with truncated plain text of body
if (body && !subject) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = body;
const plainText = tempDiv.textContent || tempDiv.innerText || '';
subject = plainText.trim().substring(0, 50) || 'Nota interna';
document.getElementById('note-subject').value = subject;
}
// If both are still empty, show warning
if (!body && !subject) {
Toast.warning('Inserisci un oggetto o il corpo della nota 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,
attachments: this.noteAttachments.length > 0 ? this.noteAttachments : undefined
}),
});
this.noteAttachments = []; // Clear attachments
Toast.success(res.message || 'Nota aggiunta!');
App.clearDraft(this.ticketId, 'note');
App.updateDailyTimer();
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore: ' + err.message);
noteSendBtn.disabled = false;
noteSendBtn.innerHTML = 'Invia Nota';
}
});
// Note Attachments Upload Handlers
const noteAttachBtn = document.getElementById('btn-note-add-attachments');
const noteFileInput = document.getElementById('note-attachments');
if (noteAttachBtn && noteFileInput) {
noteAttachBtn.addEventListener('click', () => noteFileInput.click());
noteFileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
for (const file of files) {
const reader = new FileReader();
reader.onload = () => {
const base64Data = reader.result.split(',')[1];
this.noteAttachments.push({
filename: file.name,
content: base64Data,
content_type: file.type
});
this.updateNoteAttachmentList();
};
reader.readAsDataURL(file);
}
noteFileInput.value = ''; // Reset input
});
}
// Email Compose Button
const btnEmailCompose = document.getElementById('btn-open-email-compose');
if (btnEmailCompose) {
btnEmailCompose.addEventListener('click', () => {
if (window.EmailCompose) {
const tId = parseInt(btnEmailCompose.dataset.ticketId, 10);
const emailDraft = App.getDraft(tId, 'email');
if (emailDraft) {
EmailCompose.open({
...emailDraft.options,
draft: emailDraft
});
} else {
EmailCompose.open({
ticketId: tId,
ticketTn: btnEmailCompose.dataset.ticketTn,
ticketTitle: btnEmailCompose.dataset.ticketTitle,
customerEmail: btnEmailCompose.dataset.customerEmail,
});
}
} else {
Toast.error('Modulo email non disponibile');
}
});
}
// Article Email Reply Buttons
document.querySelectorAll('.btn-email-article').forEach(btn => {
btn.addEventListener('click', () => {
const articleId = parseInt(btn.dataset.articleId, 10);
const article = articles.find(art => art.article_id == articleId);
if (!article) return;
if (window.EmailCompose) {
const hasHtml = (article.a_content_type || '').toLowerCase().includes('html') || article.a_body.includes('</') || article.a_body.includes('/>');
let processedQuoted = article.a_body || '';
if (hasHtml) {
const articleAttachments = (attachments || []).filter(att => att.article_id === article.article_id);
articleAttachments.forEach(att => {
if (att.content_id) {
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
if (cleanCid) {
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`cid:<?${escapedCid}>?`, 'gi');
processedQuoted = processedQuoted.replace(regex, `/api/tickets/attachments/${att.id}`);
}
}
if (att.filename) {
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
processedQuoted = processedQuoted.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
}
});
}
const quotedBody = hasHtml ? processedQuoted : App.escapeHtml(article.a_body || '').replace(/\n/g, '<br>');
const initialBodyHtml = `
<p><br></p>
<p>Il ${App.formatDateTime(article.create_time)}, <strong>${App.escapeHtml(article.a_from || 'Sistema')}</strong> ha scritto:</p>
<blockquote style="border-left: 2px solid var(--border-subtle, #444); padding-left: 12px; margin-left: 8px; color: var(--text-secondary);">
${quotedBody}
</blockquote>
<p><br></p>
`;
// Helper to extract email addresses from headers
const parseEmails = (str) => {
if (!str) return [];
return (str.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [])
.map(email => email.toLowerCase().trim());
};
const fromEmails = parseEmails(article.a_from);
const toEmails = parseEmails(article.a_to);
const ccEmails = parseEmails(article.a_cc);
// Exclude list (helpdesk and active agent)
const config = App.lookups.config || {};
const helpdeskEmail = (config.helpdeskEmail || 'helpdesk@pharmaidea.com').toLowerCase();
const agentEmail = (config.agentEmail || '').toLowerCase();
const excludeEmails = [helpdeskEmail, agentEmail].filter(Boolean);
const initialToSet = new Set();
fromEmails.forEach(e => {
if (!excludeEmails.includes(e) && !e.includes('helpdesk')) {
initialToSet.add(e);
}
});
if (initialToSet.size === 0 && ticket.customer_email) {
initialToSet.add(ticket.customer_email.toLowerCase());
}
const initialCcSet = new Set();
[...toEmails, ...ccEmails].forEach(e => {
if (!excludeEmails.includes(e) && !e.includes('helpdesk') && !initialToSet.has(e)) {
initialCcSet.add(e);
}
});
let inReplyTo = article.a_message_id || '';
let references = '';
if (article.a_references) {
references = article.a_references + (article.a_message_id ? ' ' + article.a_message_id : '');
} else if (article.a_message_id) {
references = article.a_message_id;
}
EmailCompose.open({
ticketId: ticket.id,
ticketTn: ticket.tn,
ticketTitle: ticket.title,
initialTo: Array.from(initialToSet),
initialCc: Array.from(initialCcSet),
initialBodyHtml: initialBodyHtml,
inReplyTo: inReplyTo,
references: references,
});
} else {
Toast.error('Modulo email non disponibile');
}
});
});
// 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;
}
});
}
});
// Time Edit Event Listeners (Issue 21)
document.querySelectorAll('.time-edit-container[data-article-id]').forEach(container => {
const articleId = container.dataset.articleId;
const btnEdit = container.querySelector('.btn-edit-article-time');
const popover = container.querySelector('.time-editor-popover');
const btnSave = container.querySelector('.btn-save-article-time');
const btnCancel = container.querySelector('.btn-cancel-article-time');
const input = container.querySelector('.input-article-time');
if (btnEdit) {
btnEdit.addEventListener('click', (e) => {
e.stopPropagation();
// Close all other open time edit popovers
document.querySelectorAll('.time-editor-popover').forEach(p => {
if (p !== popover) p.style.display = 'none';
});
const isOpen = popover.style.display === 'block';
popover.style.display = isOpen ? 'none' : 'block';
if (!isOpen) {
input.focus();
input.select();
}
});
}
if (btnCancel) {
btnCancel.addEventListener('click', (e) => {
e.stopPropagation();
popover.style.display = 'none';
});
}
if (btnSave) {
btnSave.addEventListener('click', async (e) => {
e.stopPropagation();
const val = parseFloat(input.value);
if (isNaN(val) || val < 0) {
Toast.warning('Inserisci un valore numerico valido maggiore o uguale a 0.');
return;
}
try {
btnSave.disabled = true;
await App.api(`/api/tickets/articles/${articleId}/time`, {
method: 'PUT',
body: JSON.stringify({ time_unit: val })
});
Toast.success('Tempo consuntivato aggiornato con successo!');
popover.style.display = 'none';
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore durante l\'aggiornamento del tempo: ' + err.message);
btnSave.disabled = false;
}
});
}
// Stop propagation so clicking inside doesn't close it
if (popover) {
popover.addEventListener('click', (e) => e.stopPropagation());
}
});
// Close popovers clicking outside
document.addEventListener('click', () => {
document.querySelectorAll('.time-editor-popover').forEach(p => {
p.style.display = 'none';
});
});
// Delete Article Event Listeners
document.querySelectorAll('.btn-delete-article').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const articleId = btn.dataset.articleId;
const ok = await App.confirm(
"Elimina Articolo",
"Sei sicuro di voler eliminare questa nota/articolo? Tutti i file allegati e i tempi ad esso associati verranno rimossi permanentemente.",
{ confirmText: 'Elimina', cancelText: 'Annulla' }
);
if (ok) {
try {
btn.disabled = true;
await App.api(`/api/tickets/articles/${articleId}`, {
method: 'DELETE'
});
Toast.success('Articolo/nota eliminato con successo!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore durante l\'eliminazione: ' + err.message);
btn.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);
});
}
// 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) {
sessionStorage.setItem('otrs_create_group_with_master_tn', ticket.tn);
window.location.hash = '#/tickets/groups';
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;
}
});
}
// Inline image preview & lightbox binding
document.querySelectorAll('.articles-timeline iframe').forEach(iframe => {
const attachImageClick = () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc) {
doc.querySelectorAll('img').forEach(img => {
img.style.cursor = 'zoom-in';
img.addEventListener('click', (e) => {
e.preventDefault();
this.openImageLightbox(img.src);
});
});
}
} catch (err) {
console.warn('Cannot attach click listeners to iframe images', err);
}
};
iframe.addEventListener('load', attachImageClick);
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc && doc.readyState === 'complete') {
attachImageClick();
}
} catch (_) {}
});
document.querySelectorAll('.attachment-badge[data-is-image="true"]').forEach(badge => {
badge.style.cursor = 'zoom-in';
badge.addEventListener('click', (e) => {
e.preventDefault();
this.openImageLightbox(badge.href);
});
});
},
};