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:
1 parent
9ae0cec0ca
commit
cf500aabb3
7 files changed
+743
-85
No files matched your search
@@ -162,6 +162,15 @@
|
|||||||
<span>Storico Attività Turbo</span>
|
<span>Storico Attività Turbo</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/shortcuts" class="nav-link" data-view="shortcuts" id="nav-shortcuts">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<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>Shortcuts</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -249,6 +258,7 @@
|
|||||||
<script src="/js/views/ticketCreate.js"></script>
|
<script src="/js/views/ticketCreate.js"></script>
|
||||||
<script src="/js/views/ticketBulk.js"></script>
|
<script src="/js/views/ticketBulk.js"></script>
|
||||||
<script src="/js/views/activityLog.js"></script>
|
<script src="/js/views/activityLog.js"></script>
|
||||||
|
<script src="/js/views/shortcuts.js"></script>
|
||||||
<script src="/js/views/emailCompose.js"></script>
|
<script src="/js/views/emailCompose.js"></script>
|
||||||
<script src="/js/views/mailManagement.js"></script>
|
<script src="/js/views/mailManagement.js"></script>
|
||||||
<script src="/js/views/timeAccounting.js"></script>
|
<script src="/js/views/timeAccounting.js"></script>
|
||||||
|
|||||||
@@ -53,6 +53,69 @@ const App = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getShortcut(id) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('otrs_shortcuts');
|
||||||
|
const customMap = stored ? JSON.parse(stored) : {};
|
||||||
|
if (customMap[id]) return customMap[id];
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
create_ticket_open: { ctrl: true, alt: true, shift: false, key: 'n', enabled: true },
|
||||||
|
create_ticket_submit: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||||
|
quick_edit_save: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||||
|
};
|
||||||
|
return defaults[id] || { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
saveCustomShortcut(id, combo) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('otrs_shortcuts');
|
||||||
|
const customMap = stored ? JSON.parse(stored) : {};
|
||||||
|
customMap[id] = combo;
|
||||||
|
localStorage.setItem('otrs_shortcuts', JSON.stringify(customMap));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isShortcutTriggered(e, id) {
|
||||||
|
const sc = this.getShortcut(id);
|
||||||
|
if (!sc || !sc.enabled) return false;
|
||||||
|
|
||||||
|
const isCtrlOrCmd = sc.ctrl ? (e.ctrlKey || e.metaKey) : (!e.ctrlKey && !e.metaKey);
|
||||||
|
const isAlt = sc.alt ? e.altKey : !e.altKey;
|
||||||
|
const isShift = sc.shift ? e.shiftKey : !e.shiftKey;
|
||||||
|
|
||||||
|
let targetKey = sc.key.toLowerCase();
|
||||||
|
let pressedKey = e.key.toLowerCase();
|
||||||
|
|
||||||
|
if (targetKey === 'space' && (pressedKey === ' ' || pressedKey === 'space')) {
|
||||||
|
return isCtrlOrCmd && isAlt && isShift;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isCtrlOrCmd && isAlt && isShift && (pressedKey === targetKey);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatShortcutLabel(sc) {
|
||||||
|
if (!sc) return '';
|
||||||
|
const parts = [];
|
||||||
|
if (sc.ctrl) parts.push(navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl');
|
||||||
|
if (sc.alt) parts.push('Alt');
|
||||||
|
if (sc.shift) parts.push('Shift');
|
||||||
|
let keyName = sc.key;
|
||||||
|
if (keyName === 'Enter') keyName = 'Invio';
|
||||||
|
else if (keyName === ' ' || keyName === 'Space') keyName = 'Spazio';
|
||||||
|
parts.push(keyName);
|
||||||
|
return parts.join(' + ');
|
||||||
|
},
|
||||||
|
|
||||||
|
linkifyText(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const urlRegex = /(https?:\/\/[^\s<]+)/g;
|
||||||
|
return text.replace(urlRegex, url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color:var(--accent-primary); text-decoration:underline;">${url}</a>`);
|
||||||
|
},
|
||||||
|
|
||||||
openTab(id, tn, title) {
|
openTab(id, tn, title) {
|
||||||
const exists = this.tabs.find(t => String(t.id) === String(id));
|
const exists = this.tabs.find(t => String(t.id) === String(id));
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -197,6 +260,16 @@ const App = {
|
|||||||
toggleClearBtn();
|
toggleClearBtn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global keyboard shortcuts (e.g. Crea Nuovo Ticket)
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (this.isShortcutTriggered(e, 'create_ticket_open')) {
|
||||||
|
if (window.location.hash !== '#/tickets/new') {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.hash = '#/tickets/new';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check DB connection
|
// Check DB connection
|
||||||
this.checkConnection();
|
this.checkConnection();
|
||||||
|
|
||||||
@@ -369,6 +442,11 @@ const App = {
|
|||||||
titleEl.textContent = 'Storico Attività';
|
titleEl.textContent = 'Storico Attività';
|
||||||
ActivityLogView.render();
|
ActivityLogView.render();
|
||||||
|
|
||||||
|
} else if (hash === '#/shortcuts') {
|
||||||
|
document.getElementById('nav-shortcuts')?.classList.add('active');
|
||||||
|
titleEl.textContent = 'Shortcuts';
|
||||||
|
ShortcutsView.render();
|
||||||
|
|
||||||
} else if (hash === '#/mail-management') {
|
} else if (hash === '#/mail-management') {
|
||||||
document.getElementById('nav-mail-management')?.classList.add('active');
|
document.getElementById('nav-mail-management')?.classList.add('active');
|
||||||
titleEl.textContent = 'Gestione Mail';
|
titleEl.textContent = 'Gestione Mail';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -747,5 +747,18 @@ const TicketCreateView = {
|
|||||||
el.addEventListener('input', () => this.saveState());
|
el.addEventListener('input', () => this.saveState());
|
||||||
el.addEventListener('change', () => 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
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
const TicketDetailView = {
|
const TicketDetailView = {
|
||||||
ticketId: null,
|
ticketId: null,
|
||||||
htmlMode: false,
|
htmlMode: true,
|
||||||
originalValues: {},
|
originalValues: {},
|
||||||
noteAttachments: [],
|
noteAttachments: [],
|
||||||
|
|
||||||
@@ -13,13 +13,15 @@ const TicketDetailView = {
|
|||||||
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
|
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
|
||||||
const subject = document.getElementById('note-subject') ? document.getElementById('note-subject').value.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 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, {
|
App.saveDraft(this.ticketId, {
|
||||||
type: 'note',
|
type: 'note',
|
||||||
body,
|
body,
|
||||||
subject,
|
subject,
|
||||||
time_unit,
|
time_unit,
|
||||||
|
ticket_state_id,
|
||||||
attachments: [...this.noteAttachments]
|
attachments: [...this.noteAttachments]
|
||||||
});
|
});
|
||||||
} else {
|
} 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(/&/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() {
|
updateNoteAttachmentList() {
|
||||||
const listEl = document.getElementById('note-file-list');
|
const listEl = document.getElementById('note-file-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
@@ -158,8 +196,8 @@ const TicketDetailView = {
|
|||||||
<div class="ticket-detail-main">
|
<div class="ticket-detail-main">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="ticket-header">
|
<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">
|
<div class="ticket-header-info" style="flex:1; min-width:280px;">
|
||||||
<div class="ticket-number" style="display:inline-flex; align-items:center; gap:6px;">
|
<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>
|
<span class="copy-ticket-btn" data-tn="${ticket.tn}" style="cursor: pointer; font-size: 0.85rem;" title="Copia numero ticket">📋</span>
|
||||||
#${ticket.tn}
|
#${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>' : ''}
|
${ticket.lock_name === 'lock' ? '<span class="badge" style="background:var(--warning-bg);color:var(--warning);">🔒 Bloccato</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -241,10 +307,6 @@ const TicketDetailView = {
|
|||||||
<input type="hidden" id="qe-customer-id" data-field="customer_id" value="${ticket.customer_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 id="qe-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%;"></div>
|
||||||
</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">
|
<div class="quick-edit-field full-width">
|
||||||
<label class="quick-edit-label">Oggetto Nota (opzionale)</label>
|
<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;" />
|
<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>
|
<label class="quick-edit-label">Nota / Commento</label>
|
||||||
<textarea class="quick-edit-textarea" id="qe-note" placeholder="Inserisci una nota..."></textarea>
|
<textarea class="quick-edit-textarea" id="qe-note" placeholder="Inserisci una nota..."></textarea>
|
||||||
</div>
|
</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-ghost btn-sm" id="qe-reset">Reset</button>
|
||||||
<button class="btn btn-primary btn-sm" id="qe-save" disabled>Salva Modifiche</button>
|
<button class="btn btn-primary btn-sm" id="qe-save" disabled>Salva Modifiche</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +328,17 @@ const TicketDetailView = {
|
|||||||
|
|
||||||
<!-- Add Note Form -->
|
<!-- Add Note Form -->
|
||||||
<div class="add-note-form" style="margin-bottom: var(--space-lg);">
|
<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);">
|
<div style="display:flex; gap:var(--space-md); margin-bottom:var(--space-md);">
|
||||||
<input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto (opzionale)" style="flex:1; margin-bottom:0;" />
|
<input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto (opzionale)" style="flex:1; margin-bottom:0;" />
|
||||||
</div>
|
</div>
|
||||||
@@ -304,34 +380,82 @@ const TicketDetailView = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="articles-timeline">
|
<div class="articles-timeline">
|
||||||
${articles.length > 0 ? articles.map(a => {
|
${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 articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
|
||||||
|
const usedAttachmentIds = new Set();
|
||||||
|
|
||||||
if (hasHtml) {
|
if (hasHtml && this.htmlMode) {
|
||||||
articleAttachments.forEach(att => {
|
articleAttachments.forEach(att => {
|
||||||
|
let replaced = false;
|
||||||
if (att.content_id) {
|
if (att.content_id) {
|
||||||
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
|
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
|
||||||
if (cleanCid) {
|
if (cleanCid) {
|
||||||
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
const regex = new RegExp(`cid:<?${escapedCid}>?`, 'gi');
|
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) {
|
if (att.filename) {
|
||||||
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
|
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
|
const displayBody = (this.htmlMode && hasHtml)
|
||||||
? `<iframe srcdoc="${processedBody.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
? `<iframe id="article-iframe-${a.article_id}" srcdoc="${processedBody.replace(/"/g, '"')}"
|
||||||
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
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 `
|
return `
|
||||||
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
<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 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>
|
<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>` : ''}
|
${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-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">
|
||||||
<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>
|
<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>
|
||||||
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
<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;">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</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>` : ''}
|
||||||
${displayBody}
|
|
||||||
|
|
||||||
${visibleAttachments.length > 0 ? `
|
<div class="article-content-layout" style="display: flex; gap: var(--space-md); align-items: flex-start; flex-wrap: wrap;">
|
||||||
<div class="article-attachments">
|
<div class="article-body-main" style="flex: 1; min-width: 280px; width: 0;">
|
||||||
${visibleAttachments.map(att => `
|
${displayBody}
|
||||||
<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>
|
${visibleAttachments.length > 0 ? `
|
||||||
<strong>${App.escapeHtml(att.filename)}</strong>
|
<div class="article-attachments">
|
||||||
<span class="attachment-size">(${Math.round(att.content_size / 1024)} KB)</span>
|
${visibleAttachments.map(att => `
|
||||||
</a>
|
<a href="/api/tickets/attachments/${att.id}" class="attachment-badge" target="_blank" download="${att.filename}" data-is-image="${(att.content_type || '').startsWith('image/')}">
|
||||||
`).join('')}
|
<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>
|
||||||
` : ''}
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('') : `
|
}).join('') : `
|
||||||
@@ -497,37 +673,6 @@ const TicketDetailView = {
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<!-- Panel per i Gruppi Ticket -->
|
|
||||||
<div class="sidebar-panel" id="ticket-groups-panel">
|
|
||||||
<div class="sidebar-panel-title">Gruppi Ticket</div>
|
|
||||||
<div id="ticket-groups-list" style="margin-bottom: var(--space-sm);">
|
|
||||||
${groupsData.asMaster.length === 0 && groupsData.asMember.length === 0 ? `
|
|
||||||
<div style="font-size:0.78rem; color:var(--text-muted); padding:4px 0;">Nessun gruppo associato</div>
|
|
||||||
` : ''}
|
|
||||||
${groupsData.asMaster.map(g => `
|
|
||||||
<div style="font-size:0.8rem; margin-bottom:4px;">
|
|
||||||
<span style="color:var(--accent-primary); font-weight:bold;">👑 Master in:</span>
|
|
||||||
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
${groupsData.asMember.map(g => `
|
|
||||||
<div style="font-size:0.8rem; margin-bottom:4px;">
|
|
||||||
<span style="color:var(--text-secondary); font-weight:bold;">🔗 Membro di:</span>
|
|
||||||
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="border-top:1px solid var(--border-subtle); padding-top:var(--space-xs); margin-top:var(--space-xs);">
|
|
||||||
<label class="quick-edit-label">Associa a Gruppo</label>
|
|
||||||
<div style="display:flex; gap:4px; margin-top:4px;">
|
|
||||||
<select class="form-select" id="group-association-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0; flex:1;">
|
|
||||||
<option value="">-- Seleziona --</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-primary btn-sm" id="btn-associate-to-group" style="height:28px; padding:0 8px; font-size:0.75rem;">+ Aggiungi</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -579,6 +724,9 @@ const TicketDetailView = {
|
|||||||
if (document.getElementById('note-time-units')) {
|
if (document.getElementById('note-time-units')) {
|
||||||
document.getElementById('note-time-units').value = noteDraft.time_unit || '';
|
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.noteAttachments = noteDraft.attachments || [];
|
||||||
this.updateNoteAttachmentList();
|
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
|
// Send note
|
||||||
const noteSendBtn = document.getElementById('note-send');
|
const noteSendBtn = document.getElementById('note-send');
|
||||||
noteSendBtn.addEventListener('click', async () => {
|
noteSendBtn.addEventListener('click', async () => {
|
||||||
@@ -925,6 +1086,9 @@ const TicketDetailView = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noteStateSelect = document.getElementById('note-ticket-state');
|
||||||
|
const ticket_state_id = noteStateSelect ? noteStateSelect.value : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
noteSendBtn.disabled = true;
|
noteSendBtn.disabled = true;
|
||||||
noteSendBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;"></div> Invio...';
|
noteSendBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;"></div> Invio...';
|
||||||
@@ -935,6 +1099,7 @@ const TicketDetailView = {
|
|||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
time_unit,
|
time_unit,
|
||||||
|
ticket_state_id: ticket_state_id ? parseInt(ticket_state_id) : undefined,
|
||||||
attachments: this.noteAttachments.length > 0 ? this.noteAttachments : undefined
|
attachments: this.noteAttachments.length > 0 ? this.noteAttachments : undefined
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,7 +46,25 @@ const TimeAccountingView = {
|
|||||||
date: this.selectedDate,
|
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;
|
this.summaryData = data;
|
||||||
|
|
||||||
// Auto-select first article if current selection is invalid
|
// Auto-select first article if current selection is invalid
|
||||||
@@ -192,22 +210,43 @@ const TimeAccountingView = {
|
|||||||
const targetMins = (data && data.target_minutes) || 480;
|
const targetMins = (data && data.target_minutes) || 480;
|
||||||
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
|
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 = [];
|
const days = [];
|
||||||
for (let offset = -7; offset <= 7; offset++) {
|
for (const d of dateList) {
|
||||||
const d = new Date(selected);
|
|
||||||
d.setDate(d.getDate() + offset);
|
|
||||||
const isoStr = this.formatLocalISO(d);
|
const isoStr = this.formatLocalISO(d);
|
||||||
const dayOfWeek = d.getDay(); // 0 = Sun, 1 = Mon ... 6 = Sat
|
const dayOfWeek = d.getDay(); // 0 = Sun, 1 = Mon ... 6 = Sat
|
||||||
const isWorkDay = workDays.includes(dayOfWeek);
|
const isWorkDay = workDays.includes(dayOfWeek);
|
||||||
const isSelected = isoStr === this.selectedDate;
|
const isSelected = isoStr === this.selectedDate;
|
||||||
const isToday = isoStr === todayStr;
|
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 dayNum = d.getDate();
|
||||||
const weekdayName = weekdays[dayOfWeek];
|
const weekdayName = weekdays[dayOfWeek];
|
||||||
const monthName = months[d.getMonth()];
|
const monthName = months[d.getMonth()];
|
||||||
@@ -413,9 +452,7 @@ const TimeAccountingView = {
|
|||||||
document.getElementById('ta-btn-toggle-nonwork')?.addEventListener('click', () => {
|
document.getElementById('ta-btn-toggle-nonwork')?.addEventListener('click', () => {
|
||||||
this.showNonWorkingDays = !this.showNonWorkingDays;
|
this.showNonWorkingDays = !this.showNonWorkingDays;
|
||||||
localStorage.setItem('otrs_ta_show_non_working_days', this.showNonWorkingDays);
|
localStorage.setItem('otrs_ta_show_non_working_days', this.showNonWorkingDays);
|
||||||
const container = document.getElementById('view-container');
|
this.loadData();
|
||||||
container.innerHTML = this.buildHtml(this.summaryData);
|
|
||||||
this.bindEvents();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Day pill clicks
|
// Day pill clicks
|
||||||
@@ -428,18 +465,24 @@ const TimeAccountingView = {
|
|||||||
|
|
||||||
// Previous day button
|
// Previous day button
|
||||||
document.getElementById('ta-day-prev')?.addEventListener('click', () => {
|
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 parts = this.selectedDate.split('-').map(Number);
|
||||||
const d = new Date(parts[0], parts[1] - 1, parts[2]);
|
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.selectedDate = this.formatLocalISO(d);
|
||||||
this.loadData();
|
this.loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Next day button
|
// Next day button
|
||||||
document.getElementById('ta-day-next')?.addEventListener('click', () => {
|
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 parts = this.selectedDate.split('-').map(Number);
|
||||||
const d = new Date(parts[0], parts[1] - 1, parts[2]);
|
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.selectedDate = this.formatLocalISO(d);
|
||||||
this.loadData();
|
this.loadData();
|
||||||
});
|
});
|
||||||
|
|||||||
+138
-6
@@ -443,6 +443,62 @@ router.get('/:id', async (req, res) => {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch HTML body attachment content for articles if present (e.g. file-1, file-1.html, text/html)
|
||||||
|
const htmlAttachmentsRes = await pool.query(
|
||||||
|
`SELECT id, article_id, filename, content_type, content
|
||||||
|
FROM article_data_mime_attachment
|
||||||
|
WHERE article_id IN (SELECT id FROM article WHERE ticket_id = $1)
|
||||||
|
AND (
|
||||||
|
filename LIKE 'file-%'
|
||||||
|
OR content_type LIKE '%html%'
|
||||||
|
OR disposition = 'inline'
|
||||||
|
)
|
||||||
|
ORDER BY id ASC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const htmlBodyMap = {};
|
||||||
|
for (const row of htmlAttachmentsRes.rows) {
|
||||||
|
if (row.content) {
|
||||||
|
let contentStr = '';
|
||||||
|
if (Buffer.isBuffer(row.content)) {
|
||||||
|
contentStr = row.content.toString('utf-8');
|
||||||
|
} else if (typeof row.content === 'string') {
|
||||||
|
contentStr = row.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = contentStr.replace(/\s+/g, '');
|
||||||
|
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
if (cleaned.length > 20 && cleaned.length % 4 === 0 && base64Regex.test(cleaned)) {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(cleaned, 'base64').toString('utf-8');
|
||||||
|
if (decoded && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(decoded)) {
|
||||||
|
contentStr = decoded;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentStr && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(contentStr)) {
|
||||||
|
const fname = (row.filename || '').toLowerCase();
|
||||||
|
if (!htmlBodyMap[row.article_id] || fname.startsWith('file-1') || fname.startsWith('file-2')) {
|
||||||
|
htmlBodyMap[row.article_id] = contentStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
articlesResult.rows.forEach(a => {
|
||||||
|
if (htmlBodyMap[a.article_id]) {
|
||||||
|
a.html_body = htmlBodyMap[a.article_id];
|
||||||
|
a.has_html = true;
|
||||||
|
} else if (a.a_body && ((a.a_content_type || '').toLowerCase().includes('html') || /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(a.a_body))) {
|
||||||
|
a.html_body = a.a_body;
|
||||||
|
a.has_html = true;
|
||||||
|
} else {
|
||||||
|
a.has_html = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let otrsWebUrl = null;
|
let otrsWebUrl = null;
|
||||||
const apiUrl = process.env.OTRS_API_URL;
|
const apiUrl = process.env.OTRS_API_URL;
|
||||||
if (apiUrl) {
|
if (apiUrl) {
|
||||||
@@ -1111,7 +1167,7 @@ router.get('/:id/articles', async (req, res) => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.post('/:id/articles', async (req, res) => {
|
router.post('/:id/articles', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { subject, body, is_visible_for_customer = 0, time_unit, attachments } = req.body;
|
const { subject, body, is_visible_for_customer = 0, time_unit, attachments, ticket_state_id } = req.body;
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
|
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
|
||||||
@@ -1131,6 +1187,27 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (ticket_state_id) {
|
||||||
|
payload.Ticket = { StateID: parseInt(ticket_state_id, 10) };
|
||||||
|
try {
|
||||||
|
const stateTypeRes = await pool.query(
|
||||||
|
`SELECT tst.name AS type_name
|
||||||
|
FROM ticket_state ts
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE ts.id = $1`,
|
||||||
|
[ticket_state_id]
|
||||||
|
);
|
||||||
|
if (stateTypeRes.rows.length > 0) {
|
||||||
|
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
|
||||||
|
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
|
||||||
|
payload.Ticket.LockID = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking state type for REST API payload:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (time_unit) {
|
if (time_unit) {
|
||||||
payload.Article.TimeUnit = parseFloat(time_unit);
|
payload.Article.TimeUnit = parseFloat(time_unit);
|
||||||
payload.Article.TimeUnits = parseFloat(time_unit);
|
payload.Article.TimeUnits = parseFloat(time_unit);
|
||||||
@@ -1182,7 +1259,7 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { subject, body, is_visible_for_customer = 0, time_unit, attachments } = req.body;
|
const { subject, body, is_visible_for_customer = 0, time_unit, attachments, ticket_state_id } = req.body;
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
// Check if this article was already inserted (e.g., via REST API or concurrent request)
|
// Check if this article was already inserted (e.g., via REST API or concurrent request)
|
||||||
@@ -1243,12 +1320,67 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
|
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Verify ticket exists
|
// Verify ticket exists & get current state
|
||||||
const ticketCheck = await client.query('SELECT id FROM ticket WHERE id = $1', [id]);
|
const ticketCheck = await client.query(
|
||||||
|
'SELECT id, ticket_state_id, ticket_priority_id, queue_id, user_id, type_id FROM ticket WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
if (ticketCheck.rows.length === 0) {
|
if (ticketCheck.rows.length === 0) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
return res.status(404).json({ error: 'Ticket not found' });
|
return res.status(404).json({ error: 'Ticket not found' });
|
||||||
}
|
}
|
||||||
|
const currentTicket = ticketCheck.rows[0];
|
||||||
|
|
||||||
|
// Check if ticket state should be updated
|
||||||
|
if (ticket_state_id && parseInt(ticket_state_id, 10) !== currentTicket.ticket_state_id) {
|
||||||
|
const newStateId = parseInt(ticket_state_id, 10);
|
||||||
|
const stateTypeRes = await client.query(
|
||||||
|
`SELECT tst.name AS type_name
|
||||||
|
FROM ticket_state ts
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE ts.id = $1`,
|
||||||
|
[newStateId]
|
||||||
|
);
|
||||||
|
let lockIdToSet = null;
|
||||||
|
if (stateTypeRes.rows.length > 0) {
|
||||||
|
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
|
||||||
|
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
|
||||||
|
lockIdToSet = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lockIdToSet !== null) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET ticket_state_id = $1, ticket_lock_id = $2, change_time = $3, change_by = $4 WHERE id = $5`,
|
||||||
|
[newStateId, lockIdToSet, getLocalTimestamp(), operatorId, id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET ticket_state_id = $1, change_time = $2, change_by = $3 WHERE id = $4`,
|
||||||
|
[newStateId, getLocalTimestamp(), operatorId, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const htResult = await client.query(
|
||||||
|
`SELECT id FROM ticket_history_type WHERE name = 'StateUpdate'`
|
||||||
|
);
|
||||||
|
if (htResult.rows.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ticket_history (
|
||||||
|
name, history_type_id, ticket_id, type_id, queue_id,
|
||||||
|
owner_id, priority_id, state_id, create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
'%%', $1, $2, $3, $4,
|
||||||
|
$5, $6, $7, $8, $9, $8, $9
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
htResult.rows[0].id, id, currentTicket.type_id || 1, currentTicket.queue_id,
|
||||||
|
currentTicket.user_id, currentTicket.ticket_priority_id, newStateId,
|
||||||
|
getLocalTimestamp(), operatorId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get sender type for "agent"
|
// Get sender type for "agent"
|
||||||
const senderResult = await client.query(
|
const senderResult = await client.query(
|
||||||
@@ -1946,9 +2078,9 @@ router.get('/time-accounting/summary', async (req, res) => {
|
|||||||
|
|
||||||
const targetDate = new Date(`${dateStr}T00:00:00Z`);
|
const targetDate = new Date(`${dateStr}T00:00:00Z`);
|
||||||
const startDateObj = new Date(targetDate);
|
const startDateObj = new Date(targetDate);
|
||||||
startDateObj.setDate(startDateObj.getDate() - 7);
|
startDateObj.setDate(startDateObj.getDate() - 14);
|
||||||
const endDateObj = new Date(targetDate);
|
const endDateObj = new Date(targetDate);
|
||||||
endDateObj.setDate(endDateObj.getDate() + 7);
|
endDateObj.setDate(endDateObj.getDate() + 14);
|
||||||
|
|
||||||
const startTime = `${startDateObj.toISOString().slice(0, 10)} 00:00:00`;
|
const startTime = `${startDateObj.toISOString().slice(0, 10)} 00:00:00`;
|
||||||
const endTime = `${endDateObj.toISOString().slice(0, 10)} 23:59:59`;
|
const endTime = `${endDateObj.toISOString().slice(0, 10)} 23:59:59`;
|
||||||
|
|||||||
Reference in new issue
Block a user