tutto di troppo: fix visualizzazione email. style: riviste icone invio ed elimina, postato aggiungi ticket al grppo e informazioni laterali da a cc.

This commit is contained in:
Gabriele Cimaschi committed 2026-09-09 11:04:14 +02:00
1 parent 9ae0cec0ca
commit cf500aabb3
7 files changed
+743 -85

No files matched your search

+217
View File
@@ -0,0 +1,217 @@
/**
* ShortcutsView — Gestione Shortcuts e Scorciatoie da Tastiera
* Visualizza e permette la personalizzazione delle scorciatoie da tastiera.
*/
const ShortcutsView = {
editingId: null,
render() {
const container = document.getElementById('view-container');
const shortcuts = this.getShortcutsList();
container.innerHTML = `
<div class="view-header" style="padding:var(--space-md) var(--space-xl); display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-sm); border-bottom:1px solid var(--border-subtle);">
<div style="display:flex; align-items:center; gap:var(--space-sm);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:22px; height:22px; color:var(--accent-primary);">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
</svg>
<span style="font-weight:700; font-size:1.1rem;">Shortcuts e Scorciatoie da Tastiera</span>
</div>
<button id="sc-reset-all" class="btn btn-ghost btn-sm" style="font-size:0.82rem;">
🔄 Ripristina Predefiniti
</button>
</div>
<div style="padding:var(--space-lg) var(--space-xl); max-width:960px;">
<p style="color:var(--text-secondary); font-size:0.88rem; margin-bottom:var(--space-lg); line-height:1.5;">
Personalizza o abilita/disabilita le scorciatoie da tastiera rapide per velocizzare la gestione dei ticket e delle consuntivazioni.
</p>
<div style="display:flex; flex-direction:column; gap:var(--space-md);">
${shortcuts.map(sc => this.buildShortcutRow(sc)).join('')}
</div>
</div>
<!-- Modal per la registrazione di una nuova combinazione -->
<div id="sc-modal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:440px; text-align:center;">
<h3 style="margin-bottom:var(--space-xs); font-size:1.1rem;" id="sc-modal-title">Modifica Shortcut</h3>
<p style="font-size:0.85rem; color:var(--text-muted); margin-bottom:var(--space-lg);">
Premi la combinazione di tasti desiderata sulla tastiera (es. <kbd style="background:var(--bg-tertiary); padding:2px 6px; border-radius:4px; border:1px solid var(--border-subtle);">Ctrl</kbd> + <kbd style="background:var(--bg-tertiary); padding:2px 6px; border-radius:4px; border:1px solid var(--border-subtle);">Invio</kbd>).
</p>
<div id="sc-key-recorder" tabindex="0" style="padding:var(--space-lg); background:var(--bg-tertiary); border:2px dashed var(--accent-primary); border-radius:var(--radius-md); font-size:1.2rem; font-weight:700; color:var(--accent-primary); outline:none; cursor:pointer; min-height:70px; display:flex; align-items:center; justify-content:center;">
Premi i tasti...
</div>
<div style="display:flex; justify-content:flex-end; gap:var(--space-sm); margin-top:var(--space-lg);">
<button id="sc-modal-cancel" class="btn btn-ghost btn-sm">Annulla</button>
<button id="sc-modal-save" class="btn btn-primary btn-sm" disabled>Conferma</button>
</div>
</div>
</div>
`;
this.bindEvents();
},
getShortcutsList() {
const list = [
{
id: 'create_ticket_open',
name: 'Crea Nuovo Ticket',
description: 'Apre la pagina di creazione di un nuovo ticket',
context: 'Globale',
defaultCombo: { ctrl: true, alt: true, shift: false, key: 'n', enabled: true }
},
{
id: 'create_ticket_submit',
name: 'Invia Nuovo Ticket',
description: 'Invia il modulo e crea un nuovo ticket',
context: 'Pagina Nuovo Ticket',
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
},
{
id: 'quick_edit_save',
name: 'Salva Modifica Rapida',
description: 'Salva le modifiche apportate nei campi di Modifica Rapida',
context: 'Pagina Dettaglio Ticket (Modifica Rapida)',
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
}
];
return list.map(item => {
const current = App.getShortcut(item.id);
return {
...item,
currentCombo: current
};
});
},
buildShortcutRow(sc) {
const combo = sc.currentCombo;
const label = App.formatShortcutLabel(combo);
const keysHtml = label.split(' + ').map(k => `
<kbd style="background:var(--bg-tertiary); color:var(--text-primary); border:1px solid var(--border-subtle); border-bottom-width:2px; padding:3px 8px; border-radius:4px; font-family:monospace; font-weight:700; font-size:0.85rem; box-shadow:0 1px 2px rgba(0,0,0,0.15);">
${App.escapeHtml(k)}
</kbd>
`).join(' + ');
return `
<div class="card" style="display:flex; align-items:center; justify-content:space-between; padding:var(--space-md) var(--space-lg); gap:var(--space-md); border:1px solid var(--border-subtle); flex-wrap:wrap;">
<div style="flex:1; min-width:240px;">
<div style="font-weight:700; font-size:0.95rem; color:var(--text-primary); display:flex; align-items:center; gap:var(--space-xs);">
${App.escapeHtml(sc.name)}
<span class="badge" style="background:var(--bg-tertiary); color:var(--text-muted); font-size:0.72rem; font-weight:600; padding:2px 8px; border-radius:10px;">
${App.escapeHtml(sc.context)}
</span>
</div>
<div style="font-size:0.82rem; color:var(--text-secondary); margin-top:3px;">
${App.escapeHtml(sc.description)}
</div>
</div>
<div style="display:flex; align-items:center; gap:var(--space-lg);">
<div style="display:flex; align-items:center; gap:4px;">
${combo.enabled ? keysHtml : '<span style="color:var(--text-muted); font-size:0.82rem; font-style:italic;">Disabilitato</span>'}
</div>
<div style="display:flex; align-items:center; gap:var(--space-xs);">
<button class="btn btn-ghost btn-sm sc-edit-btn" data-id="${sc.id}" style="height:32px; padding:4px 10px; font-size:0.82rem;">
✏️ Modifica
</button>
<label class="html-toggle-container" style="margin:0; cursor:pointer;" title="Abilita/Disabilita shortcut">
<input type="checkbox" class="sc-toggle-input" data-id="${sc.id}" ${combo.enabled ? 'checked' : ''} />
<span style="font-size:0.8rem; color:var(--text-secondary);">${combo.enabled ? 'Attivo' : 'Off'}</span>
</label>
</div>
</div>
</div>
`;
},
bindEvents() {
// Reset all
document.getElementById('sc-reset-all')?.addEventListener('click', () => {
if (confirm('Ripristinare tutte le scorciatoie da tastiera ai valori predefiniti?')) {
localStorage.removeItem('otrs_shortcuts');
Toast.success('Shortcuts ripristinati ai valori predefiniti');
this.render();
}
});
// Toggles
document.querySelectorAll('.sc-toggle-input').forEach(toggle => {
toggle.addEventListener('change', (e) => {
const id = e.target.dataset.id;
const current = App.getShortcut(id);
current.enabled = e.target.checked;
App.saveCustomShortcut(id, current);
Toast.success(`Shortcut ${current.enabled ? 'abilitato' : 'disabilitato'}`);
this.render();
});
});
// Edit button
let recordedCombo = null;
const modal = document.getElementById('sc-modal');
const recorder = document.getElementById('sc-key-recorder');
const saveBtn = document.getElementById('sc-modal-save');
const cancelBtn = document.getElementById('sc-modal-cancel');
document.querySelectorAll('.sc-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.editingId = btn.dataset.id;
const item = this.getShortcutsList().find(x => x.id === this.editingId);
document.getElementById('sc-modal-title').textContent = `Modifica: ${item ? item.name : ''}`;
recordedCombo = null;
recorder.innerHTML = 'Premi i tasti sulla tastiera...';
saveBtn.disabled = true;
modal.style.display = 'flex';
recorder.focus();
});
});
cancelBtn?.addEventListener('click', () => {
modal.style.display = 'none';
});
recorder?.addEventListener('keydown', (e) => {
e.preventDefault();
e.stopPropagation();
// Ignore lone modifier presses
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) {
return;
}
const ctrl = e.ctrlKey || e.metaKey;
const alt = e.altKey;
const shift = e.shiftKey;
let key = e.key;
if (key === ' ') key = 'Space';
recordedCombo = { ctrl, alt, shift, key, enabled: true };
const label = App.formatShortcutLabel(recordedCombo);
recorder.innerHTML = label.split(' + ').map(k => `
<kbd style="background:var(--bg-secondary); color:var(--accent-primary); border:1px solid var(--accent-primary); padding:4px 10px; border-radius:6px; font-family:monospace; margin:0 2px;">
${App.escapeHtml(k)}
</kbd>
`).join(' + ');
saveBtn.disabled = false;
});
saveBtn?.addEventListener('click', () => {
if (this.editingId && recordedCombo) {
App.saveCustomShortcut(this.editingId, recordedCombo);
Toast.success('Combinazione salvata!');
modal.style.display = 'none';
this.render();
}
});
}
};
+13
View File
@@ -747,5 +747,18 @@ const TicketCreateView = {
el.addEventListener('input', () => this.saveState());
el.addEventListener('change', () => this.saveState());
});
// Shortcut to submit ticket
const createFormEl = document.querySelector('.create-form');
if (createFormEl) {
createFormEl.addEventListener('keydown', (e) => {
if (App.isShortcutTriggered(e, 'create_ticket_submit')) {
e.preventDefault();
if (submitBtn && !submitBtn.disabled) {
submitBtn.click();
}
}
});
}
},
};
+229 -64
View File
@@ -4,7 +4,7 @@
*/
const TicketDetailView = {
ticketId: null,
htmlMode: false,
htmlMode: true,
originalValues: {},
noteAttachments: [],
@@ -13,13 +13,15 @@ const TicketDetailView = {
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) {
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 {
@@ -27,6 +29,42 @@ const TicketDetailView = {
}
},
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;
@@ -158,8 +196,8 @@ const TicketDetailView = {
<div class="ticket-detail-main">
<!-- Header -->
<div class="card">
<div class="ticket-header">
<div class="ticket-header-info">
<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}
@@ -177,6 +215,34 @@ const TicketDetailView = {
${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>
@@ -241,10 +307,6 @@ const TicketDetailView = {
<input type="hidden" id="qe-customer-id" data-field="customer_id" value="${ticket.customer_id || ''}" />
<div id="qe-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%;"></div>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Tempo (minuti)</label>
<input type="number" step="any" min="0" class="form-input" id="qe-time-unit" placeholder="minuti" style="padding: 6px 12px; height: 32px; font-size: 0.85rem;" />
</div>
<div 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;" />
@@ -253,7 +315,11 @@ const TicketDetailView = {
<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);justify-content:flex-end;grid-column:1 / -1;">
<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>
@@ -262,7 +328,17 @@ const TicketDetailView = {
<!-- Add Note Form -->
<div class="add-note-form" style="margin-bottom: var(--space-lg);">
<div class="card-title">Aggiungi Nota</div>
<div style="display:flex; 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>
@@ -304,34 +380,82 @@ const TicketDetailView = {
</div>
<div class="articles-timeline">
${articles.length > 0 ? articles.map(a => {
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('</') || a.a_body.includes('/>');
const 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 = 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) {
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');
processedBody = processedBody.replace(regex, `/api/tickets/attachments/${att.id}`);
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');
processedBody = processedBody.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
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 srcdoc="${processedBody.replace(/"/g, '&quot;')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
const 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 => att.filename !== 'file-1' && att.filename !== 'file-2');
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()}">
@@ -341,8 +465,12 @@ const TicketDetailView = {
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
<button class="btn-delete-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Elimina Articolo">🗑️</button>
<button class="btn-email-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Rispondi via email (Quota questo articolo)">✉️</button>
<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>
<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;">
@@ -373,20 +501,68 @@ const TicketDetailView = {
</div>
</div>
</div>
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
${displayBody}
${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>` : ''}
${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 class="article-content-layout" style="display: flex; gap: var(--space-md); align-items: flex-start; flex-wrap: wrap;">
<div class="article-body-main" style="flex: 1; min-width: 280px; width: 0;">
${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>
` : ''}
<!-- Scheda a lato per ogni mail con Da: A: Cc: e lista Link -->
<div class="article-email-side-card" style="width: 260px; min-width: 220px; background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px; font-size: 0.78rem; align-self: flex-start; box-shadow: var(--shadow-sm);">
<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; gap: 4px; font-size: 0.8rem;">
<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 Mail
</div>
<div style="margin-bottom: 6px; word-break: break-all; line-height: 1.35;">
<strong style="color: var(--text-muted);">Da:</strong>
<span style="color: var(--text-primary); font-family: inherit;">${App.escapeHtml(a.a_from || '—')}</span>
</div>
<div style="margin-bottom: 6px; word-break: break-all; line-height: 1.35;">
<strong style="color: var(--text-muted);">A:</strong>
<span style="color: var(--text-primary); font-family: inherit;">${App.escapeHtml(a.a_to || '—')}</span>
</div>
${a.a_cc ? `
<div style="margin-bottom: 6px; word-break: break-all; line-height: 1.35;">
<strong style="color: var(--text-muted);">Cc:</strong>
<span style="color: var(--text-primary); font-family: inherit;">${App.escapeHtml(a.a_cc)}</span>
</div>
` : ''}
<div style="font-weight: 700; color: var(--text-secondary); margin-top: 12px; 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="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-card);">${extractedLinks.length}</span>
</div>
${extractedLinks.length > 0 ? `
<div class="article-links-minilist" style="display: flex; flex-direction: column; gap: 6px; max-height: 200px; 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-card); padding: 4px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); transition: border-color 0.15s ease;">
<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>
</div>
`;
}).join('') : `
@@ -497,37 +673,6 @@ const TicketDetailView = {
</div>
` : ''}
<!-- Panel per i Gruppi Ticket -->
<div class="sidebar-panel" id="ticket-groups-panel">
<div class="sidebar-panel-title">Gruppi Ticket</div>
<div id="ticket-groups-list" style="margin-bottom: var(--space-sm);">
${groupsData.asMaster.length === 0 && groupsData.asMember.length === 0 ? `
<div style="font-size:0.78rem; color:var(--text-muted); padding:4px 0;">Nessun gruppo associato</div>
` : ''}
${groupsData.asMaster.map(g => `
<div style="font-size:0.8rem; margin-bottom:4px;">
<span style="color:var(--accent-primary); font-weight:bold;">👑 Master in:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</div>
`).join('')}
${groupsData.asMember.map(g => `
<div style="font-size:0.8rem; margin-bottom:4px;">
<span style="color:var(--text-secondary); font-weight:bold;">🔗 Membro di:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</div>
`).join('')}
</div>
<div style="border-top:1px solid var(--border-subtle); padding-top:var(--space-xs); margin-top:var(--space-xs);">
<label class="quick-edit-label">Associa a Gruppo</label>
<div style="display:flex; gap:4px; margin-top:4px;">
<select class="form-select" id="group-association-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0; flex:1;">
<option value="">-- Seleziona --</option>
</select>
<button class="btn btn-primary btn-sm" id="btn-associate-to-group" style="height:28px; padding:0 8px; font-size:0.75rem;">+ Aggiungi</button>
</div>
</div>
</div>
</div>
</div>
`;
@@ -579,6 +724,9 @@ const TicketDetailView = {
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();
}
@@ -891,6 +1039,19 @@ const TicketDetailView = {
}
});
// 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 () => {
@@ -925,6 +1086,9 @@ const TicketDetailView = {
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...';
@@ -935,6 +1099,7 @@ const TicketDetailView = {
subject,
body,
time_unit,
ticket_state_id: ticket_state_id ? parseInt(ticket_state_id) : undefined,
attachments: this.noteAttachments.length > 0 ? this.noteAttachments : undefined
}),
});
+58 -15
View File
@@ -46,7 +46,25 @@ const TimeAccountingView = {
date: this.selectedDate,
});
const data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
let data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
// If non-working days are hidden, ensure selectedDate is a valid working day
if (!this.showNonWorkingDays) {
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1] - 1, parts[2]);
let shifted = false;
while (workDays.length > 0 && !workDays.includes(d.getDay())) {
d.setDate(d.getDate() - 1);
shifted = true;
}
if (shifted) {
this.selectedDate = this.formatLocalISO(d);
params.set('date', this.selectedDate);
data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
}
}
this.summaryData = data;
// Auto-select first article if current selection is invalid
@@ -192,22 +210,43 @@ const TimeAccountingView = {
const targetMins = (data && data.target_minutes) || 480;
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
// Generate 7 days before and 7 days after selected date (15 days total)
// Generate 2 full weeks of days (15 calendar days if showing all, or 10-11 working days if hiding non-working days)
const dateList = [];
if (this.showNonWorkingDays) {
for (let offset = -7; offset <= 7; offset++) {
const d = new Date(selected);
d.setDate(d.getDate() + offset);
dateList.push(d);
}
} else {
const before = [];
let dPrev = new Date(selected);
while (before.length < 5) {
dPrev.setDate(dPrev.getDate() - 1);
if (workDays.includes(dPrev.getDay())) {
before.unshift(new Date(dPrev));
}
}
const current = new Date(selected);
const after = [];
let dNext = new Date(selected);
while (after.length < 5) {
dNext.setDate(dNext.getDate() + 1);
if (workDays.includes(dNext.getDay())) {
after.push(new Date(dNext));
}
}
dateList.push(...before, current, ...after);
}
const days = [];
for (let offset = -7; offset <= 7; offset++) {
const d = new Date(selected);
d.setDate(d.getDate() + offset);
for (const d of dateList) {
const isoStr = this.formatLocalISO(d);
const dayOfWeek = d.getDay(); // 0 = Sun, 1 = Mon ... 6 = Sat
const isWorkDay = workDays.includes(dayOfWeek);
const isSelected = isoStr === this.selectedDate;
const isToday = isoStr === todayStr;
// Filter out non-working days if toggle is off, unless it's currently selected
if (!isWorkDay && !this.showNonWorkingDays && !isSelected) {
continue;
}
const dayNum = d.getDate();
const weekdayName = weekdays[dayOfWeek];
const monthName = months[d.getMonth()];
@@ -413,9 +452,7 @@ const TimeAccountingView = {
document.getElementById('ta-btn-toggle-nonwork')?.addEventListener('click', () => {
this.showNonWorkingDays = !this.showNonWorkingDays;
localStorage.setItem('otrs_ta_show_non_working_days', this.showNonWorkingDays);
const container = document.getElementById('view-container');
container.innerHTML = this.buildHtml(this.summaryData);
this.bindEvents();
this.loadData();
});
// Day pill clicks
@@ -428,18 +465,24 @@ const TimeAccountingView = {
// Previous day button
document.getElementById('ta-day-prev')?.addEventListener('click', () => {
const workDays = (this.summaryData && this.summaryData.work_days) || [1, 2, 3, 4, 5];
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1] - 1, parts[2]);
d.setDate(d.getDate() - 1);
do {
d.setDate(d.getDate() - 1);
} while (!this.showNonWorkingDays && workDays.length > 0 && !workDays.includes(d.getDay()));
this.selectedDate = this.formatLocalISO(d);
this.loadData();
});
// Next day button
document.getElementById('ta-day-next')?.addEventListener('click', () => {
const workDays = (this.summaryData && this.summaryData.work_days) || [1, 2, 3, 4, 5];
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1] - 1, parts[2]);
d.setDate(d.getDate() + 1);
do {
d.setDate(d.getDate() + 1);
} while (!this.showNonWorkingDays && workDays.length > 0 && !workDays.includes(d.getDay()));
this.selectedDate = this.formatLocalISO(d);
this.loadData();
});