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

3123 lines
150 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: true,
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() : '';
const ticket_state_id = document.getElementById('note-ticket-state') ? document.getElementById('note-ticket-state').value : '';
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0 || ticket_state_id !== '') {
App.saveDraft(this.ticketId, {
type: 'note',
body,
subject,
time_unit,
ticket_state_id,
attachments: [...this.noteAttachments]
});
} else {
App.clearDraft(this.ticketId, 'note');
}
},
extractLinks(htmlOrText) {
if (!htmlOrText) return [];
const links = [];
const seen = new Set();
// 1. Extract <a href="..."> tags
const anchorRegex = /<a\s+(?:[^>]*?\s+)?href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi;
let match;
while ((match = anchorRegex.exec(htmlOrText)) !== null) {
let url = match[1].trim().replace(/&amp;/g, '&');
let text = match[2].replace(/<[^>]+>/g, '').trim();
if (!text) text = url;
if (/^(javascript|mailto|tel|cid|#|\/api\/tickets\/attachments)/i.test(url)) {
continue;
}
if (!seen.has(url)) {
seen.add(url);
links.push({ url, text });
}
}
// 2. Extract plain HTTP/HTTPS URLs from text
const plainUrlRegex = /(https?:\/\/[^\s<"'>]+)/gi;
while ((match = plainUrlRegex.exec(htmlOrText)) !== null) {
let url = match[1].trim().replace(/[.,;)]+$/, '');
if (/^\/api\/tickets\/attachments/i.test(url)) continue;
if (!seen.has(url)) {
seen.add(url);
links.push({ url, text: url });
}
}
return links;
},
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();
});
});
},
getFileIcon(filename) {
const ext = (filename || '').split('.').pop().toLowerCase();
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return '📝';
if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return '📊';
if (['ppt', 'pptx', 'odp'].includes(ext)) return '📊';
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return '📦';
if (['pdf'].includes(ext)) return '📄';
if (['txt', 'log', 'json', 'xml', 'html', 'css', 'js', 'md'].includes(ext)) return '📜';
if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) return '🎵';
if (['mp4', 'webm', 'mov', 'avi'].includes(ext)) return '🎬';
return '📎';
},
collectAllTicketAttachments() {
const items = [];
// 1. Gather images from iframe email bodies
document.querySelectorAll('.articles-timeline iframe').forEach(iframe => {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc) {
doc.querySelectorAll('img').forEach(img => {
if (img.src && !img.src.startsWith('data:image/svg')) {
items.push({
type: 'image',
src: img.src,
filename: img.alt || img.title || 'Immagine articolo',
sizeText: '',
downloadUrl: img.src
});
}
});
}
} catch (_) {}
});
// 2. Gather all attachment badges (Images, PDFs, Word, Excel, etc.)
document.querySelectorAll('.attachment-badge').forEach(badge => {
if (badge.href) {
const filename = badge.querySelector('strong') ? badge.querySelector('strong').textContent : 'Allegato';
const sizeText = badge.querySelector('.attachment-size') ? badge.querySelector('.attachment-size').textContent : '';
const ext = (filename || '').split('.').pop().toLowerCase();
let type = 'file';
if (badge.dataset.isImage === 'true' || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) {
type = 'image';
} else if (ext === 'pdf') {
type = 'pdf';
} else if (['txt', 'log', 'json', 'xml', 'csv', 'html', 'md'].includes(ext)) {
type = 'text';
}
items.push({
type: type,
src: badge.href,
filename: filename,
sizeText: sizeText,
downloadUrl: badge.href
});
}
});
// Deduplicate items by src while maintaining order
const uniqueItems = [];
const seen = new Set();
items.forEach(item => {
if (!seen.has(item.src)) {
seen.add(item.src);
uniqueItems.push(item);
}
});
return uniqueItems;
},
openImageLightbox(initialSrc) {
const itemsList = this.collectAllTicketAttachments();
if (itemsList.length === 0) {
itemsList.push({ type: 'file', src: initialSrc, filename: 'Allegato', downloadUrl: initialSrc });
}
let currentIndex = itemsList.findIndex(item => item.src === initialSrc);
if (currentIndex === -1) {
itemsList.unshift({ type: 'file', src: initialSrc, filename: 'Allegato', downloadUrl: initialSrc });
currentIndex = 0;
}
let overlay = document.getElementById('image-lightbox-overlay');
if (overlay) {
overlay.remove();
}
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.88);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
user-select: none;
`;
overlay.innerHTML = `
<button id="lightbox-close-btn" title="Chiudi (Esc)" style="position: absolute; top: 20px; right: 24px; background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.25); color: white; border-radius: 50%; width: 40px; height: 40px; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; z-index: 10002;">✕</button>
<button id="lightbox-prev-btn" title="Allegato precedente (Freccia Sinistra)" style="position: absolute; left: 24px; top: 50%; transform: translateY(-50%); background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.25); color: white; border-radius: 50%; width: 48px; height: 48px; font-size: 26px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; z-index: 10002;">‹</button>
<button id="lightbox-next-btn" title="Allegato successivo (Freccia Destra)" style="position: absolute; right: 24px; top: 50%; transform: translateY(-50%); background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.25); color: white; border-radius: 50%; width: 48px; height: 48px; font-size: 26px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; z-index: 10002;">›</button>
<div id="lightbox-img-wrapper" style="display: flex; align-items: center; justify-content: center; width: 100%; max-width: 88vw; max-height: 82vh;"></div>
<div id="lightbox-caption" style="position: absolute; bottom: 20px; background: rgba(0, 0, 0, 0.85); border: 1px solid rgba(255, 255, 255, 0.2); padding: 6px 16px; border-radius: 24px; color: white; font-size: 0.82rem; display: flex; align-items: center; gap: 12px; z-index: 10002;">
<span id="lightbox-title-text" style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500;"></span>
<span id="lightbox-counter-text" style="opacity: 0.75; font-size: 0.78rem; font-family: monospace;"></span>
<a id="lightbox-download-btn" href="#" target="_blank" style="color: white; background: var(--accent-primary); padding: 4px 12px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; gap: 6px;" title="Apri nell'applicazione o scarica">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Apri nell'app
</a>
</div>
`;
overlay.tabIndex = -1;
document.body.appendChild(overlay);
overlay.focus();
const wrapper = overlay.querySelector('#lightbox-img-wrapper');
const prevBtn = overlay.querySelector('#lightbox-prev-btn');
const nextBtn = overlay.querySelector('#lightbox-next-btn');
const closeBtn = overlay.querySelector('#lightbox-close-btn');
const titleEl = overlay.querySelector('#lightbox-title-text');
const counterEl = overlay.querySelector('#lightbox-counter-text');
const downloadBtn = overlay.querySelector('#lightbox-download-btn');
const updateDisplay = () => {
const current = itemsList[currentIndex];
let previewHtml = '';
if (current.type === 'image') {
previewHtml = `<img id="image-lightbox-img" src="${current.src}" style="max-width: 86vw; max-height: 80vh; border-radius: 6px; box-shadow: 0 10px 40px rgba(0,0,0,0.6); object-fit: contain; transition: opacity 0.15s ease;" />`;
} else if (current.type === 'pdf') {
previewHtml = `
<div id="pdf-viewer-container" style="position: relative; width: 84vw; height: 78vh; background: var(--bg-card); border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.6); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border-subtle);">
<div id="pdf-toolbar" style="height: 48px; min-height: 48px; background: rgba(15, 23, 42, 0.95); border-bottom: 1px solid rgba(255, 255, 255, 0.15); display: flex; align-items: center; justify-content: space-between; padding: 0 16px; color: #ffffff; font-size: 0.82rem; user-select: none; gap: 12px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 10px;">
<span id="pdf-page-num" style="font-family: monospace; font-weight: 700; background: rgba(255,255,255,0.15); color: #ffffff; padding: 4px 12px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.2);">Pagina 1 / 1</span>
<!-- Editing Tools -->
<div id="pdf-edit-tools" style="display: flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.08); padding: 3px 8px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15);">
<button id="pdf-tool-select" class="btn btn-ghost btn-sm" title="Seleziona, Sposta, Ridimensiona o Elimina elementi" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🖐️ Seleziona</button>
<button id="pdf-tool-draw" class="btn btn-ghost btn-sm active" title="Disegna / Penna libera" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:var(--accent-primary); border-radius:4px; border:none; cursor:pointer;">✏️ Penna</button>
<button id="pdf-tool-text" class="btn btn-ghost btn-sm" title="Aggiungi Testo" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🔤 Testo</button>
<button id="pdf-tool-highlight" class="btn btn-ghost btn-sm" title="Evidenziatore" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🖍️ Evidenzia</button>
<button id="pdf-tool-shape" class="btn btn-ghost btn-sm" title="Inserisci Forme" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">📐 Forme</button>
<!-- Main Color Picker -->
<input type="color" id="pdf-color-picker" value="#ef4444" title="Colore primario" style="width:26px; height:26px; padding:0; border:none; border-radius:50%; cursor:pointer; background:none;" />
<!-- Shape controls container -->
<div id="pdf-shape-controls" style="display: none; align-items: center; gap: 6px; padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.2);">
<select id="pdf-shape-type" style="height: 26px; background: #0f172a; color: #fff; border: 1px solid rgba(255,255,255,0.3); border-radius: 4px; font-size: 0.76rem; padding: 0 4px; cursor: pointer;">
<option value="rectangle">🟦 Rettangolo</option>
<option value="circle">⚪ Cerchio</option>
</select>
<label style="font-size:0.72rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;" title="Colore Interno">
Riemp.
<input type="color" id="pdf-shape-fill-color" value="#3b82f6" style="width:22px; height:22px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</label>
<label style="font-size:0.72rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;" title="Colore del Bordo">
Bordo
<input type="color" id="pdf-shape-border-color" value="#1e3a8a" style="width:22px; height:22px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</label>
<label style="font-size:0.72rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;" title="Spessore del Bordo">
Spess.
<input type="number" id="pdf-shape-border-width" min="0" max="30" value="3" style="width:40px; height:24px; background:#0f172a; color:#fff; border:1px solid rgba(255,255,255,0.3); border-radius:4px; font-size:0.76rem; padding:0 4px; text-align:center;" />
</label>
</div>
<button id="pdf-tool-clear" class="btn btn-ghost btn-sm" title="Cancella tutte le modifiche apportate a questa pagina" style="color:#f87171; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🗑️ Pulisci Pagina</button>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 0.76rem; color: #cbd5e1; font-weight: 500;">
📜 Scorrimento continuo | ‹ › Allegati | Esc per chiudere
</span>
<button id="pdf-save-edited" class="btn btn-primary btn-sm" style="height:30px; padding:0 14px; font-size:0.8rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:var(--accent-primary); color:#ffffff; border:none; border-radius:6px; cursor:pointer;" title="Scarica il PDF modificato con le tue annotazioni">
💾 Scarica PDF Modificato
</button>
</div>
</div>
<div id="pdf-canvas-wrapper" style="flex: 1; overflow-y: auto; overflow-x: auto; display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 20px 16px; background: #334155; scroll-behavior: smooth;">
<div id="pdf-loading" style="color: #ffffff; display: flex; align-items: center; gap: 8px; margin-top: 40px; font-weight:500;">
<div class="spinner" style="width:20px; height:20px; border-width:2px;"></div>
<span>Rendering documento PDF in corso...</span>
</div>
</div>
</div>
`;
} else if (current.type === 'text') {
previewHtml = `<iframe src="${current.src}" style="width: 84vw; height: 78vh; border: none; border-radius: 6px; background: #ffffff; box-shadow: 0 10px 40px rgba(0,0,0,0.6);"></iframe>`;
} else {
const icon = this.getFileIcon(current.filename);
previewHtml = `
<div style="width: 480px; max-width: 88vw; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 32px 24px; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,0.6); color: var(--text-primary); display: flex; flex-direction: column; align-items: center; gap: 16px;">
<div style="font-size: 56px; line-height: 1;">${icon}</div>
<div>
<div style="font-size: 1.1rem; font-weight: 700; color: var(--text-primary); word-break: break-all; margin-bottom: 4px;">
${App.escapeHtml(current.filename)}
</div>
<div style="font-size: 0.82rem; color: var(--text-muted);">
${App.escapeHtml(current.sizeText || 'Allegato documento')}
</div>
</div>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 0; line-height: 1.4;">
Per modificare o visualizzare questo file con l'applicazione predefinita del tuo sistema (Word, Excel, ecc.), usa il pulsante qui sotto.
</p>
<a href="${current.downloadUrl}" download="${App.escapeHtml(current.filename)}" target="_blank" class="btn btn-primary" style="padding: 10px 22px; font-size: 0.9rem; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px; height:18px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Apri nell'app / Scarica
</a>
</div>
`;
}
wrapper.innerHTML = previewHtml;
this.currentFilename = current.filename || 'documento.pdf';
titleEl.textContent = current.filename || 'Allegato';
counterEl.textContent = `${currentIndex + 1} / ${itemsList.length}`;
downloadBtn.href = current.downloadUrl;
downloadBtn.download = current.filename || '';
if (itemsList.length <= 1) {
prevBtn.style.display = 'none';
nextBtn.style.display = 'none';
} else {
prevBtn.style.display = 'flex';
nextBtn.style.display = 'flex';
}
if (current.type === 'pdf') {
this.initPdfJsViewer(current.src);
}
overlay.focus();
};
const showPrev = () => {
if (itemsList.length <= 1) return;
currentIndex = (currentIndex - 1 + itemsList.length) % itemsList.length;
updateDisplay();
};
const showNext = () => {
if (itemsList.length <= 1) return;
currentIndex = (currentIndex + 1) % itemsList.length;
updateDisplay();
};
const closeLightbox = () => {
window.currentPdfDoc = null;
document.removeEventListener('keydown', handleKeydown, true);
window.removeEventListener('keydown', handleKeydown, true);
window.removeEventListener('blur', handleWindowBlur);
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 200);
};
// Helper to generate filename with optional timestamp prefix if already downloaded in current session
const getOutputFilename = (originalName) => {
let base = originalName || 'documento.pdf';
if (!this.downloadedFilenames) this.downloadedFilenames = new Set();
if (this.downloadedFilenames.has(base)) {
const now = new Date();
const yyyy = now.getFullYear();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const min = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const timestamp = `${yyyy}${mm}${dd}_${hh}${min}${ss}`;
return `${timestamp}_${base}`;
} else {
this.downloadedFilenames.add(base);
return base;
}
};
const savePdfDocument = async () => {
if (!window.PDFLib) {
Toast.error('Libreria salvataggio PDF-Lib non caricata');
return;
}
const saveBtn = document.getElementById('pdf-save-edited');
try {
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = 'Generazione PDF...';
}
const pdfDocLib = await window.PDFLib.PDFDocument.load(this.currentRawPdfBytes);
const libPages = pdfDocLib.getPages();
const totalPages = libPages.length;
for (let num = 1; num <= totalPages; num++) {
const baseCanvas = document.getElementById(`pdf-canvas-page-${num}`);
const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${num}`);
if (!baseCanvas || !drawCanvas) continue;
// Check if drawCanvas has any annotations
const drawCtx = drawCanvas.getContext('2d');
const imgData = drawCtx.getImageData(0, 0, drawCanvas.width, drawCanvas.height);
const hasDrawing = imgData.data.some(channel => channel !== 0);
if (hasDrawing && libPages[num - 1]) {
// Merge base page and drawings onto a temporary canvas
const tempCanvas = document.createElement('canvas');
tempCanvas.width = baseCanvas.width;
tempCanvas.height = baseCanvas.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(baseCanvas, 0, 0);
tempCtx.globalCompositeOperation = 'multiply';
tempCtx.drawImage(drawCanvas, 0, 0);
tempCtx.globalCompositeOperation = 'source-over';
const dataUrl = tempCanvas.toDataURL('image/jpeg', 0.92);
const jpgImage = await pdfDocLib.embedJpg(dataUrl);
const targetPage = libPages[num - 1];
const { width, height } = targetPage.getSize();
targetPage.drawImage(jpgImage, {
x: 0,
y: 0,
width: width,
height: height,
});
}
}
const modifiedBytes = await pdfDocLib.save();
const blob = new Blob([modifiedBytes], { type: 'application/pdf' });
const downloadUrl = URL.createObjectURL(blob);
const outName = getOutputFilename(this.currentFilename);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = outName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
Toast.success(`PDF modificato scaricato: ${outName}`);
} catch (saveErr) {
console.error('Error saving edited PDF', saveErr);
Toast.error('Errore durante il salvataggio: ' + saveErr.message);
} finally {
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = '💾 Scarica PDF Modificato';
}
}
};
const handleKeydown = (e) => {
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
if (isCtrlOrCmd && e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
savePdfDocument();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeLightbox();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
e.stopPropagation();
showPrev();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
showNext();
} else if (e.key === 'ArrowUp' && window.currentPdfDoc) {
e.preventDefault();
const prevPageBtn = document.getElementById('pdf-prev-page');
if (prevPageBtn) prevPageBtn.click();
} else if (e.key === 'ArrowDown' && window.currentPdfDoc) {
e.preventDefault();
const nextPageBtn = document.getElementById('pdf-next-page');
if (nextPageBtn) nextPageBtn.click();
}
};
// When clicking anywhere on the lightbox overlay or pressing ESC, ensure focus returns to main window
const handleWindowBlur = () => {
window.focus();
};
window.addEventListener('blur', handleWindowBlur);
document.addEventListener('keydown', handleKeydown, true);
window.addEventListener('keydown', handleKeydown, true);
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); showPrev(); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); showNext(); });
closeBtn.addEventListener('click', (e) => { e.stopPropagation(); closeLightbox(); });
overlay.addEventListener('click', (e) => {
if (e.target === overlay || e.target.id === 'lightbox-img-wrapper') {
closeLightbox();
}
});
updateDisplay();
overlay.offsetHeight;
overlay.style.opacity = '1';
},
async initPdfJsViewer(url) {
if (!window.pdfjsLib) {
console.warn('PDF.js library not loaded');
return;
}
try {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const response = await fetch(url);
const pdfBytes = await response.arrayBuffer();
this.currentRawPdfBytes = pdfBytes.slice(0); // copy buffer for pdf-lib export
const loadingTask = window.pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) });
const pdfDoc = await loadingTask.promise;
window.currentPdfDoc = pdfDoc;
const totalPages = pdfDoc.numPages;
const pageNumEl = document.getElementById('pdf-page-num');
const loadingEl = document.getElementById('pdf-loading');
const wrapper = document.getElementById('pdf-canvas-wrapper');
if (!wrapper) return;
if (pageNumEl) pageNumEl.textContent = `Pagina 1 / ${totalPages}`;
let currentTool = 'draw'; // 'draw' | 'text' | 'highlight'
let currentColor = '#ef4444';
const toolDrawBtn = document.getElementById('pdf-tool-draw');
const toolTextBtn = document.getElementById('pdf-tool-text');
const toolHighlightBtn = document.getElementById('pdf-tool-highlight');
const toolShapeBtn = document.getElementById('pdf-tool-shape');
const shapeControls = document.getElementById('pdf-shape-controls');
const colorPicker = document.getElementById('pdf-color-picker');
const toolClearBtn = document.getElementById('pdf-tool-clear');
const saveEditedBtn = document.getElementById('pdf-save-edited');
const setActiveTool = (tool, activeBtn) => {
currentTool = tool;
const allToolBtns = [toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, document.getElementById('pdf-tool-select')];
allToolBtns.forEach(b => {
if (b) {
b.style.background = 'transparent';
b.classList.remove('active');
}
});
if (activeBtn) {
activeBtn.style.background = 'var(--accent-primary)';
activeBtn.classList.add('active');
}
if (shapeControls) {
shapeControls.style.display = tool === 'shape' ? 'flex' : 'none';
}
};
if (toolDrawBtn) toolDrawBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('draw', toolDrawBtn); };
if (toolTextBtn) toolTextBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('text', toolTextBtn); };
if (toolHighlightBtn) toolHighlightBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('highlight', toolHighlightBtn); };
if (toolShapeBtn) {
toolShapeBtn.onclick = async (e) => {
e.stopPropagation();
setActiveTool('shape', toolShapeBtn);
let shapePopup = document.getElementById('pdf-shape-picker-popup');
if (shapePopup) {
shapePopup.remove();
return;
}
let allShapes = [];
try {
allShapes = await App.api('/api/config/user-shapes');
} catch (_) {}
const btnRect = toolShapeBtn.getBoundingClientRect();
shapePopup = document.createElement('div');
shapePopup.id = 'pdf-shape-picker-popup';
shapePopup.style.cssText = `
position: fixed;
left: ${btnRect.left}px;
top: ${btnRect.bottom + 6}px;
z-index: 20000;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
color: #ffffff;
font-size: 0.78rem;
min-width: 220px;
max-height: 280px;
overflow-y: auto;
user-select: none;
`;
shapePopup.innerHTML = `
<div style="font-weight: 700; margin-bottom: 6px; color: var(--accent-primary); border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between;">
<span>📐 Seleziona Forma</span>
<span style="cursor:pointer; opacity:0.7;" id="shape-popup-close">✕</span>
</div>
<div style="margin-bottom: 8px;">
<label style="font-size:0.72rem; color:#94a3b8; display:block; margin-bottom:4px;">Forme Base</label>
<div style="display:flex; gap:4px;">
<button class="base-shape-btn" data-type="rectangle" style="flex:1; padding:6px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.76rem;">🟦 Rettangolo</button>
<button class="base-shape-btn" data-type="circle" style="flex:1; padding:6px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.76rem;">⚪ Cerchio</button>
</div>
</div>
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 6px;">
<label style="font-size:0.72rem; color:#94a3b8; display:block; margin-bottom:4px;">Forme Salvate in SQLite</label>
${allShapes.length > 0 ? `
<div style="display:flex; flex-direction:column; gap:4px;">
${allShapes.map(sh => `
<button class="preset-shape-btn" data-id="${sh.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<span style="display:inline-block; width:16px; height:12px; border-radius:${sh.shape_type === 'circle' ? '50%' : '2px'}; background:${sh.fill_color === 'transparent' ? 'transparent' : sh.fill_color}; border:${Math.min(sh.border_width, 3)}px solid ${sh.border_color}; box-sizing:border-box;"></span>
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(sh.name)}</span>
<span>${sh.is_favorite ? '⭐' : ''}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.72rem; color:#94a3b8; font-style:italic;">Nessuna forma salvata. Puoi crearne su 'Configurazioni Utente'.</div>
`}
</div>
`;
document.body.appendChild(shapePopup);
const closePopup = () => { if (shapePopup) shapePopup.remove(); };
shapePopup.querySelector('#shape-popup-close').onclick = closePopup;
shapePopup.querySelectorAll('.base-shape-btn').forEach(btn => {
btn.onclick = (eBtn) => {
eBtn.stopPropagation();
const typeSelect = document.getElementById('pdf-shape-type');
if (typeSelect) typeSelect.value = btn.dataset.type;
closePopup();
};
});
shapePopup.querySelectorAll('.preset-shape-btn').forEach(btn => {
btn.onclick = (eBtn) => {
eBtn.stopPropagation();
const sh = allShapes.find(s => s.id == btn.dataset.id);
if (sh) {
const typeSelect = document.getElementById('pdf-shape-type');
const fillPicker = document.getElementById('pdf-shape-fill-color');
const borderPicker = document.getElementById('pdf-shape-border-color');
const borderWidth = document.getElementById('pdf-shape-border-width');
if (typeSelect) typeSelect.value = sh.shape_type;
if (fillPicker && sh.fill_color !== 'transparent') fillPicker.value = sh.fill_color;
if (borderPicker) borderPicker.value = sh.border_color;
if (borderWidth) borderWidth.value = sh.border_width;
Toast.success(`Forma "${sh.name}" selezionata! Clicca sul PDF per posizionarla.`);
}
closePopup();
};
});
const closeOnOutsideClick = (evt) => {
if (!shapePopup.contains(evt.target) && evt.target !== toolShapeBtn) {
closePopup();
document.removeEventListener('mousedown', closeOnOutsideClick, true);
}
};
setTimeout(() => document.addEventListener('mousedown', closeOnOutsideClick, true), 50);
};
}
if (colorPicker) colorPicker.onchange = (e) => { currentColor = e.target.value; };
// Create page containers, base canvases, and annotation overlay canvases
for (let num = 1; num <= totalPages; num++) {
const pageContainer = document.createElement('div');
pageContainer.className = 'pdf-page-container';
pageContainer.dataset.pageNum = num;
pageContainer.style.cssText = 'position: relative; margin-bottom: 16px; display: flex; flex-direction: column; align-items: center; user-select: none;';
const baseCanvas = document.createElement('canvas');
baseCanvas.id = `pdf-canvas-page-${num}`;
baseCanvas.style.cssText = 'box-shadow: 0 4px 20px rgba(0,0,0,0.4); border-radius: 4px; background: #ffffff; display: block;';
const drawCanvas = document.createElement('canvas');
drawCanvas.id = `pdf-draw-canvas-page-${num}`;
drawCanvas.className = 'pdf-annotation-canvas';
drawCanvas.dataset.pageNum = num;
drawCanvas.style.cssText = 'position: absolute; top: 0; left: 50%; transform: translateX(-50%); border-radius: 4px; cursor: crosshair; z-index: 10; touch-action: none;';
pageContainer.appendChild(baseCanvas);
pageContainer.appendChild(drawCanvas);
wrapper.appendChild(pageContainer);
}
if (loadingEl) loadingEl.style.display = 'none';
// Render pages & bind drawing events on overlay canvases
for (let num = 1; num <= totalPages; num++) {
try {
const page = await pdfDoc.getPage(num);
const viewport = page.getViewport({ scale: 1.25 });
const baseCanvas = document.getElementById(`pdf-canvas-page-${num}`);
const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${num}`);
if (!baseCanvas || !drawCanvas) continue;
baseCanvas.height = viewport.height;
baseCanvas.width = viewport.width;
drawCanvas.height = viewport.height;
drawCanvas.width = viewport.width;
const baseCtx = baseCanvas.getContext('2d');
const renderContext = {
canvasContext: baseCtx,
viewport: viewport
};
await page.render(renderContext).promise;
// Setup Object-Based Interactive Annotation System for each PDF page
const drawCtx = drawCanvas.getContext('2d');
// Page elements array: [{ id, type: 'stroke'|'text'|'image', x, y, width, height, color, size, text, img, strokePoints }]
const pageObjects = [];
drawCanvas.pageObjects = pageObjects;
let selectedObject = null;
let isDraggingObj = false;
let isResizingObj = false;
let resizeHandleIndex = -1; // 0..7
let dragOffsetX = 0;
let dragOffsetY = 0;
let isDrawingStroke = false;
let currentStrokePoints = [];
let currentBrushType = 'pencil'; // 'pencil' | 'brush' | 'highlight'
let currentBrushSize = 3;
const redrawPageObjects = () => {
drawCtx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
pageObjects.forEach(obj => {
drawCtx.save();
if (obj.type === 'stroke') {
if (obj.points && obj.points.length > 0) {
drawCtx.beginPath();
drawCtx.moveTo(obj.points[0].x, obj.points[0].y);
obj.points.forEach(p => drawCtx.lineTo(p.x, p.y));
drawCtx.strokeStyle = obj.color;
drawCtx.lineWidth = obj.size;
if (obj.brushType === 'highlight') {
drawCtx.globalCompositeOperation = 'multiply';
drawCtx.globalAlpha = 0.55;
drawCtx.lineCap = 'square';
} else {
drawCtx.globalCompositeOperation = 'source-over';
drawCtx.globalAlpha = obj.opacity || 1.0;
drawCtx.lineCap = 'round';
}
drawCtx.lineJoin = 'round';
drawCtx.stroke();
}
} else if (obj.type === 'text') {
drawCtx.font = `bold ${obj.size || 16}px Inter, sans-serif`;
drawCtx.fillStyle = obj.color;
drawCtx.globalAlpha = 1.0;
drawCtx.fillText(obj.text, obj.x, obj.y + (obj.size || 16));
} else if (obj.type === 'image') {
drawCtx.globalAlpha = 1.0;
if (obj.img) {
drawCtx.drawImage(obj.img, obj.x, obj.y, obj.width, obj.height);
}
} else if (obj.type === 'shape') {
drawCtx.globalAlpha = 1.0;
drawCtx.beginPath();
if (obj.shapeType === 'circle') {
const rx = obj.width / 2;
const ry = obj.height / 2;
const cx = obj.x + rx;
const cy = obj.y + ry;
drawCtx.ellipse(cx, cy, Math.abs(rx), Math.abs(ry), 0, 0, Math.PI * 2);
} else {
drawCtx.rect(obj.x, obj.y, obj.width, obj.height);
}
if (obj.fillColor && obj.fillColor !== 'transparent') {
drawCtx.fillStyle = obj.fillColor;
drawCtx.fill();
}
if (obj.borderWidth > 0 && obj.borderColor) {
drawCtx.lineWidth = obj.borderWidth;
drawCtx.strokeStyle = obj.borderColor;
drawCtx.stroke();
}
}
drawCtx.restore();
// Draw selection box & resize handles if selected
if (selectedObject === obj) {
drawCtx.save();
drawCtx.strokeStyle = '#3b82f6';
drawCtx.lineWidth = 1.5;
drawCtx.setLineDash([4, 4]);
drawCtx.strokeRect(obj.x - 4, obj.y - 4, obj.width + 8, obj.height + 8);
drawCtx.setLineDash([]);
// Handles at corners and midpoints
const handles = getObjectHandles(obj);
handles.forEach(h => {
drawCtx.fillStyle = '#ffffff';
drawCtx.strokeStyle = '#2563eb';
drawCtx.lineWidth = 1.5;
drawCtx.beginPath();
drawCtx.arc(h.x, h.y, 4.5, 0, Math.PI * 2);
drawCtx.fill();
drawCtx.stroke();
});
drawCtx.restore();
}
});
};
const getObjectHandles = (obj) => {
const x = obj.x;
const y = obj.y;
const w = obj.width;
const h = obj.height;
return [
{ x: x, y: y }, // 0: Top-Left
{ x: x + w / 2, y: y }, // 1: Top-Center
{ x: x + w, y: y }, // 2: Top-Right
{ x: x + w, y: y + h / 2 }, // 3: Middle-Right
{ x: x + w, y: y + h }, // 4: Bottom-Right
{ x: x + w / 2, y: y + h }, // 5: Bottom-Center
{ x: x, y: y + h }, // 6: Bottom-Left
{ x: x, y: y + h / 2 } // 7: Middle-Left
];
};
const hitTestObject = (x, y) => {
for (let i = pageObjects.length - 1; i >= 0; i--) {
const obj = pageObjects[i];
if (x >= obj.x - 6 && x <= obj.x + obj.width + 6 && y >= obj.y - 6 && y <= obj.y + obj.height + 6) {
return obj;
}
}
return null;
};
const hitTestHandle = (obj, x, y) => {
if (!obj) return -1;
const handles = getObjectHandles(obj);
for (let i = 0; i < handles.length; i++) {
const h = handles[i];
const dist = Math.hypot(x - h.x, y - h.y);
if (dist <= 7) return i;
}
return -1;
};
// Delete selected object on Delete or Backspace key
const deleteSelectedObject = () => {
if (selectedObject) {
const idx = pageObjects.indexOf(selectedObject);
if (idx !== -1) {
pageObjects.splice(idx, 1);
selectedObject = null;
redrawPageObjects();
Toast.info('Elemento rimosso');
}
}
};
document.addEventListener('keydown', (e) => {
if (selectedObject && (e.key === 'Delete' || e.key === 'Backspace')) {
if (document.activeElement && ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
e.preventDefault();
deleteSelectedObject();
}
});
// Ctrl + Right Click Context Menu Handler
drawCanvas.oncontextmenu = async (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Fetch signatures and shapes from SQLite
let signatures = [];
let favoriteShapes = [];
try {
const [sigRes, shapeRes] = await Promise.all([
App.api('/api/config/user-signatures'),
App.api('/api/config/user-shapes')
]);
signatures = sigRes || [];
favoriteShapes = (shapeRes || []).filter(s => s.is_favorite);
} catch (_) {}
let menuOverlay = document.getElementById('pdf-context-menu-popup');
if (menuOverlay) menuOverlay.remove();
menuOverlay = document.createElement('div');
menuOverlay.id = 'pdf-context-menu-popup';
menuOverlay.style.cssText = `
position: fixed;
left: ${e.clientX}px;
top: ${e.clientY}px;
z-index: 20000;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
color: #ffffff;
font-size: 0.8rem;
min-width: 260px;
user-select: none;
`;
menuOverlay.innerHTML = `
<div style="font-weight: 700; margin-bottom: 8px; color: var(--accent-primary); border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between;">
<span>⚙️ Strumenti Rapidi PDF</span>
<span style="cursor:pointer; opacity:0.7;" id="ctx-close">✕</span>
</div>
<div style="margin-bottom: 10px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:4px;">Tipo di Strumento</label>
<div style="display:flex; gap:4px; flex-wrap:wrap;">
<button class="ctx-btn ${currentTool === 'draw' && currentBrushType === 'pencil' ? 'active' : ''}" data-type="pencil" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'draw' && currentBrushType === 'pencil' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">✏️ Matita</button>
<button class="ctx-btn ${currentTool === 'draw' && currentBrushType === 'brush' ? 'active' : ''}" data-type="brush" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'draw' && currentBrushType === 'brush' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">🖌️ Pennello</button>
<button class="ctx-btn ${currentTool === 'highlight' ? 'active' : ''}" data-type="highlight" style="flex:1; min-width:65px; padding:4px; font-size:0.75rem; background:${currentTool === 'highlight' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">🖍️ Evidenzia</button>
<button class="ctx-btn ${currentTool === 'shape' ? 'active' : ''}" data-type="shape" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'shape' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">📐 Forme</button>
</div>
</div>
<div style="margin-bottom: 10px; display:flex; gap:8px; align-items:center;">
<div style="flex:1;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:2px;">Dimensione tratto</label>
<input type="range" id="ctx-brush-size" min="1" max="40" value="${currentBrushSize}" style="width:100%; cursor:pointer;" />
</div>
<div>
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:2px;">Colore</label>
<input type="color" id="ctx-color" value="${currentColor}" style="width:26px; height:26px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</div>
</div>
<!-- Favorite Shapes Section -->
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px; margin-bottom: 8px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:6px;">📐 Forme Preferite Salvate</label>
${favoriteShapes.length > 0 ? `
<div style="max-height: 100px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;">
${favoriteShapes.map(sh => `
<button class="ctx-shape-btn" data-id="${sh.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<span style="display:inline-block; width:16px; height:12px; border-radius:${sh.shape_type === 'circle' ? '50%' : '2px'}; background:${sh.fill_color === 'transparent' ? 'transparent' : sh.fill_color}; border:${Math.min(sh.border_width, 3)}px solid ${sh.border_color}; box-sizing:border-box;"></span>
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(sh.name)}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic;">Nessuna forma preferita salvata.</div>
`}
</div>
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:6px;">✍️ Inserisci Firma / Immagine Utente</label>
${signatures.length > 0 ? `
<div style="max-height: 120px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;">
${signatures.map(s => `
<button class="ctx-sig-btn" data-id="${s.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<img src="${s.image_data}" style="width:28px; height:20px; object-fit:contain; background:#fff; border-radius:2px; padding:1px;" />
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(s.name)}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic;">Nessuna firma salvata in SQLite.</div>
`}
</div>
`;
document.body.appendChild(menuOverlay);
// Menu Event Listeners
const closeCtx = () => { if (menuOverlay) menuOverlay.remove(); };
menuOverlay.querySelector('#ctx-close').onclick = closeCtx;
menuOverlay.querySelectorAll('.ctx-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const type = btn.dataset.type;
menuOverlay.querySelectorAll('.ctx-btn').forEach(b => {
b.style.background = 'rgba(255,255,255,0.1)';
b.classList.remove('active');
});
btn.style.background = 'var(--accent-primary)';
btn.classList.add('active');
if (type === 'pencil') {
currentBrushType = 'pencil';
currentBrushSize = 3;
setActiveTool('draw', toolDrawBtn);
} else if (type === 'brush') {
currentBrushType = 'brush';
currentBrushSize = 8;
setActiveTool('draw', toolDrawBtn);
} else if (type === 'highlight') {
currentBrushType = 'highlight';
currentBrushSize = 20;
setActiveTool('highlight', toolHighlightBtn);
} else if (type === 'shape') {
setActiveTool('shape', toolShapeBtn);
}
if (sizeInput) sizeInput.value = currentBrushSize;
};
});
const sizeInput = menuOverlay.querySelector('#ctx-brush-size');
if (sizeInput) {
sizeInput.oninput = (eVal) => {
eVal.stopPropagation();
currentBrushSize = parseInt(eVal.target.value, 10);
};
}
const ctxColorPicker = menuOverlay.querySelector('#ctx-color');
if (ctxColorPicker) {
ctxColorPicker.onchange = (eVal) => {
eVal.stopPropagation();
currentColor = eVal.target.value;
if (colorPicker) colorPicker.value = currentColor;
};
}
menuOverlay.querySelectorAll('.ctx-shape-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const shId = btn.dataset.id;
const sh = favoriteShapes.find(s => s.id == shId);
if (sh) {
const defaultW = 130;
const defaultH = 85;
const newObj = {
id: Date.now(),
type: 'shape',
shapeType: sh.shape_type,
x: clickX - defaultW / 2,
y: clickY - defaultH / 2,
width: defaultW,
height: defaultH,
fillColor: sh.fill_color,
borderColor: sh.border_color,
borderWidth: sh.border_width
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
Toast.success(`Forma "${sh.name}" inserita!`);
}
closeCtx();
};
});
menuOverlay.querySelectorAll('.ctx-sig-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const sigId = btn.dataset.id;
const sig = signatures.find(s => s.id == sigId);
if (sig) {
const img = new Image();
img.onload = () => {
const targetWidth = Math.min(220, img.width || 180);
const targetHeight = (img.height / img.width) * targetWidth || 80;
const newObj = {
id: Date.now(),
type: 'image',
img: img,
x: clickX - targetWidth / 2,
y: clickY - targetHeight / 2,
width: targetWidth,
height: targetHeight
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
Toast.success(`Firma "${sig.name}" aggiunta al PDF! Usa 'Seleziona' per trascinare o ridimensionare.`);
};
img.src = sig.image_data;
}
closeCtx();
};
});
const closeOnOutside = (evt) => {
if (!menuOverlay.contains(evt.target)) {
closeCtx();
document.removeEventListener('mousedown', closeOnOutside, true);
}
};
setTimeout(() => document.addEventListener('mousedown', closeOnOutside, true), 50);
}
};
// Tool Select Button Handler
const toolSelectBtn = document.getElementById('pdf-tool-select');
if (toolSelectBtn) {
toolSelectBtn.onclick = (e) => {
e.stopPropagation();
setActiveTool('select', toolSelectBtn);
drawCanvas.style.cursor = 'default';
};
}
drawCanvas.onmousedown = (e) => {
if (e.button !== 0) return; // Only primary click
e.preventDefault();
e.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 1. Check if clicking on resize handles of currently selected object (any tool mode)
if (selectedObject) {
const hIdx = hitTestHandle(selectedObject, x, y);
if (hIdx !== -1) {
isResizingObj = true;
resizeHandleIndex = hIdx;
return;
}
}
// 2. Check if clicking directly on an existing object border/body (any tool mode)
const hitObj = hitTestObject(x, y);
if (hitObj) {
selectedObject = hitObj;
isDraggingObj = true;
dragOffsetX = x - hitObj.x;
dragOffsetY = y - hitObj.y;
redrawPageObjects();
return;
}
// If clicked empty space, deselect current object
if (selectedObject) {
selectedObject = null;
redrawPageObjects();
}
if (currentTool === 'select') {
return;
}
const openInlineTextEditor = (clientX, clientY, initialX, initialY, existingTextObj = null) => {
let inlineInput = document.getElementById('pdf-inline-text-input');
if (inlineInput) inlineInput.remove();
const initialText = existingTextObj ? existingTextObj.text : '';
const textColor = existingTextObj ? existingTextObj.color : currentColor;
const textSize = existingTextObj ? existingTextObj.size : (currentBrushSize > 8 ? currentBrushSize : 16);
inlineInput = document.createElement('input');
inlineInput.id = 'pdf-inline-text-input';
inlineInput.type = 'text';
inlineInput.value = initialText;
inlineInput.placeholder = 'Scrivi testo...';
inlineInput.style.cssText = `
position: absolute;
left: ${clientX}px;
top: ${clientY}px;
z-index: 20000;
background: var(--bg-card);
color: ${textColor};
border: 2px solid var(--accent-primary);
border-radius: 4px;
padding: 2px 6px;
font-family: Inter, sans-serif;
font-weight: 700;
font-size: ${textSize}px;
box-shadow: 0 4px 14px rgba(0,0,0,0.5);
outline: none;
min-width: 140px;
`;
document.body.appendChild(inlineInput);
inlineInput.focus();
if (existingTextObj) inlineInput.select();
let isCommitted = false;
const commitInlineText = () => {
if (isCommitted) return;
isCommitted = true;
const text = inlineInput ? inlineInput.value.trim() : '';
if (inlineInput) inlineInput.remove();
if (existingTextObj) {
if (text) {
existingTextObj.text = text;
drawCtx.font = `bold ${existingTextObj.size}px Inter, sans-serif`;
existingTextObj.width = drawCtx.measureText(text).width;
existingTextObj.height = existingTextObj.size * 1.2;
selectedObject = existingTextObj;
} else {
// Remove if empty
const idx = pageObjects.indexOf(existingTextObj);
if (idx !== -1) pageObjects.splice(idx, 1);
selectedObject = null;
}
redrawPageObjects();
} else if (text) {
drawCtx.font = `bold ${textSize}px Inter, sans-serif`;
const textWidth = drawCtx.measureText(text).width;
const newObj = {
id: Date.now(),
type: 'text',
text: text,
x: initialX,
y: initialY,
width: textWidth,
height: textSize * 1.2,
color: currentColor,
size: textSize
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
}
};
inlineInput.onkeydown = (eInput) => {
if (eInput.key === 'Enter') {
eInput.preventDefault();
commitInlineText();
} else if (eInput.key === 'Escape') {
eInput.preventDefault();
isCommitted = true;
if (inlineInput) inlineInput.remove();
redrawPageObjects();
}
};
inlineInput.onblur = () => {
setTimeout(commitInlineText, 100);
};
};
// Bind double click to re-edit existing text objects
drawCanvas.ondblclick = (eDbl) => {
eDbl.preventDefault();
eDbl.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
const dblX = eDbl.clientX - rect.left;
const dblY = eDbl.clientY - rect.top;
const hitObj = hitTestObject(dblX, dblY);
if (hitObj && hitObj.type === 'text') {
openInlineTextEditor(eDbl.clientX, eDbl.clientY, dblX, dblY, hitObj);
}
};
if (currentTool === 'text') {
openInlineTextEditor(e.clientX, e.clientY, x, y);
return;
}
if (currentTool === 'shape') {
const shapeTypeEl = document.getElementById('pdf-shape-type');
const fillColorEl = document.getElementById('pdf-shape-fill-color');
const borderColorEl = document.getElementById('pdf-shape-border-color');
const borderWidthEl = document.getElementById('pdf-shape-border-width');
const shapeType = shapeTypeEl ? shapeTypeEl.value : 'rectangle';
const fillColor = fillColorEl ? fillColorEl.value : '#3b82f6';
const borderColor = borderColorEl ? borderColorEl.value : '#1e3a8a';
const borderWidth = borderWidthEl ? parseInt(borderWidthEl.value, 10) : 3;
const defaultW = 120;
const defaultH = 80;
const newShapeObj = {
id: Date.now(),
type: 'shape',
shapeType: shapeType,
x: x - defaultW / 2,
y: y - defaultH / 2,
width: defaultW,
height: defaultH,
fillColor: fillColor,
borderColor: borderColor,
borderWidth: borderWidth
};
pageObjects.push(newShapeObj);
selectedObject = newShapeObj;
redrawPageObjects();
return;
}
// Drawing mode (pencil / brush / highlight)
isDrawingStroke = true;
currentStrokePoints = [{ x, y }];
const opacity = (currentTool === 'highlight' || currentBrushType === 'highlight') ? 0.15 : 1.0;
const size = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3));
const newStrokeObj = {
id: Date.now(),
type: 'stroke',
points: currentStrokePoints,
x: x,
y: y,
width: size,
height: size,
color: currentColor,
size: size,
opacity: opacity,
brushType: currentBrushType
};
pageObjects.push(newStrokeObj);
// Keep selectedObject null during active drawing so no blue bounding box displays while drawing
selectedObject = null;
};
drawCanvas.onmousemove = (e) => {
const rect = drawCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (isResizingObj && selectedObject) {
e.preventDefault();
const obj = selectedObject;
const oldX = obj.x;
const oldY = obj.y;
const oldW = obj.width || 1;
const oldH = obj.height || 1;
if (resizeHandleIndex === 0) { // Top-Left
const oldRight = obj.x + obj.width;
const oldBottom = obj.y + obj.height;
obj.x = Math.min(x, oldRight - 10);
obj.y = Math.min(y, oldBottom - 10);
obj.width = oldRight - obj.x;
obj.height = oldBottom - obj.y;
} else if (resizeHandleIndex === 2) { // Top-Right
const oldLeft = obj.x;
const oldBottom = obj.y + obj.height;
obj.y = Math.min(y, oldBottom - 10);
obj.width = Math.max(10, x - oldLeft);
obj.height = oldBottom - obj.y;
} else if (resizeHandleIndex === 4) { // Bottom-Right
obj.width = Math.max(10, x - obj.x);
obj.height = Math.max(10, y - obj.y);
} else if (resizeHandleIndex === 6) { // Bottom-Left
const oldRight = obj.x + obj.width;
obj.x = Math.min(x, oldRight - 10);
obj.width = oldRight - obj.x;
obj.height = Math.max(10, y - obj.y);
} else if (resizeHandleIndex === 1) { // Top-Center
const oldBottom = obj.y + obj.height;
obj.y = Math.min(y, oldBottom - 10);
obj.height = oldBottom - obj.y;
} else if (resizeHandleIndex === 3) { // Middle-Right
obj.width = Math.max(10, x - obj.x);
} else if (resizeHandleIndex === 5) { // Bottom-Center
obj.height = Math.max(10, y - obj.y);
} else if (resizeHandleIndex === 7) { // Middle-Left
const oldRight = obj.x + obj.width;
obj.x = Math.min(x, oldRight - 10);
obj.width = oldRight - obj.x;
}
if (obj.type === 'stroke' && obj.points) {
const scaleX = obj.width / oldW;
const scaleY = obj.height / oldH;
obj.points.forEach(p => {
p.x = obj.x + (p.x - oldX) * scaleX;
p.y = obj.y + (p.y - oldY) * scaleY;
});
} else if (obj.type === 'text') {
const scaleY = obj.height / oldH;
obj.size = Math.max(10, Math.round((obj.size || 16) * scaleY));
drawCtx.font = `bold ${obj.size}px Inter, sans-serif`;
obj.width = drawCtx.measureText(obj.text).width;
obj.height = obj.size * 1.2;
}
redrawPageObjects();
return;
}
if (isDraggingObj && selectedObject) {
e.preventDefault();
const newX = x - dragOffsetX;
const newY = y - dragOffsetY;
const dx = newX - selectedObject.x;
const dy = newY - selectedObject.y;
selectedObject.x = newX;
selectedObject.y = newY;
if (selectedObject.type === 'stroke' && selectedObject.points) {
selectedObject.points.forEach(p => {
p.x += dx;
p.y += dy;
});
}
redrawPageObjects();
return;
}
// Update cursor on hover over object or handle
if (selectedObject) {
const hIdx = hitTestHandle(selectedObject, x, y);
if (hIdx !== -1) {
drawCanvas.style.cursor = 'nwse-resize';
return;
}
}
const hoverObj = hitTestObject(x, y);
drawCanvas.style.cursor = hoverObj ? 'move' : (currentTool === 'select' ? 'default' : 'crosshair');
if (!isDrawingStroke) return;
e.preventDefault();
e.stopPropagation();
currentStrokePoints.push({ x, y });
// Update stroke bounding box
if (isDrawingStroke && pageObjects.length > 0) {
const activeStrokeObj = pageObjects[pageObjects.length - 1];
if (activeStrokeObj && activeStrokeObj.type === 'stroke') {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
currentStrokePoints.forEach(p => {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
});
activeStrokeObj.x = minX;
activeStrokeObj.y = minY;
activeStrokeObj.width = maxX - minX;
activeStrokeObj.height = maxY - minY;
}
}
redrawPageObjects();
};
const stopDrawing = (e) => {
if (isDrawingStroke && pageObjects.length > 0) {
const activeObj = pageObjects[pageObjects.length - 1];
if (activeObj && activeObj.type === 'stroke') {
if (!activeObj.points || activeObj.points.length <= 1) {
// Single click without dragging: discard stroke
pageObjects.pop();
redrawPageObjects();
}
}
}
isDrawingStroke = false;
isDraggingObj = false;
isResizingObj = false;
resizeHandleIndex = -1;
};
drawCanvas.onmouseup = stopDrawing;
drawCanvas.onmouseleave = stopDrawing;
} catch (pErr) {
console.warn(`Failed rendering PDF page ${num}`, pErr);
}
}
// Clear button handler for current page
if (toolClearBtn) {
toolClearBtn.onclick = (e) => {
e.stopPropagation();
const pageContainers = wrapper.querySelectorAll('.pdf-page-container');
let activePageNum = 1;
const wrapperTop = wrapper.scrollTop;
pageContainers.forEach((container) => {
if (container.offsetTop - wrapper.offsetTop <= wrapperTop + 150) {
activePageNum = parseInt(container.dataset.pageNum, 10);
}
});
const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${activePageNum}`);
if (drawCanvas) {
if (drawCanvas.pageObjects) {
drawCanvas.pageObjects.length = 0;
}
const ctx = drawCanvas.getContext('2d');
ctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
Toast.info(`Pagina ${activePageNum} pulita.`);
}
};
}
// Save Edited PDF using pdf-lib and html canvas flattening
if (saveEditedBtn) {
saveEditedBtn.onclick = (e) => {
e.stopPropagation();
savePdfDocument();
};
}
// Scroll listener to update page counter in toolbar
wrapper.addEventListener('scroll', () => {
const pageContainers = wrapper.querySelectorAll('.pdf-page-container');
let currentVisiblePage = 1;
const wrapperTop = wrapper.scrollTop;
pageContainers.forEach((container) => {
if (container.offsetTop - wrapper.offsetTop <= wrapperTop + 150) {
currentVisiblePage = parseInt(container.dataset.pageNum, 10);
}
});
if (pageNumEl) {
pageNumEl.textContent = `Pagina ${currentVisiblePage} / ${totalPages}`;
}
});
} catch (err) {
console.error('Failed to load PDF via PDF.js', err);
const loadingEl = document.getElementById('pdf-loading');
if (loadingEl) {
loadingEl.innerHTML = `<span style="color:var(--danger);">Errore caricamento PDF: ${App.escapeHtml(err.message)}</span>`;
}
}
},
showPdfTextModal(onSubmitCallback) {
let modal = document.getElementById('pdf-text-modal-overlay');
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = 'pdf-text-modal-overlay';
modal.style.cssText = `
position: fixed;
inset: 0;
z-index: 25000;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
backdrop-filter: blur(2px);
`;
modal.innerHTML = `
<div style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 20px; width: 380px; max-width: 90vw; box-shadow: var(--shadow-lg); color: var(--text-primary);">
<div style="font-weight: 700; font-size: 1rem; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between;">
<span>🔤 Inserisci Testo sul PDF</span>
<button id="pdf-modal-close-btn" style="background: none; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer;">✕</button>
</div>
<div style="margin-bottom: 16px;">
<input type="text" id="pdf-modal-text-input" class="form-input" placeholder="Scrivi il testo da aggiungere..." style="width: 100%; height: 38px; font-size: 0.9rem;" value="Nota" />
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px;">
<button id="pdf-modal-cancel-btn" class="btn btn-ghost btn-sm">Annulla</button>
<button id="pdf-modal-confirm-btn" class="btn btn-primary btn-sm">Inserisci Testo</button>
</div>
</div>
`;
document.body.appendChild(modal);
const inputEl = modal.querySelector('#pdf-modal-text-input');
const confirmBtn = modal.querySelector('#pdf-modal-confirm-btn');
const cancelBtn = modal.querySelector('#pdf-modal-cancel-btn');
const closeBtn = modal.querySelector('#pdf-modal-close-btn');
if (inputEl) {
inputEl.focus();
inputEl.select();
}
const closeModal = () => {
if (modal) modal.remove();
};
const submitText = () => {
const val = inputEl ? inputEl.value : '';
closeModal();
if (onSubmitCallback) onSubmitCallback(val);
};
confirmBtn.onclick = submitText;
cancelBtn.onclick = closeModal;
closeBtn.onclick = closeModal;
inputEl.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitText();
} else if (e.key === 'Escape') {
e.preventDefault();
closeModal();
}
};
modal.onclick = (e) => {
if (e.target === modal) closeModal();
};
},
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}`);
// Route guard: verify user is still on this ticket's detail page
const currentHash = (window.location.hash || '').split('?')[0];
if (currentHash !== `#/tickets/${id}`) {
return;
}
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" style="display:flex; justify-content:space-between; align-items:flex-start; gap:16px; flex-wrap:wrap;">
<div class="ticket-header-info" style="flex:1; min-width:280px;">
<div class="ticket-number" style="display:inline-flex; align-items:center; gap:6px;">
<span class="copy-ticket-btn" data-tn="${ticket.tn}" style="cursor: pointer; font-size: 0.85rem;" title="Copia numero ticket">📋</span>
#${ticket.tn}
-
</div>
<h2 class="ticket-detail-title" style="display:inline-flex; align-items:center; gap:6px; flex-wrap:wrap;">
<span class="copy-ticket-btn" data-tn="${App.escapeHtml(ticket.title || '')}" style="cursor: pointer; font-size: 0.85rem;" title="Copia titolo ticket">📋</span>
${App.escapeHtml(ticket.title || '(senza titolo)')}
<button class="open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.openTab(${ticket.id}, '${ticket.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
</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>
<!-- Gruppi Ticket sul lato destro dell'intestazione in due righe -->
<div id="ticket-groups-panel" style="display:flex; flex-direction:column; align-items:flex-end; gap:6px; font-size:0.8rem;">
<div id="ticket-groups-list" style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; justify-content:flex-end;">
${groupsData.asMaster.length === 0 && groupsData.asMember.length === 0 ? `
<span style="font-size:0.78rem; color:var(--text-muted);">Nessun gruppo associato</span>
` : ''}
${groupsData.asMaster.map(g => `
<span style="font-size:0.8rem;">
<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>
</span>
`).join('')}
${groupsData.asMember.map(g => `
<span style="font-size:0.8rem;">
<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>
</span>
`).join('')}
</div>
<div style="display:flex; align-items:center; gap:4px; margin-top:2px;">
<select class="form-select" id="group-association-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0;">
<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>
<!-- 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; min-width: 500px; width: max-content; max-width: 85vw; 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 full-width">
<label class="quick-edit-label">Oggetto Nota (opzionale)</label>
<input type="text" class="form-input quick-edit-input" id="qe-note-subject" placeholder="Oggetto della nota..." style="padding: 6px 12px; height: 32px; font-size: 0.85rem;" />
</div>
<div class="quick-edit-field full-width">
<label class="quick-edit-label">Nota / Commento</label>
<textarea class="quick-edit-textarea" id="qe-note" placeholder="Inserisci una nota..."></textarea>
</div>
<div style="margin-top:var(--space-md);display:flex;gap:var(--space-sm);align-items:center;justify-content:flex-end;grid-column:1 / -1;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:var(--space-xs);margin-right:auto;">
<label for="qe-time-unit" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary);white-space:nowrap;">Tempo (minuti):</label>
<input type="number" step="any" min="0" class="form-input" id="qe-time-unit" placeholder="minuti" style="padding:4px 8px; height:32px; font-size:0.85rem; width:110px;" />
</div>
<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 style="display:flex; justify-content:space-between; align-items:center; margin-bottom: var(--space-md); flex-wrap:wrap; gap:var(--space-sm);">
<div class="card-title" style="margin-bottom:0;">Aggiungi Nota</div>
<div style="display:flex; align-items:center; gap:var(--space-xs);">
<label for="note-ticket-state" style="font-size:0.85rem; font-weight:600; color:var(--text-secondary);">Stato ticket:</label>
<select id="note-ticket-state" class="quick-edit-select" style="height:32px; padding:4px 8px; font-size:0.85rem; margin-bottom:0; width:auto;">
${(App.lookups.states || []).map(s =>
`<option value="${s.id}" ${s.id === ticket.ticket_state_id ? 'selected' : ''}>${App.escapeHtml(s.name)}</option>`
).join('')}
</select>
</div>
</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="datetime-local" class="note-subject-input" id="note-create-time" title="Data/ora nota (opzionale per retrodatazione)" style="width:185px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.82rem;" />
<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.has_html || (a.a_content_type || '').toLowerCase().includes('html') || /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(a.html_body || a.a_body || '');
let processedBody = (hasHtml && a.html_body) ? a.html_body : (a.a_body || '');
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
const usedAttachmentIds = new Set();
if (hasHtml && this.htmlMode) {
articleAttachments.forEach(att => {
let replaced = false;
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');
if (regex.test(processedBody)) {
processedBody = processedBody.replace(regex, `/api/tickets/attachments/${att.id}`);
replaced = true;
}
}
}
if (att.filename) {
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
if (regexFilename.test(processedBody)) {
processedBody = processedBody.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
replaced = true;
}
const regexSrc = new RegExp(`src=["']${escapedFilename}["']`, 'gi');
if (regexSrc.test(processedBody)) {
processedBody = processedBody.replace(regexSrc, `src="/api/tickets/attachments/${att.id}"`);
replaced = true;
}
const nameWithoutExt = att.filename.replace(/\.[^/.]+$/, '');
if (nameWithoutExt && nameWithoutExt.length > 3) {
const escapedBase = nameWithoutExt.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexBase = new RegExp(`cid:<?${escapedBase}[^"'>\\s]*>?`, 'gi');
if (regexBase.test(processedBody)) {
processedBody = processedBody.replace(regexBase, `/api/tickets/attachments/${att.id}`);
replaced = true;
}
}
}
if (att.disposition === 'inline' || replaced) {
usedAttachmentIds.add(att.id);
}
});
if (!processedBody.includes('<base')) {
if (processedBody.includes('<head>')) {
processedBody = processedBody.replace('<head>', '<head><base target="_blank">');
} else {
processedBody = `<base target="_blank">${processedBody}`;
}
}
}
const displayBody = (this.htmlMode && hasHtml)
? `<iframe id="article-iframe-${a.article_id}" srcdoc="${processedBody.replace(/"/g, '&quot;')}"
onload="try{this.style.height=(this.contentWindow.document.body.scrollHeight+30)+'px';}catch(e){}"
style="width:100%; border:none; background:#ffffff; color:#222222; border-radius:var(--radius-md); min-height:160px; padding:0; display:block;">
</iframe>`
: `<div class="article-body" style="white-space:pre-wrap; font-family:inherit;">${App.linkifyText(App.escapeHtml(a.a_body || ''))}</div>`;
const visibleAttachments = articleAttachments.filter(att => {
const fname = (att.filename || '').toLowerCase();
if (fname.startsWith('file-1') || fname.startsWith('file-2') || fname.startsWith('file-3')) {
return false;
}
if (usedAttachmentIds.has(att.id)) {
return false;
}
return true;
});
const extractedLinks = this.extractLinks(processedBody || a.a_body || '');
return `
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}" style="margin-bottom: var(--space-md);">
<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:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:3px 6px; margin-left:var(--space-xs); display:inline-flex; align-items:center; justify-content:center; color:var(--danger); transition:all 0.15s ease; opacity:1;" title="Elimina Articolo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
<!-- Pulsante Info (i) a destra del cestino con popover al passaggio del mouse -->
<div class="article-info-popover-wrapper" style="position: relative; display: inline-flex; margin-left: var(--space-xs); padding: 15px 60px 45px 20px; margin-top: -15px; margin-bottom: -45px; margin-right: -60px; margin-left: -20px;">
<button class="btn-article-info" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:3px 6px; display:inline-flex; align-items:center; justify-content:center; color:var(--accent-primary); transition:all 0.15s ease; font-weight:700; font-size:0.75rem; width:22px; height:22px; position:relative; z-index:2;" title="Dettagli e Persone Email">
ℹ️
</button>
<div class="article-info-popover" style="position: absolute; top: calc(100% - 25px); left: 10px; width: 340px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px; font-size: 0.78rem; box-shadow: var(--shadow-lg); z-index: 120; display: none; text-align: left;">
<div style="font-weight: 700; color: var(--text-secondary); margin-bottom: 8px; border-bottom: 1px solid var(--border-subtle); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between; font-size: 0.8rem;">
<span style="display: flex; align-items: center; gap: 4px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;color:var(--accent-primary);"><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>
Dettagli Email #${a.article_id}
</span>
</div>
<div style="margin-bottom: 6px; word-break: break-all; line-height: 1.35; display: flex; align-items: flex-start; justify-content: space-between; gap: 6px;">
<div>
<strong style="color: var(--text-muted);">Da:</strong>
<span style="color: var(--text-primary); font-family: inherit;">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || '—')}</span>
</div>
${(a.a_from) ? `
<button class="btn-copy-email-addr" data-copy-text="${App.escapeHtml(a.a_from)}" title="Copia Da" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:2px 5px; font-size:0.7rem; flex-shrink:0; color:var(--text-secondary);">📋 Copia</button>
` : ''}
</div>
${a.a_to ? `
<div style="margin-bottom: 6px; word-break: break-all; line-height: 1.35; display: flex; align-items: flex-start; justify-content: space-between; gap: 6px;">
<div>
<strong style="color: var(--text-muted);">A:</strong>
<span style="color: var(--text-primary); font-family: inherit;">${App.escapeHtml(a.a_to)}</span>
</div>
<button class="btn-copy-email-addr" data-copy-text="${App.escapeHtml(a.a_to)}" title="Copia A" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:2px 5px; font-size:0.7rem; flex-shrink:0; color:var(--text-secondary);">📋 Copia</button>
</div>
` : ''}
${a.a_cc ? `
<div style="margin-bottom: 6px; word-break: break-all; line-height: 1.35; display: flex; align-items: flex-start; justify-content: space-between; gap: 6px;">
<div>
<strong style="color: var(--text-muted);">Cc:</strong>
<span style="color: var(--text-primary); font-family: inherit;">${App.escapeHtml(a.a_cc)}</span>
</div>
<button class="btn-copy-email-addr" data-copy-text="${App.escapeHtml(a.a_cc)}" title="Copia Cc" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:2px 5px; font-size:0.7rem; flex-shrink:0; color:var(--text-secondary);">📋 Copia</button>
</div>
` : ''}
<div style="font-weight: 700; color: var(--text-secondary); margin-top: 10px; margin-bottom: 6px; border-bottom: 1px solid var(--border-subtle); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between; font-size: 0.78rem;">
<span style="display: flex; align-items: center; gap: 4px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;color:var(--accent-primary);"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Link contenuti
</span>
<span class="badge" style="font-size: 0.7rem; padding: 1px 6px; background: var(--bg-tertiary);">${extractedLinks.length}</span>
</div>
${extractedLinks.length > 0 ? `
<div class="article-links-minilist" style="display: flex; flex-direction: column; gap: 6px; max-height: 180px; overflow-y: auto; padding-right: 2px;">
${extractedLinks.map(link => `
<a href="${App.escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" title="${App.escapeHtml(link.url)}" style="color: var(--accent-primary); text-decoration: none; word-break: break-all; font-size: 0.75rem; display: flex; align-items: flex-start; gap: 4px; line-height: 1.3; background: var(--bg-tertiary); padding: 4px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);">
<span style="flex-shrink:0; font-size: 0.7rem;">🌐</span>
<span style="overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-word;">${App.escapeHtml(link.text || link.url)}</span>
</a>
`).join('')}
</div>
` : `
<div style="color: var(--text-muted); font-size: 0.75rem; font-style: italic;">Nessun link presente</div>
`}
</div>
</div>
<button class="btn-email-article" data-article-id="${a.article_id}" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:3px 6px; margin-left:var(--space-xs); display:inline-flex; align-items:center; justify-content:center; color:var(--accent-secondary); transition:all 0.15s ease; opacity:1;" title="Rispondi via email (Quota questo articolo)">
<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>
</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" style="font-weight:700; font-size:0.95rem; color:var(--text-primary); margin-bottom:var(--space-xs);">${App.escapeHtml(a.a_subject)}</div>` : ''}
<div class="article-body-main" style="width: 100%;">
${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>
</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>
` : ''}
</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 || '';
}
if (document.getElementById('note-ticket-state') && noteDraft.ticket_state_id) {
document.getElementById('note-ticket-state').value = noteDraft.ticket_state_id;
}
this.noteAttachments = noteDraft.attachments || [];
this.updateNoteAttachmentList();
}
this.bindEvents(ticket, articles, container, groupsData, attachments);
} 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, attachments) {
// 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 noteSubjectInput = document.getElementById('qe-note-subject');
const noteInput = document.getElementById('qe-note');
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;
}
const noteSubText = noteSubjectInput ? noteSubjectInput.value.trim() : '';
if (noteSubjectInput) {
noteSubjectInput.classList.toggle('changed', noteSubText.length > 0);
}
if (noteSubText.length > 0) {
hasChanges = true;
}
const noteText = noteInput ? noteInput.value.trim() : '';
if (noteInput) {
noteInput.classList.toggle('changed', noteText.length > 0);
}
if (noteText.length > 0) {
hasChanges = true;
}
saveBtn.disabled = !hasChanges;
};
fields.forEach(el => {
el.addEventListener('change', checkChanges);
});
if (timeUnitInput) {
timeUnitInput.addEventListener('input', checkChanges);
timeUnitInput.addEventListener('change', checkChanges);
}
if (noteSubjectInput) {
noteSubjectInput.addEventListener('input', checkChanges);
noteSubjectInput.addEventListener('change', checkChanges);
}
if (noteInput) {
noteInput.addEventListener('input', checkChanges);
noteInput.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);
});
App.setupAutocompleteKeyboardNavigation(queueSearchInput, queueSuggestionsDiv, (item) => {
if (item && item.dataset.id) {
queueSearchInput.value = item.dataset.name;
queueIdInput.value = item.dataset.id;
queueSuggestionsDiv.style.display = 'none';
queueIdInput.dispatchEvent(new Event('change'));
}
});
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 = '';
}
if (noteSubjectInput) {
noteSubjectInput.value = '';
noteSubjectInput.classList.remove('changed');
}
if (noteInput) {
noteInput.value = '';
noteInput.classList.remove('changed');
}
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 (noteSubjectInput && noteSubjectInput.value.trim()) {
updates.note_subject = noteSubjectInput.value.trim();
}
if (noteInput && noteInput.value.trim()) {
updates.note = noteInput.value.trim();
}
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!');
TicketListView.invalidateCache();
// Refresh the view
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore: ' + err.message);
saveBtn.disabled = false;
saveBtn.textContent = 'Salva Modifiche';
}
});
// Shortcut for Quick Edit save
const quickEditContainer = container.querySelector('.quick-edit');
if (quickEditContainer) {
quickEditContainer.addEventListener('keydown', (e) => {
if (App.isShortcutTriggered(e, 'quick_edit_save')) {
e.preventDefault();
if (saveBtn && !saveBtn.disabled) {
saveBtn.click();
}
}
});
}
// 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();
const note_create_time = document.getElementById('note-create-time')?.value;
// 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;
}
const noteStateSelect = document.getElementById('note-ticket-state');
const ticket_state_id = noteStateSelect ? noteStateSelect.value : null;
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,
ticket_state_id: ticket_state_id ? parseInt(ticket_state_id) : undefined,
attachments: this.noteAttachments.length > 0 ? this.noteAttachments : undefined
}),
});
this.noteAttachments = []; // Clear attachments
if (note_create_time && res.article_id) {
try {
const formattedDate = note_create_time.replace('T', ' ');
await App.api(`/api/tickets/articles/${res.article_id}/retrodata-article`, {
method: 'POST',
body: JSON.stringify({ create_time: formattedDate })
});
Toast.success('Nota aggiunta e retrodatata!');
} catch (retErr) {
Toast.warning('Nota aggiunta, ma errore durante la retrodatazione: ' + retErr.message);
}
} else {
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 rawQuotedBody = article.html_body || article.a_body || '';
const hasHtml = article.has_html || (article.a_content_type || '').toLowerCase().includes('html') || rawQuotedBody.includes('</') || rawQuotedBody.includes('/>');
let processedQuoted = rawQuotedBody;
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 style="font-size:0.88rem; color:var(--text-muted); margin-bottom:8px;">Il ${App.formatDateTime(article.create_time)}, <strong>${App.escapeHtml(article.a_from || 'Sistema')}</strong> ha scritto:</p>
<blockquote style="border-left: 3px solid var(--accent-primary, #3b82f6); background: rgba(255, 255, 255, 0.03); border-radius: 0 6px 6px 0; padding: 10px 14px; margin: 4px 0 12px 0; color: inherit;">
${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');
}
});
});
// Event listener for email address copy buttons inside popover
container.querySelectorAll('.btn-copy-email-addr').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const textToCopy = btn.dataset.copyText || '';
if (textToCopy) {
navigator.clipboard.writeText(textToCopy).then(() => {
const origText = btn.textContent;
btn.textContent = '✓ Copiato!';
btn.style.color = 'var(--success)';
setTimeout(() => {
btn.textContent = origText;
btn.style.color = 'var(--text-secondary)';
}, 1500);
}).catch(err => {
Toast.error('Impossibile copiare: ' + err.message);
});
}
});
});
// 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').forEach(badge => {
badge.style.cursor = 'pointer';
badge.addEventListener('click', (e) => {
e.preventDefault();
this.openImageLightbox(badge.href);
});
});
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('Copiato negli appunti!');
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
},
};