feat: aggiunta ricerca spudorata. progress: ripreso con il tentativo di invio delle mail che non creino nuovi ticket
This commit is contained in:
1 parent
3650bf5790
commit
8d24759a15
18 files changed
+1661
-98
No files matched your search
@@ -106,6 +106,17 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS env_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL DEFAULT 1,
|
||||||
|
agent_name TEXT NOT NULL DEFAULT '',
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN color TEXT`);
|
db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN color TEXT`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -154,6 +165,12 @@ try {
|
|||||||
// Column already exists
|
// Column already exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE agent_settings ADD COLUMN use_agent_sender INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a UUID v7 (time-ordered).
|
* Generate a UUID v7 (time-ordered).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1878,15 +1878,17 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
max-height: 220px;
|
max-height: 280px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
|
min-width: 480px;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 85vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-suggestion-item {
|
.autocomplete-suggestion-item {
|
||||||
@@ -1896,6 +1898,7 @@ body {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-suggestion-item:hover {
|
.autocomplete-suggestion-item:hover {
|
||||||
|
|||||||
@@ -70,6 +70,16 @@
|
|||||||
<span class="nav-badge" id="my-ticket-count"></span>
|
<span class="nav-badge" id="my-ticket-count"></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/ricerca-spudorata" class="nav-link" data-view="ricerca-spudorata" id="nav-ricerca-spudorata">
|
||||||
|
<svg class="nav-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" />
|
||||||
|
<path d="M11 8v6M8 11h6" />
|
||||||
|
</svg>
|
||||||
|
<span>Ricerca Spudorata</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/tickets/groups" class="nav-link" data-view="ticket-groups" id="nav-ticket-groups">
|
<a href="#/tickets/groups" class="nav-link" data-view="ticket-groups" id="nav-ticket-groups">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -111,6 +121,17 @@
|
|||||||
<span>Gestione Mail</span>
|
<span>Gestione Mail</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/config-env" class="nav-link" data-view="config-env" id="nav-config-env">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
<span>Modifica .env</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/time-accounting" class="nav-link" data-view="time-accounting" id="nav-time-accounting">
|
<a href="#/time-accounting" class="nav-link" data-view="time-accounting" id="nav-time-accounting">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -216,6 +237,7 @@
|
|||||||
<script src="/js/components/filters.js"></script>
|
<script src="/js/components/filters.js"></script>
|
||||||
<script src="/js/views/dashboard.js"></script>
|
<script src="/js/views/dashboard.js"></script>
|
||||||
<script src="/js/views/ticketList.js"></script>
|
<script src="/js/views/ticketList.js"></script>
|
||||||
|
<script src="/js/views/ricercaSpudorata.js"></script>
|
||||||
<script src="/js/views/ticketDetail.js"></script>
|
<script src="/js/views/ticketDetail.js"></script>
|
||||||
<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>
|
||||||
@@ -224,6 +246,7 @@
|
|||||||
<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>
|
||||||
<script src="/js/views/ticketGroups.js"></script>
|
<script src="/js/views/ticketGroups.js"></script>
|
||||||
|
<script src="/js/views/envConfig.js"></script>
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,10 @@ const App = {
|
|||||||
titleEl.textContent = 'Ticket a mio carico';
|
titleEl.textContent = 'Ticket a mio carico';
|
||||||
TicketListView.render();
|
TicketListView.render();
|
||||||
|
|
||||||
|
} else if (hash === '#/ricerca-spudorata') {
|
||||||
|
titleEl.textContent = 'Ricerca Spudorata';
|
||||||
|
RicercaSpudorataView.render();
|
||||||
|
|
||||||
} else if (hash === '#/tickets/new') {
|
} else if (hash === '#/tickets/new') {
|
||||||
document.getElementById('nav-new-ticket')?.classList.add('active');
|
document.getElementById('nav-new-ticket')?.classList.add('active');
|
||||||
titleEl.textContent = 'Nuovo Ticket';
|
titleEl.textContent = 'Nuovo Ticket';
|
||||||
@@ -320,6 +324,11 @@ const App = {
|
|||||||
titleEl.textContent = 'Gestione Mail';
|
titleEl.textContent = 'Gestione Mail';
|
||||||
MailManagementView.render();
|
MailManagementView.render();
|
||||||
|
|
||||||
|
} else if (hash === '#/config-env') {
|
||||||
|
document.getElementById('nav-config-env')?.classList.add('active');
|
||||||
|
titleEl.textContent = 'Modifica .env';
|
||||||
|
EnvConfigView.render();
|
||||||
|
|
||||||
} else if (hash === '#/time-accounting') {
|
} else if (hash === '#/time-accounting') {
|
||||||
document.getElementById('nav-time-accounting')?.classList.add('active');
|
document.getElementById('nav-time-accounting')?.classList.add('active');
|
||||||
titleEl.textContent = 'Storico consuntivazione';
|
titleEl.textContent = 'Storico consuntivazione';
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ const Filters = {
|
|||||||
customer_user_id: '',
|
customer_user_id: '',
|
||||||
date_from: '',
|
date_from: '',
|
||||||
date_to: '',
|
date_to: '',
|
||||||
|
},
|
||||||
|
spudorata: {
|
||||||
|
queue_id: '',
|
||||||
|
state_id: '',
|
||||||
|
priority_id: '',
|
||||||
|
user_id: '',
|
||||||
|
customer_user_id: '',
|
||||||
|
date_from: '',
|
||||||
|
date_to: '',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -38,11 +47,15 @@ const Filters = {
|
|||||||
|
|
||||||
// Dynamic state getter based on active mode
|
// Dynamic state getter based on active mode
|
||||||
get state() {
|
get state() {
|
||||||
return this.allStates[this.currentMode];
|
return this.allStates[this.currentMode] || this.allStates.general;
|
||||||
},
|
},
|
||||||
|
|
||||||
set state(val) {
|
set state(val) {
|
||||||
this.allStates[this.currentMode] = val;
|
if (this.allStates[this.currentMode]) {
|
||||||
|
this.allStates[this.currentMode] = val;
|
||||||
|
} else {
|
||||||
|
this.allStates.general = val;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Load saved filters from localStorage */
|
/** Load saved filters from localStorage */
|
||||||
@@ -56,6 +69,10 @@ const Filters = {
|
|||||||
if (savedMy) {
|
if (savedMy) {
|
||||||
Object.assign(this.allStates.my, JSON.parse(savedMy));
|
Object.assign(this.allStates.my, JSON.parse(savedMy));
|
||||||
}
|
}
|
||||||
|
const savedSpudorata = localStorage.getItem('otrs_turbo_filters_spudorata');
|
||||||
|
if (savedSpudorata) {
|
||||||
|
Object.assign(this.allStates.spudorata, JSON.parse(savedSpudorata));
|
||||||
|
}
|
||||||
// Always sync mode 'my' user_id to activeAgentId
|
// Always sync mode 'my' user_id to activeAgentId
|
||||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
this.allStates.my.user_id = activeAgentId;
|
this.allStates.my.user_id = activeAgentId;
|
||||||
@@ -222,7 +239,7 @@ const Filters = {
|
|||||||
<span class="multiselect-label" id="queue-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(queueLabel)}</span>
|
<span class="multiselect-label" id="queue-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(queueLabel)}</span>
|
||||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="multiselect-popover" id="queue-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
<div class="multiselect-popover" id="queue-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; min-width: 480px; width: max-content; max-width: 85vw; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
<input type="text" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
<input type="text" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||||
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:300px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:300px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
${(lookups.queues || []).map(q => {
|
${(lookups.queues || []).map(q => {
|
||||||
@@ -230,7 +247,7 @@ const Filters = {
|
|||||||
const isSelected = selectedList.includes(String(q.id));
|
const isSelected = selectedList.includes(String(q.id));
|
||||||
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||||
return `
|
return `
|
||||||
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${q.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${q.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; white-space: nowrap; ${activeStyle}">
|
||||||
${App.escapeHtml(q.name)}
|
${App.escapeHtml(q.name)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* Env Config View
|
||||||
|
* Manage and edit .env file content with local SQLite version history & restore capabilities.
|
||||||
|
*/
|
||||||
|
const EnvConfigView = {
|
||||||
|
currentContent: '',
|
||||||
|
history: [],
|
||||||
|
selectedPreview: null,
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="loading-screen">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento configurazione .env...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await App.api('/api/config/env');
|
||||||
|
this.currentContent = data.content || '';
|
||||||
|
this.history = data.history || [];
|
||||||
|
|
||||||
|
this.renderContent(container);
|
||||||
|
this.bindEvents();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading env config:', err);
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-text">Errore caricamento configurazione .env</div>
|
||||||
|
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderContent(container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header" style="margin-bottom: var(--space-lg);">
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-md);">
|
||||||
|
<div style="display:inline-flex; align-items:center; justify-content:center; width:40px; height:40px; background:linear-gradient(135deg, rgba(79,70,229,0.15), rgba(16,185,129,0.15)); border-radius:var(--radius-md); color:var(--accent-primary); font-size:1.3rem;">⚙️</div>
|
||||||
|
<div>
|
||||||
|
<h1 style="margin:0; font-size:1.3rem; font-weight:700; color:var(--text-primary);">Configurazione File .env</h1>
|
||||||
|
<p style="margin:2px 0 0 0; font-size:0.83rem; color:var(--text-secondary);">
|
||||||
|
Modifica le variabili d'ambiente (.env). Ad ogni salvataggio viene creata automaticamente una copia di backup nello storico locale SQLite (internal.db).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Layout: 2 Columns -->
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr 380px; gap: var(--space-lg); align-items: start;">
|
||||||
|
|
||||||
|
<!-- Left: Editor Card -->
|
||||||
|
<div class="card" style="background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-lg); padding:var(--space-lg); box-shadow:var(--shadow-sm);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-md);">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<h3 style="margin:0; font-size:1rem; font-weight:600; color:var(--text-primary);">📝 Editor File .env</h3>
|
||||||
|
<span class="badge" style="background:var(--bg-tertiary); color:var(--text-tertiary); font-size:0.75rem;">Modifica Diretta</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="env-reload-btn" title="Ricarica contenuto da disco">
|
||||||
|
🔄 Ricarica
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: var(--space-md);">
|
||||||
|
<textarea id="env-editor-content" rows="22" spellcheck="false" style="width:100%; font-family: 'Consolas', 'Courier New', monospace; font-size:0.88rem; line-height:1.45; padding:12px; background:var(--bg-tertiary); color:var(--text-primary); border:1px solid var(--border-subtle); border-radius:var(--radius-md); resize:vertical; box-sizing:border-box; outline:none;" placeholder="# Inserisci qui le configurazioni .env...">${App.escapeHtml(this.currentContent)}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:var(--space-md);">
|
||||||
|
<div>
|
||||||
|
<label style="display:block; font-size:0.78rem; font-weight:600; text-transform:uppercase; letter-spacing:0.04em; color:var(--text-tertiary); margin-bottom:4px;">
|
||||||
|
Nota / Descrizione Modifica (Opzionale)
|
||||||
|
</label>
|
||||||
|
<input type="text" id="env-note-input" class="form-input" placeholder="Es: Modificata porta DB, aggiornata chiave API Azure..." style="width:100%; box-sizing:border-box;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md);">
|
||||||
|
<div style="font-size:0.78rem; color:var(--text-tertiary);" id="env-status-msg">
|
||||||
|
Le modifiche avranno effetto sui moduli del server al riavvio.
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="env-save-btn" style="padding:0 var(--space-xl); height:38px; font-size:0.88rem; font-weight:600; min-width:140px; display:inline-flex; align-items:center; justify-content:center; gap:6px;">
|
||||||
|
<span>💾 Salva Modifiche</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: History Panel -->
|
||||||
|
<div class="card" style="background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-lg); padding:var(--space-lg); box-shadow:var(--shadow-sm);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-md); padding-bottom:var(--space-xs); border-bottom:1px solid var(--border-subtle);">
|
||||||
|
<h3 style="margin:0; font-size:1rem; font-weight:600; color:var(--text-primary);">📜 Storico Modifiche</h3>
|
||||||
|
<span class="badge" style="background:rgba(16,185,129,0.15); color:#10b981; font-size:0.75rem; font-weight:600;">${this.history.length} Versioni</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="env-history-list" style="display:flex; flex-direction:column; gap:var(--space-sm); max-height: 620px; overflow-y:auto; padding-right:4px;">
|
||||||
|
${this.renderHistoryList()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Modal Container -->
|
||||||
|
<div id="env-preview-modal-container"></div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHistoryList() {
|
||||||
|
if (!this.history || this.history.length === 0) {
|
||||||
|
return `
|
||||||
|
<div style="text-align:center; padding:30px 10px; color:var(--text-tertiary); font-size:0.85rem;">
|
||||||
|
Nessuna versione registrata nello storico. Salva una modifica per iniziare l'archiviazione.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.history.map((h, idx) => {
|
||||||
|
const isCurrentVersion = idx === 0;
|
||||||
|
const formattedDate = App.formatDateTime ? App.formatDateTime(h.created_at) : h.created_at;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="env-history-card" data-history-id="${h.id}" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:10px 12px; transition:all 0.15s ease;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:4px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:6px;">
|
||||||
|
<span class="badge" style="background:var(--bg-secondary); border:1px solid var(--border-subtle); font-weight:700; font-size:0.75rem; color:var(--text-primary);">#${h.id}</span>
|
||||||
|
${isCurrentVersion ? `<span class="badge" style="background:rgba(16,185,129,0.2); color:#10b981; font-weight:600; font-size:0.7rem;">Attuale</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-tertiary); font-family:monospace;">${formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size:0.82rem; color:var(--text-secondary); margin-bottom:6px; word-break:break-word;">
|
||||||
|
<strong>${App.escapeHtml(h.agent_name || 'Agente')}</strong>: ${App.escapeHtml(h.note || 'Modifica senza nota')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; justify-content:flex-end; gap:6px; padding-top:4px; border-top:1px dashed var(--border-subtle);">
|
||||||
|
<button class="btn btn-ghost btn-xs env-history-preview-btn" data-id="${h.id}" style="height:22px; padding:0 8px; font-size:0.75rem;" title="Anteprima del file .env di questa versione">
|
||||||
|
👁️ Anteprima
|
||||||
|
</button>
|
||||||
|
|
||||||
|
${!isCurrentVersion ? `
|
||||||
|
<button class="btn btn-primary btn-xs env-history-restore-btn" data-id="${h.id}" style="height:22px; padding:0 8px; font-size:0.75rem; background:var(--accent-primary); border-color:var(--accent-primary);" title="Ripristina questa versione su disco .env">
|
||||||
|
🔄 Ripristina
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
},
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
const saveBtn = document.getElementById('env-save-btn');
|
||||||
|
const reloadBtn = document.getElementById('env-reload-btn');
|
||||||
|
const editorTextarea = document.getElementById('env-editor-content');
|
||||||
|
const noteInput = document.getElementById('env-note-input');
|
||||||
|
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const content = editorTextarea.value;
|
||||||
|
const note = noteInput.value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Salvataggio...';
|
||||||
|
|
||||||
|
const res = await App.api('/api/config/env', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content, note })
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(res.message || 'File .env aggiornato con successo!');
|
||||||
|
if (noteInput) noteInput.value = '';
|
||||||
|
|
||||||
|
this.currentContent = content;
|
||||||
|
this.history = res.history || [];
|
||||||
|
|
||||||
|
// Re-render history panel
|
||||||
|
const historyListDiv = document.getElementById('env-history-list');
|
||||||
|
if (historyListDiv) {
|
||||||
|
historyListDiv.innerHTML = this.renderHistoryList();
|
||||||
|
this.bindHistoryEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore salvataggio file .env: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.innerHTML = '<span>💾 Salva Modifiche</span>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reloadBtn) {
|
||||||
|
reloadBtn.addEventListener('click', () => {
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bindHistoryEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
bindHistoryEvents() {
|
||||||
|
// Preview buttons
|
||||||
|
document.querySelectorAll('.env-history-preview-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = parseInt(btn.dataset.id, 10);
|
||||||
|
const record = this.history.find(h => h.id === id);
|
||||||
|
if (record) {
|
||||||
|
this.showPreviewModal(record);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore buttons
|
||||||
|
document.querySelectorAll('.env-history-restore-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = parseInt(btn.dataset.id, 10);
|
||||||
|
const record = this.history.find(h => h.id === id);
|
||||||
|
if (!record) return;
|
||||||
|
|
||||||
|
const confirmed = await App.confirm(
|
||||||
|
`Ripristino File .env (Versione #${record.id})`,
|
||||||
|
`Sei sicuro di voler ripristinare il file .env alla versione #${record.id} salvata il ${record.created_at}?\nL'operazione sovrascriverà il file .env attuale e salverà una nuova voce di ripristino nello storico.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
btn.disabled = true;
|
||||||
|
const res = await App.api(`/api/config/env/restore/${id}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(res.message || `Versione #${id} ripristinata con successo!`);
|
||||||
|
this.currentContent = res.content || record.content;
|
||||||
|
this.history = res.history || [];
|
||||||
|
|
||||||
|
const editorTextarea = document.getElementById('env-editor-content');
|
||||||
|
if (editorTextarea) {
|
||||||
|
editorTextarea.value = this.currentContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyListDiv = document.getElementById('env-history-list');
|
||||||
|
if (historyListDiv) {
|
||||||
|
historyListDiv.innerHTML = this.renderHistoryList();
|
||||||
|
this.bindHistoryEvents();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante il ripristino: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showPreviewModal(record) {
|
||||||
|
const modalContainer = document.getElementById('env-preview-modal-container');
|
||||||
|
if (!modalContainer) return;
|
||||||
|
|
||||||
|
modalContainer.innerHTML = `
|
||||||
|
<div class="modal-overlay" style="position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.6); z-index:2000; display:flex; align-items:center; justify-content:center; backdrop-filter:blur(4px);">
|
||||||
|
<div class="modal-content" style="background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-lg); width:750px; max-width:92vw; max-height:85vh; display:flex; flex-direction:column; box-shadow:var(--shadow-xl); overflow:hidden;">
|
||||||
|
|
||||||
|
<div style="padding:var(--space-md) var(--space-lg); border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; background:var(--bg-secondary);">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<span class="badge" style="background:var(--accent-primary); color:#fff; font-weight:700;">Versione #${record.id}</span>
|
||||||
|
<span style="font-size:0.85rem; font-weight:600; color:var(--text-primary);">${App.escapeHtml(record.agent_name || 'Agente')}</span>
|
||||||
|
<span style="font-size:0.78rem; color:var(--text-tertiary); font-family:monospace;">(${App.formatDateTime ? App.formatDateTime(record.created_at) : record.created_at})</span>
|
||||||
|
</div>
|
||||||
|
<button id="preview-modal-close" class="btn btn-ghost btn-xs" style="font-size:1.1rem; line-height:1;">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:var(--space-md) var(--space-lg); border-bottom:1px solid var(--border-subtle); font-size:0.83rem; color:var(--text-secondary); background:var(--bg-tertiary);">
|
||||||
|
<strong>Nota:</strong> ${App.escapeHtml(record.note || 'Nessuna nota fornita')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:var(--space-md) var(--space-lg); overflow-y:auto; flex:1;">
|
||||||
|
<pre style="margin:0; font-family:'Consolas', 'Courier New', monospace; font-size:0.85rem; line-height:1.45; white-space:pre-wrap; word-break:break-all; background:var(--bg-tertiary); padding:12px; border-radius:var(--radius-md); border:1px solid var(--border-subtle); color:var(--text-primary);">${App.escapeHtml(record.content)}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:var(--space-md) var(--space-lg); border-top:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; background:var(--bg-secondary);">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="preview-copy-btn">📋 Copia Testo</button>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="preview-modal-cancel">Chiudi</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="preview-modal-restore-btn" style="background:var(--accent-primary); border-color:var(--accent-primary);">🔄 Ripristina Questa Versione</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const closeModal = () => { modalContainer.innerHTML = ''; };
|
||||||
|
|
||||||
|
document.getElementById('preview-modal-close')?.addEventListener('click', closeModal);
|
||||||
|
document.getElementById('preview-modal-cancel')?.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
document.getElementById('preview-copy-btn')?.addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText(record.content).then(() => {
|
||||||
|
Toast.success('Contenuto copiato negli appunti!');
|
||||||
|
}).catch(err => Toast.error('Errore durante la copia: ' + err.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('preview-modal-restore-btn')?.addEventListener('click', async () => {
|
||||||
|
closeModal();
|
||||||
|
const restoreBtn = document.querySelector(`.env-history-restore-btn[data-id="${record.id}"]`);
|
||||||
|
if (restoreBtn) {
|
||||||
|
restoreBtn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -22,6 +22,40 @@ const MailManagementView = {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="max-width: 820px; margin: 0 auto; padding: var(--space-xl) var(--space-lg); display: flex; flex-direction: column; gap: var(--space-xl);">
|
<div style="max-width: 820px; margin: 0 auto; padding: var(--space-xl) var(--space-lg); display: flex; flex-direction: column; gap: var(--space-xl);">
|
||||||
|
|
||||||
|
<!-- SECTION 0: Sender Setting Switch -->
|
||||||
|
<div class="card" style="padding: var(--space-lg);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-md); flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--text-primary); display:flex; align-items:center; gap:8px;">
|
||||||
|
📬 Indirizzo Mittente per l'Invio Email
|
||||||
|
</h2>
|
||||||
|
<p style="margin:4px 0 0; font-size:0.82rem; color:var(--text-muted);">
|
||||||
|
Scegli se inviare le email dei ticket usando l'indirizzo dell'agente attivo oppure quello predefinito nel file .env.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sender-setting-container" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:var(--space-md);">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:12px; cursor:pointer; padding:8px 10px; border-radius:var(--radius-md); transition:background 0.15s ease;" class="sender-option-label">
|
||||||
|
<input type="radio" name="sender-choice" value="env" id="sender-choice-env" style="margin-top:3px;" />
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600; font-size:0.9rem; color:var(--text-primary);">🏢 Usa mittente configurato nel file .env</div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--text-tertiary); margin-top:2px;" id="env-sender-display">Caricamento...</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:12px; cursor:pointer; padding:8px 10px; border-radius:var(--radius-md); transition:background 0.15s ease;" class="sender-option-label">
|
||||||
|
<input type="radio" name="sender-choice" value="agent" id="sender-choice-agent" style="margin-top:3px;" />
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600; font-size:0.9rem; color:var(--text-primary);">👤 Usa email dell'agente selezionato</div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--text-tertiary); margin-top:2px;" id="agent-email-display">Caricamento...</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- SECTION 1: Address Groups -->
|
<!-- SECTION 1: Address Groups -->
|
||||||
<div class="card" style="padding: var(--space-lg);">
|
<div class="card" style="padding: var(--space-lg);">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-md);">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-md);">
|
||||||
@@ -117,11 +151,54 @@ const MailManagementView = {
|
|||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
this.loadSenderSettings(),
|
||||||
this.loadSignatures(),
|
this.loadSignatures(),
|
||||||
this.loadGroups()
|
this.loadGroups()
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadSenderSettings() {
|
||||||
|
try {
|
||||||
|
const settings = await App.api(`/api/email/settings?agent_id=${this.agentId}`);
|
||||||
|
const envDisplay = document.getElementById('env-sender-display');
|
||||||
|
const agentDisplay = document.getElementById('agent-email-display');
|
||||||
|
const radioEnv = document.getElementById('sender-choice-env');
|
||||||
|
const radioAgent = document.getElementById('sender-choice-agent');
|
||||||
|
|
||||||
|
if (envDisplay) envDisplay.textContent = settings.env_sender ? `Indirizzo: ${settings.env_sender}` : '(Nessun mittente .env configurato)';
|
||||||
|
if (agentDisplay) agentDisplay.textContent = settings.agent_email ? `Indirizzo agente: ${settings.agent_email}` : '(Nessuna email agente registrata nelle preferenze OTRS)';
|
||||||
|
|
||||||
|
if (settings.use_agent_sender) {
|
||||||
|
if (radioAgent) radioAgent.checked = true;
|
||||||
|
} else {
|
||||||
|
if (radioEnv) radioEnv.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const radios = document.querySelectorAll('input[name="sender-choice"]');
|
||||||
|
radios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', async (e) => {
|
||||||
|
const useAgentSender = e.target.value === 'agent';
|
||||||
|
try {
|
||||||
|
await App.api('/api/email/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_id: this.agentId,
|
||||||
|
use_agent_sender: useAgentSender
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Toast.success(useAgentSender
|
||||||
|
? 'Configurato invio tramite email dell\'agente!'
|
||||||
|
: 'Configurato invio tramite mittente predefinito .env!');
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore aggiornamento mittente: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading sender settings:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
injectStyles() {
|
injectStyles() {
|
||||||
if (document.getElementById('mail-management-styles')) return;
|
if (document.getElementById('mail-management-styles')) return;
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
|
|||||||
@@ -0,0 +1,777 @@
|
|||||||
|
/**
|
||||||
|
* Ricerca Spudorata View
|
||||||
|
* Full-featured text-first ticket search with local in-memory caching and client-side filtering.
|
||||||
|
*/
|
||||||
|
const RicercaSpudorataView = {
|
||||||
|
searchTerm: '',
|
||||||
|
cachedTickets: null,
|
||||||
|
currentPage: 1,
|
||||||
|
perPage: 50,
|
||||||
|
sortBy: 'create_time',
|
||||||
|
sortDir: 'DESC',
|
||||||
|
selectedIds: new Set(),
|
||||||
|
selectedOrder: [],
|
||||||
|
hasSearched: false,
|
||||||
|
isFetching: false,
|
||||||
|
|
||||||
|
invalidateCache() {
|
||||||
|
this.cachedTickets = null;
|
||||||
|
this.hasSearched = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
Filters.currentMode = 'spudorata';
|
||||||
|
Filters.load();
|
||||||
|
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="loading-screen">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Inizializzazione Ricerca Spudorata...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await App.ensureLookups();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await App.api('/api/dashboard/settings');
|
||||||
|
if (settings && settings.tickets_per_page) {
|
||||||
|
this.perPage = settings.tickets_per_page;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load agent settings:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderContainer(container);
|
||||||
|
this.bindEvents();
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || '');
|
||||||
|
const qParam = urlParams.get('q');
|
||||||
|
if (qParam && !this.hasSearched) {
|
||||||
|
const input = document.getElementById('spudorata-search-input');
|
||||||
|
if (input) input.value = qParam;
|
||||||
|
this.executeSearch(qParam);
|
||||||
|
} else if (this.hasSearched && this.cachedTickets) {
|
||||||
|
this.renderLocalResults();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderContainer(container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<!-- Hero Text Search Area -->
|
||||||
|
<div class="ricerca-spudorata-hero" style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-lg); margin-bottom: var(--space-lg); box-shadow: var(--shadow-md);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-sm); flex-wrap:wrap; gap:10px;">
|
||||||
|
<div style="display:flex; align-items:center; gap: 10px;">
|
||||||
|
<div style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background: linear-gradient(135deg, rgba(235, 114, 110, 0.2), rgba(160, 65, 71, 0.3)); border-radius: 50%; color: var(--accent-primary); font-size:1.2rem; font-weight:bold;">🚀</div>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0; font-size:1.2rem; font-weight:700; color:var(--text-primary);">Ricerca Spudorata Testuale</h2>
|
||||||
|
<p style="margin:2px 0 0 0; font-size:0.82rem; color:var(--text-secondary);">
|
||||||
|
Esegui la ricerca nel testo sui ticket. I filtri applicati successivamente operano <strong>istantaneamente in cache locale</strong> senza fare nuove richieste al server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="spudorata-cache-info" style="font-size:0.82rem; color:var(--text-tertiary);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap: var(--space-sm); flex-wrap:wrap; margin-top: var(--space-md);">
|
||||||
|
<div style="position:relative; flex:1; min-width:300px;">
|
||||||
|
<input type="text" id="spudorata-search-input" class="form-input" placeholder="Cerca nel testo (oggetto, corpo, numero ticket, cliente)..." value="${App.escapeHtml(this.searchTerm)}" style="width:100%; height:42px; font-size:0.95rem; padding-left:14px; padding-right:36px; border-radius:var(--radius-md);" />
|
||||||
|
<button id="spudorata-input-clear" style="position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--text-muted); cursor:pointer; display:${this.searchTerm ? 'flex' : 'none'}; align-items:center; justify-content:center;" title="Cancella testo">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="spudorata-search-btn" style="height:42px; padding:0 var(--space-xl); font-size:0.9rem; font-weight:600; min-width:130px; display:flex; align-items:center; justify-content:center; gap:6px;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
<span>Cerca</span>
|
||||||
|
</button>
|
||||||
|
${this.hasSearched ? `
|
||||||
|
<button class="btn btn-ghost" id="spudorata-reset-cache-btn" style="height:42px; font-size:0.85rem; color:var(--accent-primary);" title="Resetta la cache e la ricerca">
|
||||||
|
Azzera cache
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Batch Actions Bar -->
|
||||||
|
<div class="batch-bar" id="batch-bar">
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||||
|
<span class="batch-count" id="batch-count">0 selezionati</span>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Stato</span>
|
||||||
|
<select class="filter-select" id="batch-state">
|
||||||
|
<option value="">—</option>
|
||||||
|
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group" style="position:relative;">
|
||||||
|
<span class="filter-label">Coda</span>
|
||||||
|
<input type="text" class="form-input filter-select" id="batch-queue-search" placeholder="Cerca coda..." autocomplete="off" style="width:180px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||||
|
<input type="hidden" id="batch-queue" />
|
||||||
|
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; min-width: 480px; width: max-content; max-width: 85vw; z-index: 1001;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Owner</span>
|
||||||
|
<select class="filter-select" id="batch-owner">
|
||||||
|
<option value="">—</option>
|
||||||
|
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Responsabile</span>
|
||||||
|
<select class="filter-select" id="batch-responsible">
|
||||||
|
<option value="">—</option>
|
||||||
|
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
${(App.lookups.types || []).length > 0 ? `
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Tipo</span>
|
||||||
|
<select class="filter-select" id="batch-type">
|
||||||
|
<option value="">—</option>
|
||||||
|
${App.lookups.types.map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); 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>
|
||||||
|
|
||||||
|
<!-- Filters Bar (Operates 100% Client-Side in Memory) -->
|
||||||
|
${Filters.renderBar(App.lookups)}
|
||||||
|
|
||||||
|
<!-- Results Container -->
|
||||||
|
<div id="spudorata-results-area">
|
||||||
|
${this.hasSearched ? '' : `
|
||||||
|
<div class="empty-state" style="padding: 40px var(--space-lg); background:var(--bg-card); border-radius:var(--radius-lg); border:1px dashed var(--border-subtle);">
|
||||||
|
<div class="empty-state-icon">🔍</div>
|
||||||
|
<div class="empty-state-text">Nessuna ricerca effettuata</div>
|
||||||
|
<div class="empty-state-sub">Scrivi una parola nel campo in alto e clicca su "Cerca" per caricare i risultati in cache locale.</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async executeSearch(query) {
|
||||||
|
if (!query) {
|
||||||
|
Toast.error('Inserisci un termine di ricerca');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchTerm = query;
|
||||||
|
this.isFetching = true;
|
||||||
|
|
||||||
|
const resultsArea = document.getElementById('spudorata-results-area');
|
||||||
|
if (resultsArea) {
|
||||||
|
resultsArea.innerHTML = `
|
||||||
|
<div class="loading-screen" style="padding: 60px 0;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Ricerca testuale nel server in corso per "${App.escapeHtml(query)}"...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await App.api(`/api/tickets?search=${encodeURIComponent(query)}&per_page=2000`);
|
||||||
|
this.cachedTickets = data.tickets || [];
|
||||||
|
this.hasSearched = true;
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.selectedIds.clear();
|
||||||
|
this.selectedOrder = [];
|
||||||
|
|
||||||
|
Toast.success(`${this.cachedTickets.length} ticket caricati in cache locale per "${query}"`);
|
||||||
|
this.renderContainer(document.getElementById('view-container'));
|
||||||
|
this.bindEvents();
|
||||||
|
this.renderLocalResults();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
Toast.error('Errore durante la ricerca testuale: ' + err.message);
|
||||||
|
if (resultsArea) {
|
||||||
|
resultsArea.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-text">Errore durante la ricerca</div>
|
||||||
|
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isFetching = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderLocalResults() {
|
||||||
|
const resultsArea = document.getElementById('spudorata-results-area');
|
||||||
|
if (!resultsArea || !this.hasSearched) return;
|
||||||
|
|
||||||
|
if (!this.cachedTickets || this.cachedTickets.length === 0) {
|
||||||
|
resultsArea.innerHTML = `
|
||||||
|
<div class="empty-state" style="padding: 40px var(--space-lg); background:var(--bg-card); border-radius:var(--radius-lg);">
|
||||||
|
<div class="empty-state-icon">📭</div>
|
||||||
|
<div class="empty-state-text">Nessun risultato trovato</div>
|
||||||
|
<div class="empty-state-sub">Nessun ticket corrisponde alla ricerca "${App.escapeHtml(this.searchTerm)}".</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = this.getFilteredTickets();
|
||||||
|
const total = filtered.length;
|
||||||
|
const totalPages = Math.ceil(total / this.perPage) || 1;
|
||||||
|
|
||||||
|
if (this.currentPage > totalPages) {
|
||||||
|
this.currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheInfo = document.getElementById('spudorata-cache-info');
|
||||||
|
if (cacheInfo) {
|
||||||
|
cacheInfo.innerHTML = `
|
||||||
|
<span class="badge" style="background:rgba(16,112,202,0.15); color:var(--accent-primary); padding:4px 10px; border-radius:var(--radius-full); font-weight:600;">
|
||||||
|
⚡ Cache locale: ${total} visibil${total === 1 ? 'o' : 'i'} su ${this.cachedTickets.length} caricati
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = this.sortTickets(filtered);
|
||||||
|
|
||||||
|
const startIndex = (this.currentPage - 1) * this.perPage;
|
||||||
|
const paginatedTickets = sorted.slice(startIndex, startIndex + this.perPage);
|
||||||
|
|
||||||
|
resultsArea.innerHTML = `
|
||||||
|
<!-- Pagination Top -->
|
||||||
|
${this.renderPagination(this.currentPage, this.perPage, total, totalPages, true)}
|
||||||
|
|
||||||
|
<!-- Ticket Table -->
|
||||||
|
<div class="ticket-table-wrapper">
|
||||||
|
<table class="ticket-table" id="ticket-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 75px;" class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
|
||||||
|
<th style="width: 125px;" class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
||||||
|
<th style="width: 90px;" class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
|
||||||
|
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
|
||||||
|
<th style="width: 130px;" class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
||||||
|
<th style="width: 120px;">Owner</th>
|
||||||
|
<th style="width: 140px;">Cliente</th>
|
||||||
|
<th style="width: 80px;" class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${paginatedTickets.length > 0 ? paginatedTickets.map(t => {
|
||||||
|
const displayQueue = t.queue_name.includes('::') ? t.queue_name.split('::').pop() : t.queue_name;
|
||||||
|
const shortQueue = displayQueue.length > 15 ? displayQueue.substring(0, 12) + '...' : displayQueue;
|
||||||
|
return `
|
||||||
|
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;">
|
||||||
|
<td>
|
||||||
|
<span class="ticket-tn" style="display:inline-flex; align-items:center;">
|
||||||
|
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
|
||||||
|
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</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>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-state" data-state-type="${App.escapeHtml(t.state_type || '')}">
|
||||||
|
${App.escapeHtml(t.state_name)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title)}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="ticket-queue" title="${App.escapeHtml(t.queue_name)}">${App.escapeHtml(shortQueue)}</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:0.82rem;color:var(--text-secondary);">
|
||||||
|
${t.owner_first ? `${App.escapeHtml(t.owner_first)} ${App.escapeHtml(t.owner_last || '')}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:0.82rem;color:var(--text-secondary);">
|
||||||
|
${t.customer_first ? `${App.escapeHtml(t.customer_first)} ${App.escapeHtml(t.customer_last || '')}` : (t.customer_id || '—')}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-priority" data-priority="${App.escapeHtml(t.priority_name || '')}">
|
||||||
|
${App.escapeHtml(t.priority_name)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('') : `
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" style="text-align:center; padding: 30px; color: var(--text-tertiary);">
|
||||||
|
Nessun ticket corrisponde ai filtri selezionati sulla cache locale.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Bottom -->
|
||||||
|
${this.renderPagination(this.currentPage, this.perPage, total, totalPages, false)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindTableEvents();
|
||||||
|
this.updateBatchBar();
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilteredTickets() {
|
||||||
|
if (!this.cachedTickets) return [];
|
||||||
|
let list = [...this.cachedTickets];
|
||||||
|
const s = Filters.state;
|
||||||
|
|
||||||
|
if (s.state_id) {
|
||||||
|
const selectedStates = String(s.state_id).split(',').filter(Boolean);
|
||||||
|
list = list.filter(t => selectedStates.includes(String(t.ticket_state_id)));
|
||||||
|
}
|
||||||
|
if (s.queue_id) {
|
||||||
|
const selectedQueues = String(s.queue_id).split(',').filter(Boolean);
|
||||||
|
list = list.filter(t => selectedQueues.includes(String(t.queue_id)));
|
||||||
|
}
|
||||||
|
if (s.priority_id) {
|
||||||
|
const selectedPriorities = String(s.priority_id).split(',').filter(Boolean);
|
||||||
|
list = list.filter(t => selectedPriorities.includes(String(t.ticket_priority_id)));
|
||||||
|
}
|
||||||
|
if (s.user_id) {
|
||||||
|
const selectedUsers = String(s.user_id).split(',').filter(Boolean);
|
||||||
|
list = list.filter(t => selectedUsers.includes(String(t.user_id)));
|
||||||
|
}
|
||||||
|
if (s.responsible_user_id) {
|
||||||
|
list = list.filter(t => String(t.responsible_user_id) === String(s.responsible_user_id));
|
||||||
|
}
|
||||||
|
if (s.type_id) {
|
||||||
|
list = list.filter(t => String(t.type_id) === String(s.type_id));
|
||||||
|
}
|
||||||
|
if (s.customer_user_id) {
|
||||||
|
list = list.filter(t => String(t.customer_user_id || '').toLowerCase() === String(s.customer_user_id).toLowerCase());
|
||||||
|
}
|
||||||
|
if (s.customer_id) {
|
||||||
|
list = list.filter(t => String(t.customer_id || '').toLowerCase() === String(s.customer_id).toLowerCase());
|
||||||
|
}
|
||||||
|
if (s.date_from) {
|
||||||
|
const fromDate = new Date(s.date_from);
|
||||||
|
list = list.filter(t => new Date(t.create_time) >= fromDate);
|
||||||
|
}
|
||||||
|
if (s.date_to) {
|
||||||
|
const toDate = new Date(s.date_to + 'T23:59:59');
|
||||||
|
list = list.filter(t => new Date(t.create_time) <= toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
sortTickets(tickets) {
|
||||||
|
const list = [...tickets];
|
||||||
|
const key = this.sortBy;
|
||||||
|
const isAsc = this.sortDir === 'ASC';
|
||||||
|
|
||||||
|
list.sort((a, b) => {
|
||||||
|
let valA, valB;
|
||||||
|
if (key === 'queue') {
|
||||||
|
valA = a.queue_name || '';
|
||||||
|
valB = b.queue_name || '';
|
||||||
|
} else if (key === 'state') {
|
||||||
|
valA = a.state_name || '';
|
||||||
|
valB = b.state_name || '';
|
||||||
|
} else if (key === 'priority') {
|
||||||
|
valA = a.ticket_priority_id || 0;
|
||||||
|
valB = b.ticket_priority_id || 0;
|
||||||
|
} else {
|
||||||
|
valA = a[key] || '';
|
||||||
|
valB = b[key] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof valA === 'string') {
|
||||||
|
return isAsc ? valA.localeCompare(valB, 'it', { sensitivity: 'base' }) : valB.localeCompare(valA, 'it', { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
return isAsc ? valA - valB : valB - valA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPagination(current, perPage, total, totalPages, isTop = false) {
|
||||||
|
return `
|
||||||
|
<div class="pagination" style="${isTop ? 'margin-bottom:var(--space-md);' : 'margin-top:var(--space-md);'} align-items:center; justify-content:space-between; display:flex;">
|
||||||
|
<div class="pagination-info" style="font-size:0.82rem; color:var(--text-tertiary);">
|
||||||
|
Mostrati ${Math.min(total, (current - 1) * perPage + 1)}-${Math.min(total, current * perPage)} di ${total} ticket
|
||||||
|
</div>
|
||||||
|
<div class="pagination-buttons" style="display:flex; gap:4px;">
|
||||||
|
<button class="pagination-btn spudorata-page-btn" data-page="${Math.max(1, current - 1)}" ${current === 1 ? 'disabled' : ''}>«</button>
|
||||||
|
${this.renderPageNumbers(current, totalPages)}
|
||||||
|
<button class="pagination-btn spudorata-page-btn" data-page="${Math.min(totalPages, current + 1)}" ${current === totalPages ? 'disabled' : ''}>»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPageNumbers(current, totalPages) {
|
||||||
|
const pages = [];
|
||||||
|
const start = Math.max(1, current - 2);
|
||||||
|
const end = Math.min(totalPages, current + 2);
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(`<button class="pagination-btn spudorata-page-btn ${i === current ? 'active' : ''}" data-page="${i}">${i}</button>`);
|
||||||
|
}
|
||||||
|
return pages.join('');
|
||||||
|
},
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
const searchInput = document.getElementById('spudorata-search-input');
|
||||||
|
const searchBtn = document.getElementById('spudorata-search-btn');
|
||||||
|
const clearBtn = document.getElementById('spudorata-input-clear');
|
||||||
|
const resetCacheBtn = document.getElementById('spudorata-reset-cache-btn');
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
if (clearBtn) clearBtn.style.display = searchInput.value.trim() ? 'flex' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.executeSearch(searchInput.value.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchBtn) {
|
||||||
|
searchBtn.addEventListener('click', () => {
|
||||||
|
if (searchInput) this.executeSearch(searchInput.value.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
clearBtn.style.display = 'none';
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetCacheBtn) {
|
||||||
|
resetCacheBtn.addEventListener('click', () => {
|
||||||
|
this.invalidateCache();
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
Filters.reset();
|
||||||
|
this.renderContainer(document.getElementById('view-container'));
|
||||||
|
this.bindEvents();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Filters.bindEvents(() => {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.renderLocalResults();
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchApply = document.getElementById('batch-apply');
|
||||||
|
if (batchApply) batchApply.addEventListener('click', () => this.applyBatch());
|
||||||
|
|
||||||
|
const batchMerge = document.getElementById('batch-merge');
|
||||||
|
if (batchMerge) batchMerge.addEventListener('click', () => this.mergeBatch());
|
||||||
|
|
||||||
|
const batchOpenTabs = document.getElementById('batch-open-tabs');
|
||||||
|
if (batchOpenTabs) batchOpenTabs.addEventListener('click', () => this.openBatchInTabs());
|
||||||
|
|
||||||
|
const batchCopyTns = document.getElementById('batch-copy-tns');
|
||||||
|
if (batchCopyTns) batchCopyTns.addEventListener('click', () => this.copySelectedTns());
|
||||||
|
|
||||||
|
const batchSelectAll = document.getElementById('batch-select-all');
|
||||||
|
if (batchSelectAll) batchSelectAll.addEventListener('click', () => this.selectAllVisible());
|
||||||
|
|
||||||
|
const batchCancel = document.getElementById('batch-cancel');
|
||||||
|
if (batchCancel) batchCancel.addEventListener('click', () => this.cancelBatchSelection());
|
||||||
|
|
||||||
|
const batchQueueSearchInput = document.getElementById('batch-queue-search');
|
||||||
|
const batchQueueSuggestionsDiv = document.getElementById('batch-queue-suggestions');
|
||||||
|
const batchQueueIdInput = document.getElementById('batch-queue');
|
||||||
|
if (batchQueueSearchInput) {
|
||||||
|
batchQueueSearchInput.addEventListener('input', async () => {
|
||||||
|
const q = batchQueueSearchInput.value.trim();
|
||||||
|
try {
|
||||||
|
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (queues.length === 0) {
|
||||||
|
batchQueueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessuna coda trovata</div>';
|
||||||
|
} else {
|
||||||
|
batchQueueSuggestionsDiv.innerHTML = queues.map(queue => `
|
||||||
|
<div class="autocomplete-suggestion-item" data-id="${queue.id}" data-name="${App.escapeHtml(queue.name)}">
|
||||||
|
<strong>${App.escapeHtml(queue.name)}</strong>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
batchQueueSuggestionsDiv.style.display = 'block';
|
||||||
|
batchQueueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
batchQueueSearchInput.value = item.dataset.name;
|
||||||
|
if (batchQueueIdInput) batchQueueIdInput.value = item.dataset.id;
|
||||||
|
batchQueueSuggestionsDiv.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
});
|
||||||
|
batchQueueSearchInput.addEventListener('focus', () => {
|
||||||
|
batchQueueSearchInput.value = '';
|
||||||
|
if (batchQueueIdInput) batchQueueIdInput.value = '';
|
||||||
|
batchQueueSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (batchQueueSearchInput && e.target !== batchQueueSearchInput && e.target !== batchQueueSuggestionsDiv) {
|
||||||
|
if (batchQueueSuggestionsDiv) batchQueueSuggestionsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
bindTableEvents() {
|
||||||
|
document.querySelectorAll('#spudorata-results-area th.sortable').forEach(th => {
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
const sortKey = th.dataset.sort;
|
||||||
|
if (this.sortBy === sortKey) {
|
||||||
|
this.sortDir = this.sortDir === 'ASC' ? 'DESC' : 'ASC';
|
||||||
|
} else {
|
||||||
|
this.sortBy = sortKey;
|
||||||
|
this.sortDir = 'DESC';
|
||||||
|
}
|
||||||
|
this.renderLocalResults();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.spudorata-page-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const page = parseInt(btn.dataset.page, 10);
|
||||||
|
if (!isNaN(page) && page > 0) {
|
||||||
|
this.currentPage = page;
|
||||||
|
this.renderLocalResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('#spudorata-results-area tr[data-ticket-id]').forEach(row => {
|
||||||
|
row.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.ticket-tn-link') || e.target.closest('.ticket-title-link') || e.target.closest('.copy-ticket-btn') || e.target.closest('.open-tab-btn')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = String(row.dataset.ticketId);
|
||||||
|
if (this.selectedIds.has(id)) {
|
||||||
|
this.selectedIds.delete(id);
|
||||||
|
this.selectedOrder = this.selectedOrder.filter(x => x !== id);
|
||||||
|
} else {
|
||||||
|
this.selectedIds.add(id);
|
||||||
|
this.selectedOrder.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#spudorata-results-area tr[data-ticket-id]').forEach(tr => {
|
||||||
|
const trId = String(tr.dataset.ticketId);
|
||||||
|
if (this.selectedIds.has(trId)) tr.classList.add('selected');
|
||||||
|
else tr.classList.remove('selected');
|
||||||
|
if (this.selectedOrder[0] === trId) tr.classList.add('first-selected');
|
||||||
|
else tr.classList.remove('first-selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateBatchBar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.copy-ticket-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const tn = btn.dataset.tn;
|
||||||
|
if (tn) {
|
||||||
|
navigator.clipboard.writeText(tn).then(() => {
|
||||||
|
Toast.success(`Numero ticket ${tn} copiato!`);
|
||||||
|
}).catch(err => Toast.error('Errore durante la copia: ' + err.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBatchBar() {
|
||||||
|
const countEl = document.getElementById('batch-count');
|
||||||
|
const addGroupBtn = document.getElementById('batch-add-group');
|
||||||
|
const mergeBtn = document.getElementById('batch-merge');
|
||||||
|
const openTabsBtn = document.getElementById('batch-open-tabs');
|
||||||
|
|
||||||
|
const count = this.selectedIds.size;
|
||||||
|
if (countEl) countEl.textContent = `${count} selezionat${count === 1 ? 'o' : 'i'}`;
|
||||||
|
if (addGroupBtn) addGroupBtn.disabled = count === 0;
|
||||||
|
if (mergeBtn) mergeBtn.disabled = count < 2;
|
||||||
|
if (openTabsBtn) openTabsBtn.disabled = count === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAllVisible() {
|
||||||
|
document.querySelectorAll('#spudorata-results-area tr[data-ticket-id]').forEach(row => {
|
||||||
|
const id = String(row.dataset.ticketId);
|
||||||
|
if (!this.selectedIds.has(id)) {
|
||||||
|
this.selectedIds.add(id);
|
||||||
|
this.selectedOrder.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('#spudorata-results-area tr[data-ticket-id]').forEach(tr => {
|
||||||
|
const trId = String(tr.dataset.ticketId);
|
||||||
|
tr.classList.add('selected');
|
||||||
|
if (this.selectedOrder[0] === trId) tr.classList.add('first-selected');
|
||||||
|
else tr.classList.remove('first-selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateBatchBar();
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelBatchSelection() {
|
||||||
|
this.selectedIds.clear();
|
||||||
|
this.selectedOrder = [];
|
||||||
|
document.querySelectorAll('#spudorata-results-area tr').forEach(tr => {
|
||||||
|
tr.classList.remove('selected');
|
||||||
|
tr.classList.remove('first-selected');
|
||||||
|
});
|
||||||
|
this.updateBatchBar();
|
||||||
|
},
|
||||||
|
|
||||||
|
copySelectedTns() {
|
||||||
|
const selectedTns = [];
|
||||||
|
this.selectedIds.forEach(id => {
|
||||||
|
const row = document.querySelector(`#spudorata-results-area tr[data-ticket-id="${id}"]`);
|
||||||
|
if (row) {
|
||||||
|
const tnLink = row.querySelector('.ticket-tn-link');
|
||||||
|
if (tnLink) selectedTns.push(tnLink.textContent.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (selectedTns.length > 0) {
|
||||||
|
navigator.clipboard.writeText(selectedTns.join('\n')).then(() => {
|
||||||
|
Toast.success(`${selectedTns.length} numeri ticket copiati!`);
|
||||||
|
}).catch(err => Toast.error('Errore durante la copia: ' + err.message));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openBatchInTabs() {
|
||||||
|
let count = 0;
|
||||||
|
this.selectedOrder.forEach(id => {
|
||||||
|
const row = document.querySelector(`#spudorata-results-area tr[data-ticket-id="${id}"]`);
|
||||||
|
if (row) {
|
||||||
|
const tn = row.querySelector('.ticket-tn-link')?.textContent.trim() || id;
|
||||||
|
const title = row.querySelector('.ticket-title-link')?.textContent.trim() || '';
|
||||||
|
App.openTab(parseInt(id), tn, title);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (count > 0) {
|
||||||
|
Toast.success(`${count} ticket aperti in nuove schede!`);
|
||||||
|
this.cancelBatchSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyBatch() {
|
||||||
|
if (this.selectedIds.size === 0) {
|
||||||
|
Toast.error('Seleziona almeno un ticket per applicare le modifiche');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
const stateVal = document.getElementById('batch-state')?.value;
|
||||||
|
const queueVal = document.getElementById('batch-queue')?.value;
|
||||||
|
const ownerVal = document.getElementById('batch-owner')?.value;
|
||||||
|
const responsibleVal = document.getElementById('batch-responsible')?.value;
|
||||||
|
const typeVal = document.getElementById('batch-type')?.value;
|
||||||
|
|
||||||
|
if (stateVal) updates.ticket_state_id = parseInt(stateVal);
|
||||||
|
if (queueVal) updates.queue_id = parseInt(queueVal);
|
||||||
|
if (ownerVal) updates.user_id = parseInt(ownerVal);
|
||||||
|
if (responsibleVal) updates.responsible_user_id = parseInt(responsibleVal);
|
||||||
|
if (typeVal) updates.type_id = parseInt(typeVal);
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
Toast.error('Seleziona almeno un campo da modificare dalla barra');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticketIds = Array.from(this.selectedIds).map(id => parseInt(id));
|
||||||
|
const res = await App.api('/api/tickets/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ticketIds, updates })
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(res.message || 'Ticket aggiornati con successo');
|
||||||
|
|
||||||
|
if (this.cachedTickets) {
|
||||||
|
this.cachedTickets.forEach(t => {
|
||||||
|
if (this.selectedIds.has(String(t.id))) {
|
||||||
|
if (updates.ticket_state_id) {
|
||||||
|
t.ticket_state_id = updates.ticket_state_id;
|
||||||
|
const foundState = App.lookups.states.find(s => s.id === updates.ticket_state_id);
|
||||||
|
if (foundState) t.state_name = foundState.name;
|
||||||
|
}
|
||||||
|
if (updates.queue_id) {
|
||||||
|
t.queue_id = updates.queue_id;
|
||||||
|
const foundQueue = App.lookups.queues.find(q => q.id === updates.queue_id);
|
||||||
|
if (foundQueue) t.queue_name = foundQueue.name;
|
||||||
|
}
|
||||||
|
if (updates.user_id) {
|
||||||
|
t.user_id = updates.user_id;
|
||||||
|
const foundUser = App.lookups.users.find(u => u.id === updates.user_id);
|
||||||
|
if (foundUser) {
|
||||||
|
t.owner_first = foundUser.first_name;
|
||||||
|
t.owner_last = foundUser.last_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cancelBatchSelection();
|
||||||
|
this.renderLocalResults();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore aggiornamento batch: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async mergeBatch() {
|
||||||
|
if (this.selectedOrder.length < 2) return;
|
||||||
|
|
||||||
|
const targetId = this.selectedOrder[0];
|
||||||
|
const sourceIds = this.selectedOrder.slice(1);
|
||||||
|
|
||||||
|
const targetRow = document.querySelector(`#spudorata-results-area tr[data-ticket-id="${targetId}"]`);
|
||||||
|
const targetTn = targetRow ? targetRow.querySelector('.ticket-tn-link').textContent.trim() : targetId;
|
||||||
|
|
||||||
|
const sourceTns = sourceIds.map(id => {
|
||||||
|
const row = document.querySelector(`#spudorata-results-area tr[data-ticket-id="${id}"]`);
|
||||||
|
return row ? row.querySelector('.ticket-tn-link').textContent.trim() : `#${id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmed = await App.confirm('Unione Ticket', `Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await App.api('/api/tickets/merge', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetId: parseInt(targetId),
|
||||||
|
sourceIds: sourceIds.map(x => parseInt(x))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(res.message || 'Ticket uniti con successo');
|
||||||
|
|
||||||
|
if (this.cachedTickets) {
|
||||||
|
this.cachedTickets.forEach(t => {
|
||||||
|
if (sourceIds.includes(String(t.id))) {
|
||||||
|
t.ticket_state_id = 9;
|
||||||
|
t.state_name = 'merged';
|
||||||
|
t.state_type = 'merged';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cancelBatchSelection();
|
||||||
|
this.renderLocalResults();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante l\'unione: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -135,7 +135,7 @@ const TicketCreateView = {
|
|||||||
<label class="form-label">Coda <span class="required">*</span></label>
|
<label class="form-label">Coda <span class="required">*</span></label>
|
||||||
<input type="text" class="form-input" id="create-queue-search" placeholder="Cerca coda..." autocomplete="off" />
|
<input type="text" class="form-input" id="create-queue-search" placeholder="Cerca coda..." autocomplete="off" />
|
||||||
<input type="hidden" id="create-queue" />
|
<input type="hidden" id="create-queue" />
|
||||||
<div id="queue-suggestions" class="autocomplete-suggestions" style="display:none; width: 450px; max-width: 600px; z-index: 1005;"></div>
|
<div id="queue-suggestions" class="autocomplete-suggestions" style="display:none; min-width: 500px; width: max-content; max-width: 85vw; z-index: 1005;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ const TicketDetailView = {
|
|||||||
<label class="quick-edit-label">Coda</label>
|
<label class="quick-edit-label">Coda</label>
|
||||||
<input type="text" class="quick-edit-select" id="qe-queue-search" placeholder="Cerca coda..." autocomplete="off" value="${App.escapeHtml(ticket.queue_name || '')}" style="background-image: none; cursor: text;" />
|
<input type="text" class="quick-edit-select" id="qe-queue-search" placeholder="Cerca coda..." autocomplete="off" value="${App.escapeHtml(ticket.queue_name || '')}" style="background-image: none; cursor: text;" />
|
||||||
<input type="hidden" id="qe-queue" data-field="queue_id" value="${ticket.queue_id || ''}" />
|
<input type="hidden" id="qe-queue" data-field="queue_id" value="${ticket.queue_id || ''}" />
|
||||||
<div id="qe-queue-suggestions" class="autocomplete-suggestions" style="display:none; width: 800px; max-width: 800px; z-index: 1005;"></div>
|
<div id="qe-queue-suggestions" class="autocomplete-suggestions" style="display:none; min-width: 500px; width: max-content; max-width: 85vw; z-index: 1005;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="quick-edit-field">
|
<div class="quick-edit-field">
|
||||||
<label class="quick-edit-label">Owner</label>
|
<label class="quick-edit-label">Owner</label>
|
||||||
|
|||||||
@@ -125,9 +125,9 @@ const TicketListView = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="filter-group" style="position:relative;">
|
<div class="filter-group" style="position:relative;">
|
||||||
<span class="filter-label">Coda</span>
|
<span class="filter-label">Coda</span>
|
||||||
<input type="text" class="form-input filter-select" id="batch-queue-search" placeholder="Cerca coda..." autocomplete="off" style="width:160px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
<input type="text" class="form-input filter-select" id="batch-queue-search" placeholder="Cerca coda..." autocomplete="off" style="width:180px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||||
<input type="hidden" id="batch-queue" />
|
<input type="hidden" id="batch-queue" />
|
||||||
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; width: 280px; z-index: 1001;"></div>
|
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; min-width: 480px; width: max-content; max-width: 85vw; z-index: 1001;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Owner</span>
|
<span class="filter-label">Owner</span>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { db, logAttivita } = require('../activityDb');
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
const baseDir = process.pkg ? path.dirname(process.execPath) : path.resolve(__dirname, '..');
|
||||||
|
const envPath = fs.existsSync(path.resolve(baseDir, '.env'))
|
||||||
|
? path.resolve(baseDir, '.env')
|
||||||
|
: path.resolve(__dirname, '..', '.env');
|
||||||
|
|
||||||
|
// Helper to resolve agent name from DB
|
||||||
|
async function resolveAgentName(agentId) {
|
||||||
|
try {
|
||||||
|
const r = await pool.query(
|
||||||
|
`SELECT first_name, last_name, login FROM users WHERE id = $1`,
|
||||||
|
[agentId]
|
||||||
|
);
|
||||||
|
if (r.rows.length > 0) {
|
||||||
|
const u = r.rows[0];
|
||||||
|
return (u.first_name || '') + ' ' + (u.last_name || '') || u.login || `Agent #${agentId}`;
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
return `Agent #${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/config/env — Read current .env file and history
|
||||||
|
router.get('/env', async (req, res) => {
|
||||||
|
try {
|
||||||
|
let content = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
content = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = db.prepare(`
|
||||||
|
SELECT * FROM env_history ORDER BY id DESC LIMIT 100
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json({ content, history });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading env config:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante la lettura del file .env', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/config/env — Save updated .env content and create snapshot in SQLite
|
||||||
|
router.post('/env', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { content, note } = req.body;
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const agentName = await resolveAgentName(operatorId);
|
||||||
|
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Il contenuto del file .env è obbligatorio' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If history is empty and current .env exists, create an initial baseline snapshot
|
||||||
|
const countRow = db.prepare('SELECT COUNT(*) AS count FROM env_history').get();
|
||||||
|
if (countRow.count === 0 && fs.existsSync(envPath)) {
|
||||||
|
const initialContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO env_history (agent_id, agent_name, content, note)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(operatorId, agentName, initialContent, 'Baseline iniziale preesistente');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated content to disk
|
||||||
|
fs.writeFileSync(envPath, content, 'utf8');
|
||||||
|
|
||||||
|
// Save snapshot in history table
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO env_history (agent_id, agent_name, content, note)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(operatorId, agentName, content, note || 'Modifica manuale .env');
|
||||||
|
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
agente_nome: agentName,
|
||||||
|
titolo_azione: 'Modifica .env',
|
||||||
|
azione: { note: note || 'Modifica manuale .env', history_id: info.lastInsertRowid },
|
||||||
|
esito: 'successo',
|
||||||
|
});
|
||||||
|
|
||||||
|
const history = db.prepare(`
|
||||||
|
SELECT * FROM env_history ORDER BY id DESC LIMIT 100
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'File .env aggiornato e salvato nello storico locale con successo!',
|
||||||
|
history_id: info.lastInsertRowid,
|
||||||
|
history,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving .env config:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante il salvataggio del file .env', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/config/env/restore/:history_id — Restore a previous .env snapshot
|
||||||
|
router.post('/env/restore/:history_id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { history_id } = req.params;
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const agentName = await resolveAgentName(operatorId);
|
||||||
|
|
||||||
|
const record = db.prepare('SELECT * FROM env_history WHERE id = ?').get(history_id);
|
||||||
|
if (!record) {
|
||||||
|
return res.status(404).json({ error: 'Versione di storico non trovata' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write snapshot content to disk
|
||||||
|
fs.writeFileSync(envPath, record.content, 'utf8');
|
||||||
|
|
||||||
|
// Insert new history record documenting the restore operation
|
||||||
|
const restoreNote = `Ripristinato alla versione #${record.id} del ${record.created_at}`;
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO env_history (agent_id, agent_name, content, note)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(operatorId, agentName, record.content, restoreNote);
|
||||||
|
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
agente_nome: agentName,
|
||||||
|
titolo_azione: 'Ripristino .env',
|
||||||
|
azione: { restored_from_id: record.id, new_history_id: info.lastInsertRowid },
|
||||||
|
esito: 'successo',
|
||||||
|
});
|
||||||
|
|
||||||
|
const history = db.prepare(`
|
||||||
|
SELECT * FROM env_history ORDER BY id DESC LIMIT 100
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Versione #${record.id} ripristinata con successo!`,
|
||||||
|
content: record.content,
|
||||||
|
history,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error restoring .env version:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante il ripristino della versione .env', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+100
-19
@@ -193,6 +193,59 @@ router.delete('/address-groups/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── SETTINGS ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/email/settings — Fetch agent sender preference
|
||||||
|
router.get('/settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const agentId = parseInt(req.query.agent_id || '1', 10);
|
||||||
|
const row = db.prepare(`SELECT use_agent_sender FROM agent_settings WHERE agent_id = ?`).get(agentId);
|
||||||
|
const useAgentSender = row ? Boolean(row.use_agent_sender) : false;
|
||||||
|
|
||||||
|
let agentEmail = '';
|
||||||
|
const prefRes = await pool.query(
|
||||||
|
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
|
||||||
|
[agentId]
|
||||||
|
);
|
||||||
|
if (prefRes.rows.length > 0) {
|
||||||
|
agentEmail = prefRes.rows[0].preferences_value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const envSender = process.env.AZURE_MAIL_SENDER || process.env.SMTP_FROM || process.env.SMTP_USER || '';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
agent_id: agentId,
|
||||||
|
use_agent_sender: useAgentSender,
|
||||||
|
agent_email: agentEmail,
|
||||||
|
env_sender: envSender
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Email] Error fetching settings:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante il recupero delle impostazioni email: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/email/settings — Update agent sender preference
|
||||||
|
router.post('/settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { agent_id, use_agent_sender } = req.body;
|
||||||
|
const agentId = parseInt(agent_id || '1', 10);
|
||||||
|
const val = use_agent_sender ? 1 : 0;
|
||||||
|
|
||||||
|
const existing = db.prepare(`SELECT agent_id FROM agent_settings WHERE agent_id = ?`).get(agentId);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(`UPDATE agent_settings SET use_agent_sender = ? WHERE agent_id = ?`).run(val, agentId);
|
||||||
|
} else {
|
||||||
|
db.prepare(`INSERT INTO agent_settings (agent_id, use_agent_sender) VALUES (?, ?)`).run(agentId, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, use_agent_sender: Boolean(val) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Email] Error updating settings:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante il salvataggio delle impostazioni email: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── SEND EMAIL ───────────────────────────────────────────────────────────────
|
// ─── SEND EMAIL ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// POST /api/email/send — send an email for a ticket
|
// POST /api/email/send — send an email for a ticket
|
||||||
@@ -207,7 +260,6 @@ router.post('/send', async (req, res) => {
|
|||||||
attachments = [],
|
attachments = [],
|
||||||
inlineImages = [],
|
inlineImages = [],
|
||||||
agentId,
|
agentId,
|
||||||
agentName = 'Agente',
|
|
||||||
keepHelpdeskCopy = true,
|
keepHelpdeskCopy = true,
|
||||||
inReplyTo,
|
inReplyTo,
|
||||||
references,
|
references,
|
||||||
@@ -218,6 +270,39 @@ router.post('/send', async (req, res) => {
|
|||||||
if (!bodyHtml) return res.status(400).json({ error: 'Il corpo della email è obbligatorio' });
|
if (!bodyHtml) return res.status(400).json({ error: 'Il corpo della email è obbligatorio' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Fetch agent email and login details
|
||||||
|
let agentEmail = '';
|
||||||
|
let agentName = '';
|
||||||
|
|
||||||
|
if (agentId) {
|
||||||
|
const userRes = await pool.query(
|
||||||
|
`SELECT login, first_name, last_name FROM users WHERE id = $1`,
|
||||||
|
[agentId]
|
||||||
|
);
|
||||||
|
if (userRes.rows.length > 0) {
|
||||||
|
const u = userRes.rows[0];
|
||||||
|
agentName = (u.first_name && u.last_name) ? `${u.first_name} ${u.last_name}` : (u.login || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefRes = await pool.query(
|
||||||
|
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
|
||||||
|
[agentId]
|
||||||
|
);
|
||||||
|
if (prefRes.rows.length > 0) {
|
||||||
|
agentEmail = prefRes.rows[0].preferences_value || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent sender override is enabled in agent_settings SQLite
|
||||||
|
let customSender = null;
|
||||||
|
if (agentId) {
|
||||||
|
const settingRow = db.prepare(`SELECT use_agent_sender FROM agent_settings WHERE agent_id = ?`).get(parseInt(agentId, 10));
|
||||||
|
const useAgentSender = settingRow ? settingRow.use_agent_sender === 1 : false;
|
||||||
|
if (useAgentSender && agentEmail) {
|
||||||
|
customSender = agentName ? `"${agentName}" <${agentEmail}>` : agentEmail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Fetch ticket number and title for subject
|
// 1. Fetch ticket number and title for subject
|
||||||
const ticketResult = await pool.query(
|
const ticketResult = await pool.query(
|
||||||
`SELECT tn, title FROM ticket WHERE id = $1`,
|
`SELECT tn, title FROM ticket WHERE id = $1`,
|
||||||
@@ -263,29 +348,25 @@ router.post('/send', async (req, res) => {
|
|||||||
const messageId = `<${Date.now()}.${Math.random().toString(36).substring(2)}@pharmaidea.com>`;
|
const messageId = `<${Date.now()}.${Math.random().toString(36).substring(2)}@pharmaidea.com>`;
|
||||||
|
|
||||||
// 3. Send via configured mailer (Graph API or SMTP)
|
// 3. Send via configured mailer (Graph API or SMTP)
|
||||||
const mailResult = await sendMail({ to, cc, bcc: bccList, subject, bodyHtml: processedBodyHtml, attachments, inlineImages: finalInlineImages, inReplyTo, references, messageId });
|
const mailResult = await sendMail({
|
||||||
|
from: customSender,
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
bcc: bccList,
|
||||||
|
subject,
|
||||||
|
bodyHtml: processedBodyHtml,
|
||||||
|
attachments,
|
||||||
|
inlineImages: finalInlineImages,
|
||||||
|
inReplyTo,
|
||||||
|
references,
|
||||||
|
messageId
|
||||||
|
});
|
||||||
const finalMessageId = (mailResult && mailResult.internetMessageId) || messageId;
|
const finalMessageId = (mailResult && mailResult.internetMessageId) || messageId;
|
||||||
|
|
||||||
// 4. Log article in OTRS ticket via DB as a standard Email article
|
// 4. Log article in OTRS ticket via DB as a standard Email article
|
||||||
try {
|
try {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const agentLoginResult = agentId
|
const aFrom = customSender || (agentEmail ? `"${agentName}" <${agentEmail}>` : `"${agentName || 'system'}" <${process.env.AZURE_MAIL_SENDER || process.env.SMTP_FROM || 'helpdesk@example.com'}>`);
|
||||||
? await pool.query(`SELECT login FROM users WHERE id = $1`, [agentId])
|
|
||||||
: null;
|
|
||||||
const agentLogin = agentLoginResult?.rows[0]?.login || 'system';
|
|
||||||
|
|
||||||
// Query email from user_preferences for agent
|
|
||||||
let agentEmail = '';
|
|
||||||
if (agentId) {
|
|
||||||
const prefRes = await pool.query(
|
|
||||||
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
|
|
||||||
[agentId]
|
|
||||||
);
|
|
||||||
if (prefRes.rows.length > 0) {
|
|
||||||
agentEmail = prefRes.rows[0].preferences_value || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const aFrom = agentEmail ? `"${agentLogin}" <${agentEmail}>` : `"${agentLogin}" <${process.env.AZURE_MAIL_SENDER || process.env.SMTP_FROM || 'helpdesk@example.com'}>`;
|
|
||||||
|
|
||||||
const toList = to.join(', ');
|
const toList = to.join(', ');
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -3,6 +3,12 @@ const router = express.Router();
|
|||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
const { db, logAttivita } = require('../activityDb');
|
const { db, logAttivita } = require('../activityDb');
|
||||||
|
|
||||||
|
function getLocalTimestamp() {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to fetch details of a list of tickets from OTRS DB
|
// Helper to fetch details of a list of tickets from OTRS DB
|
||||||
async function fetchTicketsDetails(ticketIds) {
|
async function fetchTicketsDetails(ticketIds) {
|
||||||
if (!ticketIds || ticketIds.length === 0) return [];
|
if (!ticketIds || ticketIds.length === 0) return [];
|
||||||
@@ -295,16 +301,17 @@ router.post('/close-end-of-day', async (req, res) => {
|
|||||||
stateId = fallbackRes.rows[0]?.id || 2;
|
stateId = fallbackRes.rows[0]?.id || 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localNow = getLocalTimestamp();
|
||||||
const updatePlaceholders = openTicketIds.map((_, i) => `$${i + 1}`).join(', ');
|
const updatePlaceholders = openTicketIds.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE ticket
|
UPDATE ticket
|
||||||
SET ticket_state_id = $${openTicketIds.length + 1},
|
SET ticket_state_id = $${openTicketIds.length + 1},
|
||||||
ticket_lock_id = 1,
|
ticket_lock_id = 1,
|
||||||
change_time = NOW(),
|
change_time = $${openTicketIds.length + 2},
|
||||||
change_by = $${openTicketIds.length + 2}
|
change_by = $${openTicketIds.length + 3}
|
||||||
WHERE id IN (${updatePlaceholders})
|
WHERE id IN (${updatePlaceholders})
|
||||||
`;
|
`;
|
||||||
await pool.query(updateQuery, [...openTicketIds, stateId, operatorId]);
|
await pool.query(updateQuery, [...openTicketIds, stateId, localNow, operatorId]);
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
logAttivita({
|
logAttivita({
|
||||||
|
|||||||
+109
-48
@@ -890,8 +890,9 @@ router.patch('/:id', async (req, res) => {
|
|||||||
|
|
||||||
if (setClauses.length > 0) {
|
if (setClauses.length > 0) {
|
||||||
// Always update change_time and change_by
|
// Always update change_time and change_by
|
||||||
setClauses.push(`change_time = NOW()`);
|
setClauses.push(`change_time = $${pIdx++}`);
|
||||||
setClauses.push(`change_by = $${pIdx++}`);
|
setClauses.push(`change_by = $${pIdx++}`);
|
||||||
|
setParams.push(localNow);
|
||||||
setParams.push(operatorId);
|
setParams.push(operatorId);
|
||||||
setParams.push(parseInt(id));
|
setParams.push(parseInt(id));
|
||||||
|
|
||||||
@@ -902,8 +903,8 @@ router.patch('/:id', async (req, res) => {
|
|||||||
} else {
|
} else {
|
||||||
// If there are no fields modified but we have a note or time_unit, we still update change_time/change_by
|
// If there are no fields modified but we have a note or time_unit, we still update change_time/change_by
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE ticket SET change_time = NOW(), change_by = $1 WHERE id = $2`,
|
`UPDATE ticket SET change_time = $1, change_by = $2 WHERE id = $3`,
|
||||||
[operatorId, id]
|
[localNow, operatorId, id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,13 +951,13 @@ router.patch('/:id', async (req, res) => {
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6, $7, $8,
|
$6, $7, $8,
|
||||||
NOW(), $9, NOW(), $9
|
$9, $10, $9, $10
|
||||||
)`,
|
)`,
|
||||||
[
|
[
|
||||||
historyName,
|
historyName,
|
||||||
htResult.rows[0].id, parseInt(id), newTypeId, newQueueId,
|
htResult.rows[0].id, parseInt(id), newTypeId, newQueueId,
|
||||||
newOwnerId, newPriorityId, newStateId,
|
newOwnerId, newPriorityId, newStateId,
|
||||||
operatorId
|
localNow, operatorId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -983,9 +984,9 @@ router.patch('/:id', async (req, res) => {
|
|||||||
is_visible_for_customer, search_index_needs_rebuild,
|
is_visible_for_customer, search_index_needs_rebuild,
|
||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, 0, 1, NOW(), $4, NOW(), $4
|
$1, $2, $3, 0, 1, $4, $5, $4, $5
|
||||||
) RETURNING id`,
|
) RETURNING id`,
|
||||||
[id, senderTypeId, channelId, operatorId]
|
[id, senderTypeId, channelId, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const articleId = articleResult.rows[0].id;
|
const articleId = articleResult.rows[0].id;
|
||||||
@@ -1012,9 +1013,9 @@ router.patch('/:id', async (req, res) => {
|
|||||||
$1, $2, '', '', '', '', $3, $4,
|
$1, $2, '', '', '', '', $3, $4,
|
||||||
'', '', '',
|
'', '', '',
|
||||||
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
||||||
NOW(), $6, NOW(), $6
|
$6, $7, $6, $7
|
||||||
)`,
|
)`,
|
||||||
[articleId, fromHeader, noteSubject, htmlBody, contentPath, operatorId]
|
[articleId, fromHeader, noteSubject, htmlBody, contentPath, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create article_data_mime_attachment for OTRS CE HTML rendering
|
// Create article_data_mime_attachment for OTRS CE HTML rendering
|
||||||
@@ -1024,9 +1025,9 @@ router.patch('/:id', async (req, res) => {
|
|||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
|
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
|
||||||
NOW(), $4, NOW(), $4
|
$4, $5, $4, $5
|
||||||
)`,
|
)`,
|
||||||
[articleId, String(contentSize), base64Body, operatorId]
|
[articleId, String(contentSize), base64Body, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log the time in time_accounting if provided
|
// Log the time in time_accounting if provided
|
||||||
@@ -1035,8 +1036,8 @@ router.patch('/:id', async (req, res) => {
|
|||||||
`INSERT INTO time_accounting (
|
`INSERT INTO time_accounting (
|
||||||
ticket_id, article_id, time_unit,
|
ticket_id, article_id, time_unit,
|
||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
|
) VALUES ($1, $2, $3, $4, $5, $4, $5)`,
|
||||||
[id, articleId, timeUnitVal, operatorId]
|
[id, articleId, timeUnitVal, localNow, operatorId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1733,44 +1734,60 @@ router.post('/merge', async (req, res) => {
|
|||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
const localNow = getLocalTimestamp();
|
||||||
|
|
||||||
// Fetch target ticket details
|
// Fetch target ticket details
|
||||||
const targetRes = await client.query('SELECT tn, title FROM ticket WHERE id = $1', [targetId]);
|
const targetRes = await client.query(
|
||||||
|
'SELECT tn, title, queue_id, user_id, ticket_priority_id, ticket_state_id, type_id FROM ticket WHERE id = $1',
|
||||||
|
[targetId]
|
||||||
|
);
|
||||||
if (targetRes.rows.length === 0) {
|
if (targetRes.rows.length === 0) {
|
||||||
throw new Error(`Ticket di destinazione #${targetId} non trovato`);
|
throw new Error(`Ticket di destinazione #${targetId} non trovato`);
|
||||||
}
|
}
|
||||||
const targetTn = targetRes.rows[0].tn;
|
const targetTn = targetRes.rows[0].tn;
|
||||||
const targetTitle = targetRes.rows[0].title;
|
const targetTitle = targetRes.rows[0].title;
|
||||||
|
const targetRow = targetRes.rows[0];
|
||||||
|
|
||||||
|
// Fetch history type IDs
|
||||||
|
const htStateRes = await client.query(`SELECT id FROM ticket_history_type WHERE name = 'StateUpdate'`);
|
||||||
|
const stateUpdateTypeId = htStateRes.rows.length > 0 ? htStateRes.rows[0].id : 1;
|
||||||
|
|
||||||
|
const htNoteRes = await client.query(`SELECT id FROM ticket_history_type WHERE name = 'AddNote'`);
|
||||||
|
const addNoteTypeId = htNoteRes.rows.length > 0 ? htNoteRes.rows[0].id : 1;
|
||||||
|
|
||||||
for (const sourceId of sourceIds) {
|
for (const sourceId of sourceIds) {
|
||||||
// Fetch source ticket details
|
// Fetch source ticket details
|
||||||
const sourceRes = await client.query('SELECT tn, title FROM ticket WHERE id = $1', [sourceId]);
|
const sourceRes = await client.query(
|
||||||
|
'SELECT tn, title, queue_id, user_id, ticket_priority_id, type_id FROM ticket WHERE id = $1',
|
||||||
|
[sourceId]
|
||||||
|
);
|
||||||
if (sourceRes.rows.length === 0) continue;
|
if (sourceRes.rows.length === 0) continue;
|
||||||
const sourceTn = sourceRes.rows[0].tn;
|
const sourceTn = sourceRes.rows[0].tn;
|
||||||
const sourceTitle = sourceRes.rows[0].title;
|
const sourceTitle = sourceRes.rows[0].title;
|
||||||
|
const sourceRow = sourceRes.rows[0];
|
||||||
|
|
||||||
// 1. Move all articles of source to target
|
// 1. Move all articles of source to target
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE article
|
`UPDATE article
|
||||||
SET ticket_id = $1, change_time = NOW(), change_by = $2
|
SET ticket_id = $1, change_time = $2, change_by = $3
|
||||||
WHERE ticket_id = $3`,
|
WHERE ticket_id = $4`,
|
||||||
[targetId, operatorId, sourceId]
|
[targetId, localNow, operatorId, sourceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Move all time accounting entries
|
// 2. Move all time accounting entries
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE time_accounting
|
`UPDATE time_accounting
|
||||||
SET ticket_id = $1, change_time = NOW(), change_by = $2
|
SET ticket_id = $1, change_time = $2, change_by = $3
|
||||||
WHERE ticket_id = $3`,
|
WHERE ticket_id = $4`,
|
||||||
[targetId, operatorId, sourceId]
|
[targetId, localNow, operatorId, sourceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Mark source ticket as merged (state_id = 9)
|
// 3. Mark source ticket as merged (state_id = 9)
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE ticket
|
`UPDATE ticket
|
||||||
SET ticket_state_id = 9, change_time = NOW(), change_by = $1
|
SET ticket_state_id = 9, change_time = $1, change_by = $2
|
||||||
WHERE id = $2`,
|
WHERE id = $3`,
|
||||||
[operatorId, sourceId]
|
[localNow, operatorId, sourceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. Create ParentChild relation link (source B is child/merged of target A)
|
// 4. Create ParentChild relation link (source B is child/merged of target A)
|
||||||
@@ -1786,8 +1803,8 @@ router.post('/merge', async (req, res) => {
|
|||||||
`INSERT INTO link_relation (
|
`INSERT INTO link_relation (
|
||||||
source_object_id, source_key, target_object_id, target_key,
|
source_object_id, source_key, target_object_id, target_key,
|
||||||
type_id, state_id, create_time, create_by
|
type_id, state_id, create_time, create_by
|
||||||
) VALUES (1, $1, 1, $2, 2, 1, NOW(), $3)`,
|
) VALUES (1, $1, 1, $2, 2, 1, $3, $4)`,
|
||||||
[String(sourceId), String(targetId), operatorId]
|
[String(sourceId), String(targetId), localNow, operatorId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1801,8 +1818,8 @@ router.post('/merge', async (req, res) => {
|
|||||||
ticket_id, article_sender_type_id, communication_channel_id,
|
ticket_id, article_sender_type_id, communication_channel_id,
|
||||||
is_visible_for_customer, search_index_needs_rebuild,
|
is_visible_for_customer, search_index_needs_rebuild,
|
||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES ($1, 1, 1, 0, 1, NOW(), $2, NOW(), $2) RETURNING id`,
|
) VALUES ($1, 1, 1, 0, 1, $2, $3, $2, $3) RETURNING id`,
|
||||||
[targetId, operatorId]
|
[targetId, localNow, operatorId]
|
||||||
);
|
);
|
||||||
const articleId = artResult.rows[0].id;
|
const articleId = artResult.rows[0].id;
|
||||||
|
|
||||||
@@ -1818,9 +1835,9 @@ router.post('/merge', async (req, res) => {
|
|||||||
$1, 'Sistema OTRS Turbo', '', '', '', '', 'Ticket Unito', $2,
|
$1, 'Sistema OTRS Turbo', '', '', '', '', 'Ticket Unito', $2,
|
||||||
'', '', '',
|
'', '', '',
|
||||||
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
|
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
|
||||||
NOW(), $4, NOW(), $4
|
$4, $5, $4, $5
|
||||||
)`,
|
)`,
|
||||||
[articleId, mergeNoteBody, contentPath, operatorId]
|
[articleId, mergeNoteBody, contentPath, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create internal system note inside source ticket B (the merged ticket)
|
// Create internal system note inside source ticket B (the merged ticket)
|
||||||
@@ -1829,8 +1846,8 @@ router.post('/merge', async (req, res) => {
|
|||||||
ticket_id, article_sender_type_id, communication_channel_id,
|
ticket_id, article_sender_type_id, communication_channel_id,
|
||||||
is_visible_for_customer, search_index_needs_rebuild,
|
is_visible_for_customer, search_index_needs_rebuild,
|
||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES ($1, 1, 1, 0, 1, NOW(), $2, NOW(), $2) RETURNING id`,
|
) VALUES ($1, 1, 1, 0, 1, $2, $3, $2, $3) RETURNING id`,
|
||||||
[sourceId, operatorId]
|
[sourceId, localNow, operatorId]
|
||||||
);
|
);
|
||||||
const sourceArticleId = sourceArtResult.rows[0].id;
|
const sourceArticleId = sourceArtResult.rows[0].id;
|
||||||
const sourceMergeNoteText = `Merged Ticket ${sourceTn} to ${targetTn}`;
|
const sourceMergeNoteText = `Merged Ticket ${sourceTn} to ${targetTn}`;
|
||||||
@@ -1845,12 +1862,54 @@ router.post('/merge', async (req, res) => {
|
|||||||
$1, 'Sistema OTRS Turbo', '', '', '', '', $2, $2,
|
$1, 'Sistema OTRS Turbo', '', '', '', '', $2, $2,
|
||||||
'', '', '',
|
'', '', '',
|
||||||
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
|
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
|
||||||
NOW(), $4, NOW(), $4
|
$4, $5, $4, $5
|
||||||
)`,
|
)`,
|
||||||
[sourceArticleId, sourceMergeNoteText, contentPath, operatorId]
|
[sourceArticleId, sourceMergeNoteText, contentPath, localNow, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Record ticket history for source ticket (state changed to merged = 9)
|
||||||
|
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 (
|
||||||
|
'%%merged%%', $1, $2, $3, $4,
|
||||||
|
$5, $6, 9,
|
||||||
|
$7, $8, $7, $8
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
stateUpdateTypeId, sourceId, sourceRow.type_id || 1, sourceRow.queue_id,
|
||||||
|
sourceRow.user_id || 1, sourceRow.ticket_priority_id || 1,
|
||||||
|
localNow, operatorId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Record ticket history for target ticket (note added for merge)
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ticket_history (
|
||||||
|
name, history_type_id, ticket_id, article_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, $10, $9, $10
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
addNoteTypeId, targetId, articleId, targetRow.type_id || 1, targetRow.queue_id,
|
||||||
|
targetRow.user_id || 1, targetRow.ticket_priority_id || 1, targetRow.ticket_state_id || 1,
|
||||||
|
localNow, operatorId
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update target ticket change_time & change_by
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET change_time = $1, change_by = $2 WHERE id = $3`,
|
||||||
|
[localNow, operatorId, targetId]
|
||||||
|
);
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
resolveAgentName(operatorId).then(agente_nome => {
|
resolveAgentName(operatorId).then(agente_nome => {
|
||||||
logAttivita({
|
logAttivita({
|
||||||
@@ -2185,6 +2244,8 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
const tn = `${datePrefix}${String(counter).padStart(counterPadding, '0')}`;
|
const tn = `${datePrefix}${String(counter).padStart(counterPadding, '0')}`;
|
||||||
|
|
||||||
// 3. Create Ticket (lock = 1 [unlock], owner & responsible = operatorId)
|
// 3. Create Ticket (lock = 1 [unlock], owner & responsible = operatorId)
|
||||||
|
const localNow = getLocalTimestamp();
|
||||||
|
|
||||||
const ticketResult = await client.query(
|
const ticketResult = await client.query(
|
||||||
`INSERT INTO ticket (
|
`INSERT INTO ticket (
|
||||||
tn, title, queue_id, ticket_lock_id, type_id,
|
tn, title, queue_id, ticket_lock_id, type_id,
|
||||||
@@ -2205,14 +2266,14 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
0, 0,
|
0, 0,
|
||||||
0, 0,
|
0, 0,
|
||||||
0,
|
0,
|
||||||
NOW(), $10, NOW(), $10
|
$10, $11, $10, $11
|
||||||
) RETURNING id, tn`,
|
) RETURNING id, tn`,
|
||||||
[
|
[
|
||||||
tn, title, queueId, typeId,
|
tn, title, queueId, typeId,
|
||||||
operatorId,
|
operatorId,
|
||||||
priorityId, stateId,
|
priorityId, stateId,
|
||||||
customerId, customerUserId,
|
customerId, customerUserId,
|
||||||
operatorId
|
localNow, operatorId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const ticketId = ticketResult.rows[0].id;
|
const ticketId = ticketResult.rows[0].id;
|
||||||
@@ -2227,8 +2288,8 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
name, history_type_id, ticket_id, type_id, queue_id,
|
name, history_type_id, ticket_id, type_id, queue_id,
|
||||||
owner_id, priority_id, state_id,
|
owner_id, priority_id, state_id,
|
||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, NOW(), $9)`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $9, $10)`,
|
||||||
['%%', historyTypeId, ticketId, typeId || 1, queueId, operatorId, priorityId, stateId, operatorId]
|
['%%', historyTypeId, ticketId, typeId || 1, queueId, operatorId, priorityId, stateId, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. Create Article (Internal note)
|
// 4. Create Article (Internal note)
|
||||||
@@ -2247,8 +2308,8 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
ticket_id, article_sender_type_id, communication_channel_id,
|
ticket_id, article_sender_type_id, communication_channel_id,
|
||||||
is_visible_for_customer, search_index_needs_rebuild,
|
is_visible_for_customer, search_index_needs_rebuild,
|
||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES ($1, $2, $3, 0, 1, NOW(), $4, NOW(), $4) RETURNING id`,
|
) VALUES ($1, $2, $3, 0, 1, $4, $5, $4, $5) RETURNING id`,
|
||||||
[ticketId, senderTypeId, channelId, operatorId]
|
[ticketId, senderTypeId, channelId, localNow, operatorId]
|
||||||
);
|
);
|
||||||
const articleId = articleResult.rows[0].id;
|
const articleId = articleResult.rows[0].id;
|
||||||
|
|
||||||
@@ -2269,9 +2330,9 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, '', $3, $4,
|
$1, $2, '', $3, $4,
|
||||||
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
||||||
NOW(), $6, NOW(), $6
|
$6, $7, $6, $7
|
||||||
)`,
|
)`,
|
||||||
[articleId, fromHeader, subject, htmlBody, contentPath, operatorId]
|
[articleId, fromHeader, subject, htmlBody, contentPath, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create article attachment (file-1) for OTRS CE HTML display
|
// Create article attachment (file-1) for OTRS CE HTML display
|
||||||
@@ -2279,8 +2340,8 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
`INSERT INTO article_data_mime_attachment (
|
`INSERT INTO article_data_mime_attachment (
|
||||||
article_id, filename, content_size, content_type, disposition, content,
|
article_id, filename, content_size, content_type, disposition, content,
|
||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES ($1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3, NOW(), $4, NOW(), $4)`,
|
) VALUES ($1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3, $4, $5, $4, $5)`,
|
||||||
[articleId, String(contentSize), base64Body, operatorId]
|
[articleId, String(contentSize), base64Body, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Create Time Accounting Entry for remaining minutes
|
// 5. Create Time Accounting Entry for remaining minutes
|
||||||
@@ -2288,8 +2349,8 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
`INSERT INTO time_accounting (
|
`INSERT INTO time_accounting (
|
||||||
ticket_id, article_id, time_unit,
|
ticket_id, article_id, time_unit,
|
||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
|
) VALUES ($1, $2, $3, $4, $5, $4, $5)`,
|
||||||
[ticketId, articleId, remaining, operatorId]
|
[ticketId, articleId, remaining, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. Close tickets in default group 'CHIUDI A FINE GIORNATA'
|
// 6. Close tickets in default group 'CHIUDI A FINE GIORNATA'
|
||||||
@@ -2303,8 +2364,8 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
|
|
||||||
// Update ticket state to closed in OTRS DB
|
// Update ticket state to closed in OTRS DB
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE ticket SET ticket_state_id = $${groupTicketIds.length + 1}, change_time = NOW(), change_by = $${groupTicketIds.length + 2} WHERE id IN (${idPlaceholders})`,
|
`UPDATE ticket SET ticket_state_id = $${groupTicketIds.length + 1}, change_time = $${groupTicketIds.length + 2}, change_by = $${groupTicketIds.length + 3} WHERE id IN (${idPlaceholders})`,
|
||||||
[...groupTicketIds, stateId, operatorId]
|
[...groupTicketIds, stateId, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[Auto-Time] Closed ${groupTicketIds.length} tickets from group 'CHIUDI A FINE GIORNATA'`);
|
console.log(`[Auto-Time] Closed ${groupTicketIds.length} tickets from group 'CHIUDI A FINE GIORNATA'`);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const activityRouter = require('./routes/activity');
|
|||||||
const emailRouter = require('./routes/email');
|
const emailRouter = require('./routes/email');
|
||||||
const groupsRouter = require('./routes/groups');
|
const groupsRouter = require('./routes/groups');
|
||||||
const presetsRouter = require('./routes/presets');
|
const presetsRouter = require('./routes/presets');
|
||||||
|
const configRouter = require('./routes/config');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -31,6 +32,7 @@ app.use('/api/attivita', activityRouter);
|
|||||||
app.use('/api/email', emailRouter);
|
app.use('/api/email', emailRouter);
|
||||||
app.use('/api/groups', groupsRouter);
|
app.use('/api/groups', groupsRouter);
|
||||||
app.use('/api/presets', presetsRouter);
|
app.use('/api/presets', presetsRouter);
|
||||||
|
app.use('/api/config', configRouter);
|
||||||
|
|
||||||
// SPA fallback — serve index.html for all non-API routes
|
// SPA fallback — serve index.html for all non-API routes
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
|
|||||||
+43
-16
@@ -47,6 +47,19 @@ async function getAccessToken() {
|
|||||||
return cachedToken;
|
return cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSender(raw) {
|
||||||
|
if (!raw) return { name: '', address: '' };
|
||||||
|
const str = raw.trim();
|
||||||
|
const match = str.match(/^(?:"?([^"]*)"?\s+)?<?([^\s@]+@[^\s@>]+)>?$/);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
name: (match[1] || '').trim(),
|
||||||
|
address: match[2].trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: '', address: str };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invia una email tramite Microsoft Graph API.
|
* Invia una email tramite Microsoft Graph API.
|
||||||
*
|
*
|
||||||
@@ -61,8 +74,11 @@ async function getAccessToken() {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) {
|
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) {
|
||||||
const sender = process.env.AZURE_MAIL_SENDER;
|
const rawSender = process.env.AZURE_MAIL_SENDER;
|
||||||
if (!sender) throw new Error('AZURE_MAIL_SENDER non configurato nel .env');
|
if (!rawSender) throw new Error('AZURE_MAIL_SENDER non configurato nel .env');
|
||||||
|
|
||||||
|
const { name: senderName, address: senderAddress } = parseSender(rawSender);
|
||||||
|
if (!senderAddress) throw new Error('Indirizzo email in AZURE_MAIL_SENDER non valido');
|
||||||
|
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
|
|
||||||
@@ -102,23 +118,34 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
|
|||||||
headers.push({ name: 'References', value: references });
|
headers.push({ name: 'References', value: references });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const messageObj = {
|
||||||
message: {
|
subject,
|
||||||
subject,
|
body: {
|
||||||
body: {
|
contentType: 'HTML',
|
||||||
contentType: 'HTML',
|
content: bodyHtml,
|
||||||
content: bodyHtml,
|
|
||||||
},
|
|
||||||
toRecipients,
|
|
||||||
ccRecipients,
|
|
||||||
bccRecipients,
|
|
||||||
attachments: allAttachments,
|
|
||||||
internetMessageHeaders: headers.length ? headers : undefined,
|
|
||||||
},
|
},
|
||||||
|
toRecipients,
|
||||||
|
ccRecipients,
|
||||||
|
bccRecipients,
|
||||||
|
attachments: allAttachments,
|
||||||
|
internetMessageHeaders: headers.length ? headers : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (senderName) {
|
||||||
|
messageObj.from = {
|
||||||
|
emailAddress: {
|
||||||
|
name: senderName,
|
||||||
|
address: senderAddress,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message: messageObj,
|
||||||
saveToSentItems: true,
|
saveToSentItems: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/sendMail`;
|
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(senderAddress)}/sendMail`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -135,7 +162,7 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
|
|||||||
// Wait 1.5 seconds for Exchange to process and place it in Sent Items
|
// Wait 1.5 seconds for Exchange to process and place it in Sent Items
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
const searchUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/mailFolders/sentItems/messages?$filter=subject eq '${subject.replace(/'/g, "''")}'&$top=1&$select=internetMessageId`;
|
const searchUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(senderAddress)}/mailFolders/sentItems/messages?$filter=subject eq '${subject.replace(/'/g, "''")}'&$top=1&$select=internetMessageId`;
|
||||||
const searchRes = await fetch(searchUrl, {
|
const searchRes = await fetch(searchUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
|
|||||||
+2
-2
@@ -29,11 +29,11 @@ function getTransporter() {
|
|||||||
* Invia una email tramite SMTP (nodemailer).
|
* Invia una email tramite SMTP (nodemailer).
|
||||||
* Stessa interfaccia di graphMailer.sendMail.
|
* Stessa interfaccia di graphMailer.sendMail.
|
||||||
*/
|
*/
|
||||||
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) {
|
async function sendMail({ from, to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) {
|
||||||
const transporter = getTransporter();
|
const transporter = getTransporter();
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||||
to: to.join(', '),
|
to: to.join(', '),
|
||||||
cc: cc.length ? cc.join(', ') : undefined,
|
cc: cc.length ? cc.join(', ') : undefined,
|
||||||
bcc: bcc.length ? bcc.join(', ') : undefined,
|
bcc: bcc.length ? bcc.join(', ') : undefined,
|
||||||
|
|||||||
Reference in new issue
Block a user