fix: aggiunta nota ticket unito. feat: possiblità di visualizzare ticket in schede. feat: possibilità di creare un nuovo gruppo dall'interfaccia del ticket.
This commit is contained in:
@@ -1,4 +1,16 @@
|
||||
const { Pool } = require('pg');
|
||||
const { Pool, types } = require('pg');
|
||||
|
||||
// Override parser for TIMESTAMP WITHOUT TIME ZONE (type 1114) to return Date in UTC timezone
|
||||
types.setTypeParser(1114, function(stringValue) {
|
||||
if (!stringValue) return null;
|
||||
// If it already has a timezone indicator or 'Z', parse normally
|
||||
if (stringValue.endsWith('Z') || stringValue.includes('+') || stringValue.includes('-')) {
|
||||
return new Date(stringValue);
|
||||
}
|
||||
// Standard OTRS timestamps are stored as UTC without offset (YYYY-MM-DD HH:mm:ss).
|
||||
// Appending 'Z' tells JS engine to parse as UTC instead of local time.
|
||||
return new Date(stringValue.replace(' ', 'T') + 'Z');
|
||||
});
|
||||
|
||||
const dbType = (process.env.DB_TYPE || 'postgres').toLowerCase();
|
||||
|
||||
@@ -112,6 +124,16 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
|
||||
connectionLimit: 20,
|
||||
idleTimeout: 30000,
|
||||
connectTimeout: 5000,
|
||||
timezone: '+00:00', // Parse dates from DB as UTC
|
||||
});
|
||||
|
||||
// Ensure the session timezone is UTC for database functions like NOW()
|
||||
this.mysqlPool.on('connection', (connection) => {
|
||||
connection.query("SET time_zone = '+00:00'", (err) => {
|
||||
if (err) {
|
||||
console.error('[DB] Errore nell\'impostazione della time_zone UTC per MariaDB/MySQL:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +179,14 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
options: '-c timezone=UTC', // Ensure connection timezone is UTC
|
||||
});
|
||||
|
||||
// Ensure connection timezone is UTC via query fallback
|
||||
pool.on('connect', (client) => {
|
||||
client.query("SET TIME ZONE 'UTC'").catch(err => {
|
||||
console.error('[DB] Errore nell\'impostazione della timezone UTC per Postgres:', err);
|
||||
});
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
|
||||
+74
-1
@@ -1161,10 +1161,15 @@ body {
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
background: linear-gradient(135deg, rgba(160, 65, 71, 0.15), rgba(160, 65, 71, 0.15));
|
||||
background-color: var(--bg-card);
|
||||
background-image: linear-gradient(135deg, rgba(160, 65, 71, 0.15), rgba(160, 65, 71, 0.15));
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: var(--radius-lg);
|
||||
animation: slideDown 0.2s ease-out;
|
||||
position: sticky;
|
||||
top: var(--topbar-height);
|
||||
z-index: 45;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
@@ -2494,3 +2499,71 @@ body {
|
||||
padding: 8px 12px !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* ---- Ticket Tab Bar System ---- */
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* Wraps to new line if too many tabs */
|
||||
align-items: stretch;
|
||||
background: var(--bg-secondary);
|
||||
padding: 0;
|
||||
}
|
||||
.tab-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
border-radius: 0 !important; /* Square corners */
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all var(--transition-fast);
|
||||
margin: 0 !important;
|
||||
}
|
||||
.tab-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tab-item.active {
|
||||
background: var(--bg-primary); /* Blend with main content background */
|
||||
color: var(--accent-primary);
|
||||
border-bottom: 2px solid var(--accent-primary);
|
||||
}
|
||||
.tab-item .tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tab-item .tab-close:hover {
|
||||
opacity: 1;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* ---- Full Bleed Ticket View Area ---- */
|
||||
#view-container.ticket-view-active {
|
||||
padding: 0 !important;
|
||||
}
|
||||
#view-container.ticket-view-active .back-link {
|
||||
margin: var(--space-md) var(--space-xl) var(--space-xs);
|
||||
display: inline-flex;
|
||||
}
|
||||
#view-container.ticket-view-active .ticket-detail {
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
#view-container.ticket-view-active .ticket-detail .card {
|
||||
border-radius: 0 !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
+47
-43
@@ -123,54 +123,58 @@
|
||||
<!-- Main Content -->
|
||||
<main class="main-content" id="main-content">
|
||||
<!-- Top Bar -->
|
||||
<header class="topbar" id="topbar">
|
||||
<div class="topbar-left" style="display:flex; align-items:center; gap:var(--space-md);">
|
||||
<h1 class="page-title" id="page-title">Dashboard</h1>
|
||||
<div style="display:flex; align-items:center; gap:var(--space-xs);">
|
||||
<select class="form-select" id="active-agent-select"
|
||||
style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 180px; margin: 0; background-position: right 10px center; border-color: var(--border-light);"></select>
|
||||
<button class="btn btn-ghost btn-sm" id="refresh-lookups-btn"
|
||||
style="height:36px; padding:0 10px; min-width:36px;" title="Aggiorna dati locali (code, utenti)">
|
||||
<header class="topbar" id="topbar" style="height: auto; padding: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0;">
|
||||
<div class="topbar-main" style="display: flex; align-items: center; justify-content: space-between; width: 100%; height: var(--topbar-height); padding: 0 var(--space-xl);">
|
||||
<div class="topbar-left" style="display:flex; align-items:center; gap:var(--space-md);">
|
||||
<h1 class="page-title" id="page-title">Dashboard</h1>
|
||||
<div style="display:flex; align-items:center; gap:var(--space-xs);">
|
||||
<select class="form-select" id="active-agent-select"
|
||||
style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 180px; margin: 0; background-position: right 10px center; border-color: var(--border-light);"></select>
|
||||
<button class="btn btn-ghost btn-sm" id="refresh-lookups-btn"
|
||||
style="height:36px; padding:0 10px; min-width:36px;" title="Aggiorna dati locali (code, utenti)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
|
||||
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
|
||||
</svg>
|
||||
</button>
|
||||
<select class="form-select" id="theme-select"
|
||||
style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 130px; margin: 0; background-position: right 10px center; border-color: var(--border-light);">
|
||||
<option value="light">Tema Chiaro</option>
|
||||
<option value="dark">Tema Scuro</option>
|
||||
<option value="rosso">Rosso</option>
|
||||
<option value="naturale">Naturale</option>
|
||||
<option value="ice">Ice</option>
|
||||
<option value="autunno">Autunno</option>
|
||||
<option value="fairytale">Fairytale</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="search-bar" id="global-search-container">
|
||||
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input type="text" class="search-input" id="global-search"
|
||||
placeholder="Cerca ticket (numero, titolo o corpo)..." style="padding-right: 32px;" />
|
||||
<button id="global-search-clear"
|
||||
style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-muted); display: none; align-items: center; justify-content: center;"
|
||||
title="Cancella ricerca">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
style="width:14px;height:14px;display:block;">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="topbar-new-ticket" onclick="window.location.hash='#/tickets/new'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
|
||||
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
|
||||
</svg>
|
||||
</button>
|
||||
<select class="form-select" id="theme-select"
|
||||
style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 130px; margin: 0; background-position: right 10px center; border-color: var(--border-light);">
|
||||
<option value="light">Tema Chiaro</option>
|
||||
<option value="dark">Tema Scuro</option>
|
||||
<option value="rosso">Rosso</option>
|
||||
<option value="naturale">Naturale</option>
|
||||
<option value="ice">Ice</option>
|
||||
<option value="autunno">Autunno</option>
|
||||
<option value="fairytale">Fairytale</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="search-bar" id="global-search-container">
|
||||
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input type="text" class="search-input" id="global-search"
|
||||
placeholder="Cerca ticket (numero, titolo o corpo)..." style="padding-right: 32px;" />
|
||||
<button id="global-search-clear"
|
||||
style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-muted); display: none; align-items: center; justify-content: center;"
|
||||
title="Cancella ricerca">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
style="width:14px;height:14px;display:block;">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Nuovo
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="topbar-new-ticket" onclick="window.location.hash='#/tickets/new'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Nuovo
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tabs Bar -->
|
||||
<div id="tabs-bar" class="tabs-bar" style="display:none; border-top: 1px solid var(--border-subtle);"></div>
|
||||
</header>
|
||||
|
||||
<!-- View Container -->
|
||||
|
||||
+125
-1
@@ -13,6 +13,108 @@ const App = {
|
||||
lookupsLoaded: false,
|
||||
demotivationalPhrases: [],
|
||||
motivationalPhrases: [],
|
||||
drafts: {},
|
||||
tabs: [],
|
||||
|
||||
loadTabs() {
|
||||
try {
|
||||
const saved = localStorage.getItem('otrs_turbo_tabs');
|
||||
if (saved) this.tabs = JSON.parse(saved);
|
||||
const savedDrafts = localStorage.getItem('otrs_turbo_drafts');
|
||||
if (savedDrafts) this.drafts = JSON.parse(savedDrafts);
|
||||
} catch (e) {}
|
||||
this.renderTabs();
|
||||
},
|
||||
|
||||
saveTabs() {
|
||||
localStorage.setItem('otrs_turbo_tabs', JSON.stringify(this.tabs));
|
||||
this.renderTabs();
|
||||
},
|
||||
|
||||
saveDraft(ticketId, draft) {
|
||||
if (!this.drafts[ticketId]) this.drafts[ticketId] = {};
|
||||
this.drafts[ticketId][draft.type] = draft;
|
||||
localStorage.setItem('otrs_turbo_drafts', JSON.stringify(this.drafts));
|
||||
},
|
||||
|
||||
getDraft(ticketId, type) {
|
||||
return this.drafts[ticketId] ? this.drafts[ticketId][type] : null;
|
||||
},
|
||||
|
||||
clearDraft(ticketId, type) {
|
||||
if (this.drafts[ticketId] && this.drafts[ticketId][type]) {
|
||||
delete this.drafts[ticketId][type];
|
||||
if (Object.keys(this.drafts[ticketId]).length === 0) {
|
||||
delete this.drafts[ticketId];
|
||||
}
|
||||
localStorage.setItem('otrs_turbo_drafts', JSON.stringify(this.drafts));
|
||||
}
|
||||
},
|
||||
|
||||
openTab(id, tn, title) {
|
||||
const exists = this.tabs.find(t => String(t.id) === String(id));
|
||||
if (!exists) {
|
||||
this.tabs.push({ id, tn, title });
|
||||
this.saveTabs();
|
||||
}
|
||||
},
|
||||
|
||||
addTabWithoutRedirect(id, tn, title) {
|
||||
const exists = this.tabs.find(t => String(t.id) === String(id));
|
||||
if (!exists) {
|
||||
this.tabs.push({ id, tn, title });
|
||||
this.saveTabs();
|
||||
} else {
|
||||
if (title && exists.title !== title) {
|
||||
exists.title = title;
|
||||
this.saveTabs();
|
||||
} else {
|
||||
this.renderTabs();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
closeTab(id, e) {
|
||||
if (e) e.stopPropagation();
|
||||
this.tabs = this.tabs.filter(t => String(t.id) !== String(id));
|
||||
this.saveTabs();
|
||||
|
||||
// Clear drafts for closed tab
|
||||
delete this.drafts[id];
|
||||
localStorage.setItem('otrs_turbo_drafts', JSON.stringify(this.drafts));
|
||||
|
||||
const hash = window.location.hash;
|
||||
if (hash === `#/tickets/${id}`) {
|
||||
if (this.tabs.length > 0) {
|
||||
window.location.hash = `#/tickets/${this.tabs[this.tabs.length - 1].id}`;
|
||||
} else {
|
||||
window.location.hash = '#/tickets';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderTabs() {
|
||||
const bar = document.getElementById('tabs-bar');
|
||||
if (!bar) return;
|
||||
if (this.tabs.length === 0) {
|
||||
bar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'flex';
|
||||
|
||||
const currentHash = window.location.hash;
|
||||
|
||||
bar.innerHTML = this.tabs.map(t => {
|
||||
const isActive = currentHash === `#/tickets/${t.id}`;
|
||||
const displayTitle = t.title ? (t.title.length > 25 ? t.title.substring(0, 22) + '...' : t.title) : `#${t.tn}`;
|
||||
return `
|
||||
<div class="tab-item ${isActive ? 'active' : ''}" onclick="window.location.hash = '#/tickets/${t.id}'" title="${App.escapeHtml(t.title || '')}">
|
||||
<span>${App.escapeHtml(displayTitle)}</span>
|
||||
<button class="tab-close" onclick="App.closeTab(${t.id}, event)">✕</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
},
|
||||
|
||||
get currentAgentId() {
|
||||
return parseInt(localStorage.getItem('activeAgentId') || '1', 10);
|
||||
@@ -23,6 +125,7 @@ const App = {
|
||||
this.initTheme();
|
||||
this.loadDemotivationalPhrases();
|
||||
this.loadMotivationalPhrases();
|
||||
this.loadTabs();
|
||||
Toast.init();
|
||||
|
||||
// Hash-based SPA router
|
||||
@@ -112,6 +215,27 @@ const App = {
|
||||
const hash = fullHash.split('?')[0];
|
||||
const titleEl = document.getElementById('page-title');
|
||||
|
||||
// Save draft for previous ticket before routing
|
||||
if (typeof TicketDetailView !== 'undefined' && TicketDetailView.ticketId) {
|
||||
TicketDetailView.saveDraft();
|
||||
if (typeof EmailCompose !== 'undefined') {
|
||||
EmailCompose.saveDraft();
|
||||
const overlay = document.getElementById('email-compose-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
this.renderTabs();
|
||||
|
||||
const container = document.getElementById('view-container');
|
||||
if (container) {
|
||||
if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
||||
container.classList.add('ticket-view-active');
|
||||
} else {
|
||||
container.classList.remove('ticket-view-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Update active nav link
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
@@ -505,7 +629,7 @@ const App = {
|
||||
|
||||
if (timerEl) {
|
||||
timerEl.classList.add('clickable-auto-time');
|
||||
timerEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m). Clicca per eseguire.`);
|
||||
//timerEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m). Clicca per eseguire.`);
|
||||
timerEl.onclick = triggerAction;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -681,7 +681,13 @@ const Filters = {
|
||||
const presetId = e.target.value;
|
||||
if (!presetId) {
|
||||
this.selectedPresetId = null;
|
||||
if (onFilterChange) onFilterChange();
|
||||
const resetBtn = document.getElementById('filter-reset');
|
||||
if (resetBtn) {
|
||||
resetBtn.click();
|
||||
} else {
|
||||
this.reset();
|
||||
if (onFilterChange) onFilterChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+136
-19
@@ -8,6 +8,9 @@ const EmailCompose = (() => {
|
||||
let quillEditor = null;
|
||||
let attachmentsList = [];
|
||||
let currentOptions = {};
|
||||
let toTagsCtrl = null;
|
||||
let ccTagsCtrl = null;
|
||||
let bccTagsCtrl = null;
|
||||
|
||||
// ── CSS ──────────────────────────────────────────────────────────────────────
|
||||
function injectStyles() {
|
||||
@@ -70,6 +73,10 @@ const EmailCompose = (() => {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb,99,102,241),0.12);
|
||||
}
|
||||
#email-compose-modal .ec-tags-input.drag-over {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(99,102,241,0.06);
|
||||
}
|
||||
#email-compose-modal .ec-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: var(--accent-primary); color: #fff;
|
||||
@@ -145,7 +152,7 @@ const EmailCompose = (() => {
|
||||
}
|
||||
|
||||
// ── Tag Input Helper ──────────────────────────────────────────────────────────
|
||||
function makeTagInput(containerId, initialEmails = [], onFocus = null) {
|
||||
function makeTagInput(containerId, initialEmails = [], onFocus = null, controllers = null) {
|
||||
const container = document.getElementById(containerId);
|
||||
const tags = [...initialEmails];
|
||||
|
||||
@@ -156,8 +163,16 @@ const EmailCompose = (() => {
|
||||
tags.forEach((email, idx) => {
|
||||
const tagEl = document.createElement('span');
|
||||
tagEl.className = 'ec-tag';
|
||||
tagEl.draggable = true;
|
||||
tagEl.innerHTML = `${App.escapeHtml(email)}<button type="button" data-idx="${idx}">✕</button>`;
|
||||
tagEl.querySelector('button').addEventListener('click', () => {
|
||||
|
||||
tagEl.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', email);
|
||||
e.dataTransfer.setData('source-container-id', containerId);
|
||||
});
|
||||
|
||||
tagEl.querySelector('button').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
tags.splice(idx, 1);
|
||||
render();
|
||||
});
|
||||
@@ -197,8 +212,48 @@ const EmailCompose = (() => {
|
||||
container.addEventListener('click', () => input.focus());
|
||||
}
|
||||
|
||||
container.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
container.classList.add('drag-over');
|
||||
});
|
||||
|
||||
container.addEventListener('dragleave', () => {
|
||||
container.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
container.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
container.classList.remove('drag-over');
|
||||
const email = e.dataTransfer.getData('text/plain');
|
||||
const sourceContainerId = e.dataTransfer.getData('source-container-id');
|
||||
if (email && sourceContainerId && sourceContainerId !== containerId && controllers) {
|
||||
const sourceCtrl = controllers[sourceContainerId];
|
||||
if (sourceCtrl) {
|
||||
sourceCtrl.removeTag(email);
|
||||
ctrl.addTag(email);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
return { getTags: () => [...tags], addTag: (email) => { if (!tags.includes(email)) { tags.push(email); render(); } } };
|
||||
|
||||
const ctrl = {
|
||||
getTags: () => [...tags],
|
||||
addTag: (email) => {
|
||||
if (!tags.includes(email)) {
|
||||
tags.push(email);
|
||||
render();
|
||||
}
|
||||
},
|
||||
removeTag: (email) => {
|
||||
const idx = tags.indexOf(email);
|
||||
if (idx > -1) {
|
||||
tags.splice(idx, 1);
|
||||
render();
|
||||
}
|
||||
}
|
||||
};
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
// ── Build Modal HTML ──────────────────────────────────────────────────────────
|
||||
@@ -356,19 +411,30 @@ const EmailCompose = (() => {
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Init tag inputs with focus tracking
|
||||
const initialTo = options.initialTo || (options.customerEmail ? [options.customerEmail] : []);
|
||||
const initialCc = options.initialCc || [];
|
||||
const draft = options.draft;
|
||||
const initialTo = draft ? draft.to : (options.initialTo || (options.customerEmail ? [options.customerEmail] : []));
|
||||
const initialCc = draft ? draft.cc : (options.initialCc || []);
|
||||
const initialBcc = draft ? draft.bcc : [];
|
||||
|
||||
let lastFocusedCtrl = null;
|
||||
const toTagsCtrl = makeTagInput('ec-to-container', initialTo, () => { lastFocusedCtrl = toTagsCtrl; });
|
||||
const ccTagsCtrl = makeTagInput('ec-cc-container', initialCc, () => { lastFocusedCtrl = ccTagsCtrl; });
|
||||
const bccTagsCtrl = makeTagInput('ec-bcc-container', [], () => { lastFocusedCtrl = bccTagsCtrl; });
|
||||
const controllers = {};
|
||||
toTagsCtrl = makeTagInput('ec-to-container', initialTo, () => { lastFocusedCtrl = toTagsCtrl; }, controllers);
|
||||
ccTagsCtrl = makeTagInput('ec-cc-container', initialCc, () => { lastFocusedCtrl = ccTagsCtrl; }, controllers);
|
||||
bccTagsCtrl = makeTagInput('ec-bcc-container', initialBcc, () => { lastFocusedCtrl = bccTagsCtrl; }, controllers);
|
||||
controllers['ec-to-container'] = toTagsCtrl;
|
||||
controllers['ec-cc-container'] = ccTagsCtrl;
|
||||
controllers['ec-bcc-container'] = bccTagsCtrl;
|
||||
lastFocusedCtrl = toTagsCtrl;
|
||||
|
||||
// Subject
|
||||
const subjectEl = document.getElementById('ec-subject');
|
||||
const tn = options.ticketTn || '';
|
||||
const title = options.ticketTitle || '';
|
||||
subjectEl.value = tn ? `Re: [Ticket#${tn}] ${title}` : title;
|
||||
if (draft) {
|
||||
subjectEl.value = draft.subject || '';
|
||||
} else {
|
||||
const tn = options.ticketTn || '';
|
||||
const title = options.ticketTitle || '';
|
||||
subjectEl.value = tn ? `Re: [Ticket#${tn}] ${title}` : title;
|
||||
}
|
||||
|
||||
// Signature and groups select
|
||||
const sigSelect = document.getElementById('ec-signature-select');
|
||||
@@ -377,6 +443,11 @@ const EmailCompose = (() => {
|
||||
const defaultSigHtml = await loadSignatures(agentId, sigSelect);
|
||||
await loadAddressGroups(agentId, groupsSelect);
|
||||
|
||||
if (draft) {
|
||||
sigSelect.value = draft.signature || '';
|
||||
document.getElementById('ec-helpdesk-cc-select').value = draft.helpdeskCc || '0';
|
||||
}
|
||||
|
||||
groupsSelect.addEventListener('change', () => {
|
||||
const selectedOpt = groupsSelect.options[groupsSelect.selectedIndex];
|
||||
if (!selectedOpt || !selectedOpt.value) return;
|
||||
@@ -410,13 +481,21 @@ const EmailCompose = (() => {
|
||||
|
||||
// Insert initial body and signature
|
||||
let initialHtml = '';
|
||||
if (options.initialBodyHtml) {
|
||||
initialHtml += options.initialBodyHtml;
|
||||
if (draft) {
|
||||
if (draft.body) {
|
||||
initialHtml = draft.body;
|
||||
}
|
||||
attachmentsList = draft.attachments || [];
|
||||
renderFileList();
|
||||
} else {
|
||||
initialHtml += '<p><br></p>';
|
||||
}
|
||||
if (defaultSigHtml) {
|
||||
initialHtml += '<!-- sig -->' + defaultSigHtml;
|
||||
if (options.initialBodyHtml) {
|
||||
initialHtml += options.initialBodyHtml;
|
||||
} else {
|
||||
initialHtml += '<p><br></p>';
|
||||
}
|
||||
if (defaultSigHtml) {
|
||||
initialHtml += '<!-- sig -->' + defaultSigHtml;
|
||||
}
|
||||
}
|
||||
quillEditor.clipboard.dangerouslyPasteHTML(initialHtml);
|
||||
quillEditor.setSelection(0, 0);
|
||||
@@ -454,7 +533,15 @@ const EmailCompose = (() => {
|
||||
// Close handlers
|
||||
document.getElementById('ec-close').addEventListener('click', close);
|
||||
document.getElementById('ec-cancel').addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
saveDraft();
|
||||
const ov = document.getElementById('email-compose-overlay');
|
||||
if (ov) ov.remove();
|
||||
if (quillEditor) { quillEditor = null; }
|
||||
attachmentsList = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Send
|
||||
document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl, bccTagsCtrl));
|
||||
@@ -505,6 +592,7 @@ const EmailCompose = (() => {
|
||||
});
|
||||
|
||||
Toast.success(`Email inviata a ${to.join(', ')}`);
|
||||
App.clearDraft(currentOptions.ticketId, 'email');
|
||||
close();
|
||||
} catch (err) {
|
||||
Toast.error('Errore invio email: ' + err.message);
|
||||
@@ -515,12 +603,41 @@ const EmailCompose = (() => {
|
||||
|
||||
// ── Close ─────────────────────────────────────────────────────────────────────
|
||||
function close() {
|
||||
if (currentOptions.ticketId) {
|
||||
App.clearDraft(currentOptions.ticketId, 'email');
|
||||
}
|
||||
const overlay = document.getElementById('email-compose-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
if (quillEditor) { quillEditor = null; }
|
||||
attachmentsList = [];
|
||||
}
|
||||
|
||||
return { open, close };
|
||||
function saveDraft() {
|
||||
const overlay = document.getElementById('email-compose-overlay');
|
||||
if (!overlay || !currentOptions.ticketId) return;
|
||||
|
||||
const to = toTagsCtrl ? toTagsCtrl.getTags() : [];
|
||||
const cc = ccTagsCtrl ? ccTagsCtrl.getTags() : [];
|
||||
const bcc = bccTagsCtrl ? bccTagsCtrl.getTags() : [];
|
||||
const subject = document.getElementById('ec-subject') ? document.getElementById('ec-subject').value.trim() : '';
|
||||
const body = quillEditor ? quillEditor.root.innerHTML.trim() : '';
|
||||
const signature = document.getElementById('ec-signature-select') ? document.getElementById('ec-signature-select').value : '';
|
||||
const helpdeskCc = document.getElementById('ec-helpdesk-cc-select') ? document.getElementById('ec-helpdesk-cc-select').value : '0';
|
||||
|
||||
App.saveDraft(currentOptions.ticketId, {
|
||||
type: 'email',
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject,
|
||||
body,
|
||||
signature,
|
||||
helpdeskCc,
|
||||
attachments: [...attachmentsList],
|
||||
options: currentOptions
|
||||
});
|
||||
}
|
||||
|
||||
return { open, close, saveDraft };
|
||||
})();
|
||||
window.EmailCompose = EmailCompose;
|
||||
|
||||
@@ -8,6 +8,25 @@ const TicketDetailView = {
|
||||
originalValues: {},
|
||||
noteAttachments: [],
|
||||
|
||||
saveDraft() {
|
||||
if (!this.ticketId) return;
|
||||
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
|
||||
const subject = document.getElementById('note-subject') ? document.getElementById('note-subject').value.trim() : '';
|
||||
const time_unit = document.getElementById('note-time-units') ? document.getElementById('note-time-units').value.trim() : '';
|
||||
|
||||
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0) {
|
||||
App.saveDraft(this.ticketId, {
|
||||
type: 'note',
|
||||
body,
|
||||
subject,
|
||||
time_unit,
|
||||
attachments: [...this.noteAttachments]
|
||||
});
|
||||
} else {
|
||||
App.clearDraft(this.ticketId, 'note');
|
||||
}
|
||||
},
|
||||
|
||||
updateNoteAttachmentList() {
|
||||
const listEl = document.getElementById('note-file-list');
|
||||
if (!listEl) return;
|
||||
@@ -41,6 +60,7 @@ const TicketDetailView = {
|
||||
await App.ensureLookups();
|
||||
const data = await App.api(`/api/tickets/${id}`);
|
||||
const { ticket, articles, attachments } = data;
|
||||
App.addTabWithoutRedirect(ticket.id, ticket.tn, ticket.title);
|
||||
|
||||
let groupsData = { asMaster: [], asMember: [] };
|
||||
try {
|
||||
@@ -75,6 +95,7 @@ const TicketDetailView = {
|
||||
queue_id: ticket.queue_id,
|
||||
queue_name: ticket.queue_name,
|
||||
user_id: ticket.user_id,
|
||||
responsible_user_id: ticket.responsible_user_id,
|
||||
type_id: ticket.type_id,
|
||||
customer_id: ticket.customer_id,
|
||||
customer_user_id: ticket.customer_user_id,
|
||||
@@ -143,6 +164,15 @@ const TicketDetailView = {
|
||||
<select class="quick-edit-select" id="qe-owner" data-field="user_id">
|
||||
${(App.lookups.users || []).map(u =>
|
||||
`<option value="${u.id}" ${u.id === ticket.user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="quick-edit-field">
|
||||
<label class="quick-edit-label">Responsabile</label>
|
||||
<select class="quick-edit-select" id="qe-responsible" data-field="responsible_user_id">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.users || []).map(u =>
|
||||
`<option value="${u.id}" ${u.id === ticket.responsible_user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
@@ -333,12 +363,14 @@ const TicketDetailView = {
|
||||
<span class="meta-value">${ticket.type_name}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${ticket.responsible_first ? `
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Responsabile</span>
|
||||
<span class="meta-value">${ticket.responsible_first} ${ticket.responsible_last}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Owner</span>
|
||||
<span class="meta-value">${ticket.owner_first ? `${ticket.owner_first} ${ticket.owner_last}` : (ticket.owner_login || '—')}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Responsabile</span>
|
||||
<span class="meta-value">${ticket.responsible_first ? `${ticket.responsible_first} ${ticket.responsible_last}` : '—'}</span>
|
||||
</div>
|
||||
${totalTime > 0 ? `
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Tempo Totale</span>
|
||||
@@ -457,6 +489,36 @@ const TicketDetailView = {
|
||||
this.noteQuill = null;
|
||||
}
|
||||
|
||||
// Restore Note Draft
|
||||
const noteDraft = App.getDraft(id, 'note');
|
||||
if (noteDraft) {
|
||||
if (this.noteQuill && noteDraft.body) {
|
||||
this.noteQuill.root.innerHTML = noteDraft.body;
|
||||
}
|
||||
if (document.getElementById('note-subject')) {
|
||||
document.getElementById('note-subject').value = noteDraft.subject || '';
|
||||
}
|
||||
if (document.getElementById('note-time-units')) {
|
||||
document.getElementById('note-time-units').value = noteDraft.time_unit || '';
|
||||
}
|
||||
this.noteAttachments = noteDraft.attachments || [];
|
||||
this.updateNoteAttachmentList();
|
||||
}
|
||||
|
||||
// Restore Email Compose Draft
|
||||
const emailDraft = App.getDraft(id, 'email');
|
||||
if (emailDraft) {
|
||||
// Re-open Email Compose with draft options
|
||||
setTimeout(() => {
|
||||
if (typeof EmailCompose !== 'undefined') {
|
||||
EmailCompose.open({
|
||||
...emailDraft.options,
|
||||
draft: emailDraft
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
this.bindEvents(ticket, articles, container, groupsData);
|
||||
|
||||
} catch (err) {
|
||||
@@ -760,6 +822,7 @@ const TicketDetailView = {
|
||||
|
||||
this.noteAttachments = []; // Clear attachments
|
||||
Toast.success(res.message || 'Nota aggiunta!');
|
||||
App.clearDraft(this.ticketId, 'note');
|
||||
App.updateDailyTimer();
|
||||
this.render(this.ticketId);
|
||||
} catch (err) {
|
||||
@@ -1107,7 +1170,11 @@ const TicketDetailView = {
|
||||
btnAssociate.addEventListener('click', async () => {
|
||||
const select = document.getElementById('group-association-select');
|
||||
const groupId = select.value;
|
||||
if (!groupId) return;
|
||||
if (!groupId) {
|
||||
sessionStorage.setItem('otrs_create_group_with_master_tn', ticket.tn);
|
||||
window.location.hash = '#/tickets/groups';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
btnAssociate.disabled = true;
|
||||
|
||||
@@ -80,7 +80,15 @@ const TicketGroupsView = {
|
||||
|
||||
await this.loadGroups();
|
||||
|
||||
if (this.selectedGroupId) {
|
||||
const prefillMasterTn = sessionStorage.getItem('otrs_create_group_with_master_tn');
|
||||
if (prefillMasterTn) {
|
||||
sessionStorage.removeItem('otrs_create_group_with_master_tn');
|
||||
this.openGroupModal();
|
||||
const masterInput = document.getElementById('group-master-input');
|
||||
if (masterInput) {
|
||||
masterInput.value = prefillMasterTn;
|
||||
}
|
||||
} else if (this.selectedGroupId) {
|
||||
this.selectGroup(this.selectedGroupId);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -109,6 +109,7 @@ const TicketListView = {
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: var(--accent-secondary); border-color: var(--accent-secondary); margin-left: 8px;">Unisci Selezionati</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||||
</div>
|
||||
|
||||
@@ -147,6 +148,7 @@ const TicketListView = {
|
||||
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; vertical-align:middle; line-height:16px;">O</span>
|
||||
</a>
|
||||
` : ''}
|
||||
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
|
||||
</span>
|
||||
</td>
|
||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
|
||||
@@ -304,6 +306,39 @@ const TicketListView = {
|
||||
batchMerge.addEventListener('click', () => this.mergeBatch());
|
||||
}
|
||||
|
||||
// Batch open tabs
|
||||
const batchOpenTabsBtn = document.getElementById('batch-open-tabs');
|
||||
if (batchOpenTabsBtn) {
|
||||
batchOpenTabsBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.selectedIds.size === 0) return;
|
||||
|
||||
let count = 0;
|
||||
this.selectedIds.forEach(id => {
|
||||
const row = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${id}"]`);
|
||||
if (row) {
|
||||
const tnLink = row.querySelector('.ticket-tn-link');
|
||||
const titleLink = row.querySelector('.ticket-title-link');
|
||||
const tn = tnLink ? tnLink.textContent.trim() : '';
|
||||
const title = titleLink ? titleLink.textContent.trim() : '';
|
||||
App.openTab(parseInt(id, 10), tn, title);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
Toast.success(`${count} ticket aperti in nuove schede!`);
|
||||
this.selectedIds.clear();
|
||||
this.selectedOrder = [];
|
||||
document.querySelectorAll('.ticket-table tbody tr').forEach(tr => {
|
||||
tr.classList.remove('selected');
|
||||
tr.classList.remove('first-selected');
|
||||
});
|
||||
this.updateBatchBar();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Batch Copy Ticket Numbers
|
||||
const batchCopyTns = document.getElementById('batch-copy-tns');
|
||||
if (batchCopyTns) {
|
||||
@@ -505,6 +540,16 @@ const TicketListView = {
|
||||
mergeBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Enable/disable open tabs button
|
||||
const openTabsBtn = document.getElementById('batch-open-tabs');
|
||||
if (openTabsBtn) {
|
||||
if (this.selectedIds.size > 0) {
|
||||
openTabsBtn.disabled = false;
|
||||
} else {
|
||||
openTabsBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async applyBatch() {
|
||||
|
||||
+31
-2
@@ -742,7 +742,7 @@ router.patch('/:id', async (req, res) => {
|
||||
let current;
|
||||
try {
|
||||
const currentResult = await pool.query(
|
||||
`SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id
|
||||
`SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, responsible_user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id
|
||||
FROM ticket WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
@@ -763,6 +763,7 @@ router.patch('/:id', async (req, res) => {
|
||||
if (updates.ticket_priority_id !== undefined) ticketFields.PriorityID = updates.ticket_priority_id;
|
||||
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
||||
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
||||
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
||||
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||
if (updates.title !== undefined) ticketFields.Title = updates.title;
|
||||
if (updates.ticket_lock_id !== undefined) ticketFields.LockID = updates.ticket_lock_id;
|
||||
@@ -854,7 +855,7 @@ router.patch('/:id', async (req, res) => {
|
||||
const setParams = [];
|
||||
let pIdx = 1;
|
||||
|
||||
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id', 'type_id', 'title', 'ticket_lock_id', 'customer_id', 'customer_user_id'];
|
||||
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id', 'responsible_user_id', 'type_id', 'title', 'ticket_lock_id', 'customer_id', 'customer_user_id'];
|
||||
for (const field of allowedFields) {
|
||||
if (updates[field] !== undefined && updates[field] !== current[field]) {
|
||||
setClauses.push(`${field} = $${pIdx++}`);
|
||||
@@ -895,6 +896,7 @@ router.patch('/:id', async (req, res) => {
|
||||
ticket_priority_id: 'PriorityUpdate',
|
||||
queue_id: 'Move',
|
||||
user_id: 'OwnerUpdate',
|
||||
responsible_user_id: 'ResponsibleUpdate',
|
||||
type_id: 'TypeUpdate',
|
||||
ticket_lock_id: 'Lock',
|
||||
customer_id: 'CustomerUpdate',
|
||||
@@ -1795,6 +1797,33 @@ router.post('/merge', async (req, res) => {
|
||||
)`,
|
||||
[articleId, mergeNoteBody, contentPath, operatorId]
|
||||
);
|
||||
|
||||
// Create internal system note inside source ticket B (the merged ticket)
|
||||
const sourceArtResult = await client.query(
|
||||
`INSERT INTO article (
|
||||
ticket_id, article_sender_type_id, communication_channel_id,
|
||||
is_visible_for_customer, search_index_needs_rebuild,
|
||||
create_time, create_by, change_time, change_by
|
||||
) VALUES ($1, 1, 1, 0, 1, NOW(), $2, NOW(), $2) RETURNING id`,
|
||||
[sourceId, operatorId]
|
||||
);
|
||||
const sourceArticleId = sourceArtResult.rows[0].id;
|
||||
const sourceMergeNoteText = `Merged Ticket ${sourceTn} to ${targetTn}`;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO article_data_mime (
|
||||
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
|
||||
a_message_id, a_in_reply_to, a_references,
|
||||
a_content_type, incoming_time, content_path,
|
||||
create_time, create_by, change_time, change_by
|
||||
) VALUES (
|
||||
$1, 'Sistema OTRS Turbo', '', '', '', '', $2, $2,
|
||||
'', '', '',
|
||||
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
|
||||
NOW(), $4, NOW(), $4
|
||||
)`,
|
||||
[sourceArticleId, sourceMergeNoteText, contentPath, operatorId]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
Reference in New Issue
Block a user