aggiunta gestione html/testo e allegati

This commit is contained in:
2026-07-05 19:40:10 +02:00
parent 4f41b50d37
commit fb67a277e5
3 changed files with 156 additions and 28 deletions
+54
View File
@@ -1929,4 +1929,58 @@ body {
.retrodate-btn-action.cancel {
color: var(--error);
}
/* HTML switch & attachments styles */
.article-header-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
}
.html-toggle-container {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.82rem;
color: var(--text-secondary);
}
.html-toggle-input {
cursor: pointer;
}
.article-attachments {
margin-top: var(--space-md);
padding-top: var(--space-sm);
border-top: 1px dashed var(--border-subtle);
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.attachment-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: 4px 10px;
font-size: 0.78rem;
color: var(--text-primary);
text-decoration: none;
transition: all var(--transition-fast);
}
.attachment-badge:hover {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
color: var(--accent-primary-hover);
}
.attachment-size {
color: var(--text-muted);
font-size: 0.72rem;
}
+65 -26
View File
@@ -4,17 +4,19 @@
*/
const TicketDetailView = {
ticketId: null,
htmlMode: false,
originalValues: {},
async render(id) {
this.ticketId = id;
this.htmlMode = localStorage.getItem('otrs_turbo_html_mode') === 'true';
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
try {
await App.ensureLookups();
const data = await App.api(`/api/tickets/${id}`);
const { ticket, articles } = data;
const { ticket, articles, attachments } = data;
const localISO = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
@@ -130,37 +132,64 @@ const TicketDetailView = {
<!-- Articles Timeline -->
<div>
<div class="card-title" style="margin-bottom:var(--space-md);">Articoli & Note (${articles.length})</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 => `
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
<div class="article-header">
<div class="article-sender">
<span class="article-sender-badge ${(a.sender_type || 'system').toLowerCase()}">${a.sender_type || 'System'}</span>
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
</div>
<div style="display:flex; gap: var(--space-sm); align-items:center;">
${a.time_unit ? `<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ ${parseFloat(a.time_unit)} min</span>` : ''}
<div class="retrodate-container" data-article-id="${a.article_id}">
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
<div class="retrodate-editor" style="display:none;">
<input type="datetime-local" class="retrodate-input input-article-date" value="${localISO(a.create_time)}" />
<div class="retrodate-actions">
<button class="retrodate-btn-action save btn-save-article-date" title="Salva">✓</button>
<button class="retrodate-btn-action cancel btn-cancel-article-date" title="Annulla">✗</button>
${articles.length > 0 ? articles.map(a => {
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('</') || a.a_body.includes('/>');
const displayBody = this.htmlMode && hasHtml
? `<iframe srcdoc="${a.a_body.replace(/"/g, '&quot;')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
return `
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
<div class="article-header">
<div class="article-sender">
<span class="article-sender-badge ${(a.sender_type || 'system').toLowerCase()}">${a.sender_type || 'System'}</span>
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
</div>
<div style="display:flex; gap: var(--space-sm); align-items:center;">
${a.time_unit ? `<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ ${parseFloat(a.time_unit)} min</span>` : ''}
<div class="retrodate-container" data-article-id="${a.article_id}">
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
<div class="retrodate-editor" style="display:none;">
<input type="datetime-local" class="retrodate-input input-article-date" value="${localISO(a.create_time)}" />
<div class="retrodate-actions">
<button class="retrodate-btn-action save btn-save-article-date" title="Salva">✓</button>
<button class="retrodate-btn-action cancel btn-cancel-article-date" title="Annulla">✗</button>
</div>
</div>
</div>
</div>
</div>
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
${displayBody}
${articleAttachments.length > 0 ? `
<div class="article-attachments">
${articleAttachments.map(att => `
<a href="/api/tickets/attachments/${att.id}" class="attachment-badge" target="_blank" download="${att.filename}">
<span>📎</span>
<strong>${App.escapeHtml(att.filename)}</strong>
<span class="attachment-size">(${Math.round(att.content_size / 1024)} KB)</span>
</a>
`).join('')}
</div>
` : ''}
</div>
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>
</div>
`).join('') : `
`;
}).join('') : `
<div class="empty-state" style="padding:var(--space-lg);">
<div class="empty-state-icon">💬</div>
<div class="empty-state-text">Nessun articolo</div>
@@ -457,5 +486,15 @@ const TicketDetailView = {
});
}
});
// 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);
});
}
},
};
+37 -2
View File
@@ -210,13 +210,24 @@ router.get('/:id', async (req, res) => {
LEFT JOIN users creator ON a.create_by = creator.id
LEFT JOIN time_accounting ta ON a.id = ta.article_id
WHERE a.ticket_id = $1
ORDER BY a.create_time ASC`,
ORDER BY a.create_time DESC, a.id DESC`,
[id]
);
// Fetch attachments metadata for all articles in the ticket
const attachmentsResult = await pool.query(
`SELECT id, article_id, filename, content_size, content_type, disposition
FROM article_data_mime_attachment
WHERE article_id IN (
SELECT id FROM article WHERE ticket_id = $1
)`,
[id]
);
res.json({
ticket: ticketResult.rows[0],
articles: articlesResult.rows,
attachments: attachmentsResult.rows,
});
} catch (err) {
console.error('Error fetching ticket detail:', err);
@@ -578,7 +589,7 @@ router.get('/:id/articles', async (req, res) => {
LEFT JOIN users creator ON a.create_by = creator.id
LEFT JOIN time_accounting ta ON a.id = ta.article_id
WHERE a.ticket_id = $1
ORDER BY a.create_time ASC`,
ORDER BY a.create_time DESC, a.id DESC`,
[id]
);
res.json(result.rows);
@@ -917,4 +928,28 @@ router.post('/articles/:articleId/retrodata-article', async (req, res) => {
}
});
// GET /api/tickets/attachments/:id — Download/View attachment
router.get('/attachments/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`SELECT filename, content_type, content
FROM article_data_mime_attachment
WHERE id = $1`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).send('Allegato non trovato.');
}
const attachment = result.rows[0];
res.setHeader('Content-Type', attachment.content_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${attachment.filename}"`);
res.send(attachment.content);
} catch (err) {
console.error('Errore download allegato:', err);
res.status(500).send(err.message);
}
});
module.exports = router;