Compare commits
15
Commits
964241d8ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35888400ed | ||
|
|
922a23c062 | ||
|
|
57175922c2 | ||
|
|
e2482159bc | ||
|
|
99c4f450f4 | ||
|
|
0cbd3ccdfc | ||
|
|
5ef7e66be5 | ||
|
|
38b2645f6f | ||
|
|
7243b3da49 | ||
|
|
9527998b7a | ||
|
|
f36788510e | ||
|
|
b484f93640 | ||
|
|
b90d2b2732 | ||
|
|
1bbe561673 | ||
|
|
47d11c311e |
@@ -3,3 +3,4 @@ node_modules/
|
|||||||
internal.db
|
internal.db
|
||||||
internal.db-shm
|
internal.db-shm
|
||||||
internal.db-wal
|
internal.db-wal
|
||||||
|
dist
|
||||||
+61
-1
@@ -8,7 +8,8 @@ const Database = require('better-sqlite3');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const DB_PATH = path.join(__dirname, 'internal.db');
|
const baseDir = process.pkg ? path.dirname(process.execPath) : __dirname;
|
||||||
|
const DB_PATH = path.join(baseDir, 'internal.db');
|
||||||
const db = new Database(DB_PATH);
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
// Ensure WAL mode for better concurrent access
|
// Ensure WAL mode for better concurrent access
|
||||||
@@ -88,6 +89,65 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS dashboard_chart_lines (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
statuses TEXT NOT NULL,
|
||||||
|
types TEXT NOT NULL,
|
||||||
|
queues TEXT NOT NULL,
|
||||||
|
owners TEXT NOT NULL,
|
||||||
|
responsibles TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
is_visible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bypass_state_filter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN color TEXT`);
|
||||||
|
} catch (e) {
|
||||||
|
// Already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN is_visible INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
} catch (e) {
|
||||||
|
// Already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN bypass_state_filter INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
} catch (e) {
|
||||||
|
// Already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const countRow = db.prepare("SELECT COUNT(*) AS count FROM dashboard_chart_lines").get();
|
||||||
|
if (countRow && countRow.count === 0) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO dashboard_chart_lines (name, statuses, types, queues, owners, responsibles, color, is_visible, is_default, bypass_state_filter)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, 1)
|
||||||
|
`).run('Ticket aperti', JSON.stringify([1, 4, 6, 7, 8]), '[]', '[]', '[]', '[]', '#4f46e5');
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO dashboard_chart_lines (name, statuses, types, queues, owners, responsibles, color, is_visible, is_default, bypass_state_filter)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, 0)
|
||||||
|
`).run('Ticket chiusi', JSON.stringify([2, 3, 10]), '[]', '[]', '[]', '[]', '#10b981');
|
||||||
|
} else {
|
||||||
|
// Update default ones color if not set yet
|
||||||
|
db.prepare(`UPDATE dashboard_chart_lines SET color = '#4f46e5' WHERE name = 'Ticket aperti' AND color IS NULL`).run();
|
||||||
|
db.prepare(`UPDATE dashboard_chart_lines SET color = '#10b981' WHERE name = 'Ticket chiusi' AND color IS NULL`).run();
|
||||||
|
|
||||||
|
// Set default Ticket aperti to bypass state filter
|
||||||
|
db.prepare(`UPDATE dashboard_chart_lines SET bypass_state_filter = 1 WHERE name = 'Ticket aperti'`).run();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error seeding default chart lines:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
|
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@echo off
|
||||||
|
title Compilazione OTRS Turbo Executable (.exe)
|
||||||
|
echo Compilazione di OTRS Turbo in corso...
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo [1/3] Preparazione binding nativo SQLite per Node 18 (ABI 108)...
|
||||||
|
pushd "node_modules\better-sqlite3"
|
||||||
|
call npx prebuild-install --target=18.5.0 --runtime=node --platform=win32 --arch=x64 --force >nul 2>&1
|
||||||
|
popd
|
||||||
|
|
||||||
|
echo [2/3] Creazione eseguibile otrs-turbo.exe...
|
||||||
|
call npm run build:exe
|
||||||
|
|
||||||
|
echo [3/3] Copia asset e file distribuzionali in dist...
|
||||||
|
if exist "node_modules\better-sqlite3\build\Release\better_sqlite3.node" (
|
||||||
|
copy "node_modules\better-sqlite3\build\Release\better_sqlite3.node" "dist\better_sqlite3.node" /Y >nul
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "dist\.env" (
|
||||||
|
if exist ".env.example" (
|
||||||
|
copy ".env.example" "dist\.env" /Y >nul
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================================
|
||||||
|
echo Operazione completata con successo!
|
||||||
|
echo Cartella distribuzionale 'dist' pronta per l'uso:
|
||||||
|
echo - dist\otrs-turbo.exe
|
||||||
|
echo - dist\better_sqlite3.node
|
||||||
|
echo - dist\.env
|
||||||
|
echo - dist\avvia.bat
|
||||||
|
echo ========================================================
|
||||||
|
pause
|
||||||
Generated
+1325
-11
File diff suppressed because it is too large
Load Diff
+23
-4
@@ -3,23 +3,42 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Modern fast interface for OTRS ticket management - direct database access",
|
"description": "Modern fast interface for OTRS ticket management - direct database access",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
|
"bin": "server.js",
|
||||||
|
"pkg": {
|
||||||
|
"scripts": [
|
||||||
|
"routes/*.js",
|
||||||
|
"utils/*.js",
|
||||||
|
"*.js"
|
||||||
|
],
|
||||||
|
"assets": [
|
||||||
|
"public/**/*"
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
"node18-win-x64"
|
||||||
|
]
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "npx -y nodemon server.js"
|
"dev": "npx -y nodemon server.js",
|
||||||
|
"build:exe": "npx pkg@5.8.1 . --targets node18-win-x64 --output dist/otrs-turbo.exe"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^11.3.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"mysql2": "^3.22.5",
|
"mysql2": "^3.22.5",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"pg": "^8.13.0"
|
"pg": "^8.13.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"otrs",
|
"otrs",
|
||||||
"ticket",
|
"ticket",
|
||||||
"helpdesk"
|
"helpdesk"
|
||||||
],
|
],
|
||||||
"license": "AGPL-3.0"
|
"license": "AGPL-3.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"pkg": "^5.8.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,6 +354,25 @@ body {
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-brand-action {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand-action:hover {
|
||||||
|
color: var(--error);
|
||||||
|
background: var(--error-bg);
|
||||||
|
border-color: rgba(220, 38, 38, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-menu {
|
.nav-menu {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
@@ -2566,4 +2585,67 @@ body {
|
|||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
border-left: none !important;
|
border-left: none !important;
|
||||||
border-right: none !important;
|
border-right: none !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Advanced Dashboard Ticket Chart Custom Styles
|
||||||
|
============================================================ */
|
||||||
|
.multiselect-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.multiselect-item {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.multiselect-item:hover {
|
||||||
|
background-color: var(--bg-hover, rgba(0,0,0,0.05));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.multiselect-item.selected {
|
||||||
|
background-color: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.theme-dark .multiselect-item:hover,
|
||||||
|
.theme-rosso .multiselect-item:hover,
|
||||||
|
.theme-naturale .multiselect-item:hover,
|
||||||
|
.theme-ice .multiselect-item:hover,
|
||||||
|
.theme-autunno .multiselect-item:hover,
|
||||||
|
.theme-fairytale .multiselect-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.line-config-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.line-config-row:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
.line-config-row .line-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.line-config-row .line-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
+12
-1
@@ -18,10 +18,20 @@
|
|||||||
<body>
|
<body>
|
||||||
<!-- Sidebar Navigation -->
|
<!-- Sidebar Navigation -->
|
||||||
<nav class="sidebar" id="sidebar">
|
<nav class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-brand" style="border-bottom:none; padding-bottom:4px;">
|
<div class="sidebar-brand" style="border-bottom:none; padding-bottom:4px; display:flex; align-items:center; justify-content:space-between;">
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-md);">
|
||||||
<div class="brand-icon">⚡</div>
|
<div class="brand-icon">⚡</div>
|
||||||
<span class="brand-text">OTRS Turbo</span>
|
<span class="brand-text">OTRS Turbo</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="btn-close-end-of-day" class="btn-brand-action" title="Chiudi i ticket inseriti nel gruppo CHIUDI A FINE GIORNATA">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px; height:18px;">
|
||||||
|
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||||
|
<path d="M9 12h6M9 16h4" />
|
||||||
|
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="sidebar-timer" id="daily-timer"
|
<div class="sidebar-timer" id="daily-timer"
|
||||||
style="padding:var(--space-md) var(--space-lg) var(--space-lg) var(--space-lg); border-bottom:1px solid var(--border-subtle); font-size:0.75rem; color:var(--text-secondary); font-family:monospace; line-height:1.2;">
|
style="padding:var(--space-md) var(--space-lg) var(--space-lg) var(--space-lg); border-bottom:1px solid var(--border-subtle); font-size:0.75rem; color:var(--text-secondary); font-family:monospace; line-height:1.2;">
|
||||||
<span class="timer-display">0 / 480 | 480</span>
|
<span class="timer-display">0 / 480 | 480</span>
|
||||||
@@ -192,6 +202,7 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script src="/js/components/toast.js"></script>
|
<script src="/js/components/toast.js"></script>
|
||||||
<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>
|
||||||
|
|||||||
+42
-13
@@ -156,20 +156,8 @@ const App = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let timeout;
|
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
toggleClearBtn();
|
toggleClearBtn();
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
const hash = window.location.hash;
|
|
||||||
if (hash.startsWith('#/tickets') && !hash.includes('/new') && !hash.match(/#\/tickets\/\d+/)) {
|
|
||||||
TicketListView.currentPage = 1;
|
|
||||||
TicketListView.render();
|
|
||||||
} else {
|
|
||||||
// Navigate to ticket list with search
|
|
||||||
window.location.hash = '#/tickets';
|
|
||||||
}
|
|
||||||
}, 350);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
searchInput.addEventListener('keydown', (e) => {
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
@@ -216,6 +204,34 @@ const App = {
|
|||||||
refreshBtn.addEventListener('click', () => this.refreshLookups());
|
refreshBtn.addEventListener('click', () => this.refreshLookups());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind button close end of day tickets
|
||||||
|
const btnCloseEod = document.getElementById('btn-close-end-of-day');
|
||||||
|
if (btnCloseEod) {
|
||||||
|
btnCloseEod.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
const confirmed = await this.confirm(
|
||||||
|
'Chiusura Ticket Fine Giornata',
|
||||||
|
'Sei sicuro di voler chiudere tutti i ticket nel gruppo "CHIUDI A FINE GIORNATA"?\nI ticket rimarranno nel gruppo.'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.api('/api/groups/close-end-of-day', { method: 'POST' });
|
||||||
|
if (res.count > 0) {
|
||||||
|
Toast.success(res.message);
|
||||||
|
} else {
|
||||||
|
Toast.info(res.message);
|
||||||
|
}
|
||||||
|
this.updateSidebarBadges();
|
||||||
|
this.route();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante la chiusura dei ticket: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initial route
|
// Initial route
|
||||||
window.addEventListener('resize', () => this.updateHeaderHeight());
|
window.addEventListener('resize', () => this.updateHeaderHeight());
|
||||||
setTimeout(() => this.updateHeaderHeight(), 100);
|
setTimeout(() => this.updateHeaderHeight(), 100);
|
||||||
@@ -493,11 +509,21 @@ const App = {
|
|||||||
localStorage.setItem('activeAgentId', select.value);
|
localStorage.setItem('activeAgentId', select.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof Filters !== 'undefined' && Filters.allStates && Filters.allStates.my) {
|
||||||
|
Filters.allStates.my.user_id = select.value;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle dropdown change event
|
// Handle dropdown change event
|
||||||
select.addEventListener('change', () => {
|
select.addEventListener('change', () => {
|
||||||
localStorage.setItem('activeAgentId', select.value);
|
const newAgentId = select.value;
|
||||||
|
localStorage.setItem('activeAgentId', newAgentId);
|
||||||
|
if (typeof Filters !== 'undefined' && Filters.allStates && Filters.allStates.my) {
|
||||||
|
Filters.allStates.my.user_id = newAgentId;
|
||||||
|
localStorage.setItem('otrs_turbo_filters_my', JSON.stringify(Filters.allStates.my));
|
||||||
|
}
|
||||||
Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`);
|
Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`);
|
||||||
this.updateDailyTimer();
|
this.updateDailyTimer();
|
||||||
|
this.updateSidebarBadges();
|
||||||
this.route();
|
this.route();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -624,6 +650,9 @@ const App = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const triggerAction = async (e) => {
|
const triggerAction = async (e) => {
|
||||||
|
if (e && e.target && e.target.closest('#btn-close-end-of-day')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const confirmed = await this.confirm(
|
const confirmed = await this.confirm(
|
||||||
|
|||||||
@@ -55,11 +55,10 @@ const Filters = {
|
|||||||
const savedMy = localStorage.getItem('otrs_turbo_filters_my');
|
const savedMy = localStorage.getItem('otrs_turbo_filters_my');
|
||||||
if (savedMy) {
|
if (savedMy) {
|
||||||
Object.assign(this.allStates.my, JSON.parse(savedMy));
|
Object.assign(this.allStates.my, JSON.parse(savedMy));
|
||||||
} else {
|
}
|
||||||
// Default to active agent if not saved yet
|
// 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;
|
||||||
}
|
|
||||||
|
|
||||||
const savedCache = localStorage.getItem('otrs_turbo_customer_cache');
|
const savedCache = localStorage.getItem('otrs_turbo_customer_cache');
|
||||||
if (savedCache) {
|
if (savedCache) {
|
||||||
@@ -325,7 +324,10 @@ const Filters = {
|
|||||||
<span class="filter-label">A Data/Ora</span>
|
<span class="filter-label">A Data/Ora</span>
|
||||||
<input type="datetime-local" class="filter-select" data-filter="date_to" id="filter-date-to" value="${this.state.date_to || ''}" style="width: 170px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
<input type="datetime-local" class="filter-select" data-filter="date_to" id="filter-date-to" value="${this.state.date_to || ''}" style="width: 170px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="filters-actions">
|
<div class="filters-actions" style="display:flex; gap:6px; align-items:center;">
|
||||||
|
<button class="btn btn-primary btn-xs" id="filter-refresh" title="Aggiorna lista ticket" style="display:inline-flex; align-items:center; gap:4px; height:26px; padding:0 10px; font-size:0.78rem;">
|
||||||
|
🔄 Aggiorna
|
||||||
|
</button>
|
||||||
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
|
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -622,6 +624,13 @@ const Filters = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refreshBtn = document.getElementById('filter-refresh');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => {
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const resetBtn = document.getElementById('filter-reset');
|
const resetBtn = document.getElementById('filter-reset');
|
||||||
if (resetBtn) {
|
if (resetBtn) {
|
||||||
resetBtn.addEventListener('click', () => {
|
resetBtn.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -1,20 +1,136 @@
|
|||||||
/**
|
/**
|
||||||
* Dashboard View
|
* Dashboard View
|
||||||
* Shows stats overview, distribution charts, and recent tickets.
|
* Shows stats overview, customizable advanced ticket trends chart, distribution charts, and recent tickets.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Helper to calculate date boundaries
|
||||||
|
function getDatesForPeriod(period, customFromVal, customToVal) {
|
||||||
|
let start_date, end_date;
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
if (period === 'last_week') {
|
||||||
|
const past = new Date();
|
||||||
|
past.setDate(today.getDate() - 7);
|
||||||
|
start_date = formatDateString(past);
|
||||||
|
end_date = formatDateString(today);
|
||||||
|
} else if (period === 'last_month') {
|
||||||
|
const past = new Date();
|
||||||
|
past.setDate(today.getDate() - 30);
|
||||||
|
start_date = formatDateString(past);
|
||||||
|
end_date = formatDateString(today);
|
||||||
|
} else {
|
||||||
|
start_date = customFromVal || formatDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
|
||||||
|
end_date = customToVal || formatDateString(today);
|
||||||
|
}
|
||||||
|
return { start_date, end_date };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateString(d) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dateVal = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${dateVal}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard View
|
||||||
|
* Shows stats overview, customizable advanced ticket trends chart, distribution charts, and recent tickets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Helper to calculate date boundaries
|
||||||
|
function getDatesForPeriod(period, customFromVal, customToVal) {
|
||||||
|
let start_date, end_date;
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
if (period === 'last_week') {
|
||||||
|
const past = new Date();
|
||||||
|
past.setDate(today.getDate() - 7);
|
||||||
|
start_date = formatDateString(past);
|
||||||
|
end_date = formatDateString(today);
|
||||||
|
} else if (period === 'last_month') {
|
||||||
|
const past = new Date();
|
||||||
|
past.setDate(today.getDate() - 30);
|
||||||
|
start_date = formatDateString(past);
|
||||||
|
end_date = formatDateString(today);
|
||||||
|
} else {
|
||||||
|
start_date = customFromVal || formatDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
|
||||||
|
end_date = customToVal || formatDateString(today);
|
||||||
|
}
|
||||||
|
return { start_date, end_date };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateString(d) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dateVal = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${dateVal}`;
|
||||||
|
}
|
||||||
|
|
||||||
const DashboardView = {
|
const DashboardView = {
|
||||||
|
chartInstance: null,
|
||||||
|
activeLines: [],
|
||||||
|
|
||||||
async render() {
|
async render() {
|
||||||
const container = document.getElementById('view-container');
|
const container = document.getElementById('view-container');
|
||||||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dashboard...</p></div>';
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dashboard...</p></div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ensure lookup tables are loaded in App.lookups
|
||||||
|
await App.ensureLookups();
|
||||||
|
|
||||||
const stats = await App.api('/api/dashboard/stats');
|
const stats = await App.api('/api/dashboard/stats');
|
||||||
|
|
||||||
const maxByState = Math.max(...(stats.by_state || []).map(s => parseInt(s.count)), 1);
|
const maxByState = Math.max(...(stats.by_state || []).map(s => parseInt(s.count)), 1);
|
||||||
const maxByPriority = Math.max(...(stats.by_priority || []).map(s => parseInt(s.count)), 1);
|
const maxByPriority = Math.max(...(stats.by_priority || []).map(s => parseInt(s.count)), 1);
|
||||||
const maxByQueue = Math.max(...(stats.by_queue || []).map(s => parseInt(s.count)), 1);
|
const maxByQueue = Math.max(...(stats.by_queue || []).map(s => parseInt(s.count)), 1);
|
||||||
|
|
||||||
|
// Load saved chart period preferences
|
||||||
|
const savedPeriod = localStorage.getItem('otrs_chart_period') || 'last_week';
|
||||||
|
const savedDateFrom = localStorage.getItem('otrs_chart_date_from') || '';
|
||||||
|
const savedDateTo = localStorage.getItem('otrs_chart_date_to') || '';
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
|
<!-- Custom Ticket Trends Chart Card -->
|
||||||
|
<div class="card chart-card" style="margin-bottom: var(--space-md);">
|
||||||
|
<div class="chart-header" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:var(--space-md); margin-bottom:var(--space-md);">
|
||||||
|
<div class="card-title" style="margin-bottom:0; display:flex; align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
|
||||||
|
<span>Andamento Ticket</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="configure-series-btn" style="padding:4px 8px; font-size:0.8rem; display:flex; align-items:center; gap:4px; height:28px;" title="Configura serie di dati del grafico">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
||||||
|
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Configura serie di dati</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="export-series-excel-btn" style="padding:4px 8px; font-size:0.8rem; display:flex; align-items:center; gap:4px; height:28px;" title="Esporta dati in Excel (XLSX)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||||
|
</svg>
|
||||||
|
<span>Esporta in Excel</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date controls -->
|
||||||
|
<div class="chart-controls" style="display:flex; align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
|
||||||
|
<select id="chart-period-preset" class="form-select" style="height:32px; font-size:0.85rem; padding: 4px 28px 4px 8px; margin:0; width:170px; background-position: right 8px center;">
|
||||||
|
<option value="last_week" ${savedPeriod === 'last_week' ? 'selected' : ''}>Ultima Settimana</option>
|
||||||
|
<option value="last_month" ${savedPeriod === 'last_month' ? 'selected' : ''}>Ultimo Mese</option>
|
||||||
|
<option value="custom" ${savedPeriod === 'custom' ? 'selected' : ''}>Periodo Personalizzato</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="chart-custom-dates" style="display:${savedPeriod === 'custom' ? 'flex' : 'none'}; align-items:center; gap:6px;">
|
||||||
|
<span style="font-size:0.8rem; color:var(--text-secondary);">Da:</span>
|
||||||
|
<input type="date" id="chart-date-from" class="form-input" value="${savedDateFrom}" style="height:32px; font-size:0.85rem; padding:4px 8px; margin:0; width:135px;">
|
||||||
|
<span style="font-size:0.8rem; color:var(--text-secondary);">A:</span>
|
||||||
|
<input type="date" id="chart-date-to" class="form-input" value="${savedDateTo}" style="height:32px; font-size:0.85rem; padding:4px 8px; margin:0; width:135px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-canvas-wrapper" style="position:relative; height:300px; width:100%;">
|
||||||
|
<canvas id="dashboard-ticket-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card accent">
|
<div class="stat-card accent">
|
||||||
@@ -133,8 +249,211 @@ const DashboardView = {
|
|||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Configure Series -->
|
||||||
|
<div id="chart-series-modal" style="display:none; position:fixed; inset:0; z-index:8000; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); align-items:center; justify-content:center; padding: 20px;">
|
||||||
|
<div class="card" style="width:100%; max-width:680px; background:var(--bg-card); max-height: 90vh; display:flex; flex-direction:column; padding: var(--space-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); border: 1px solid var(--border-light);">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-subtle); padding-bottom:var(--space-sm); margin-bottom:var(--space-md); flex-shrink:0;">
|
||||||
|
<h3 style="margin:0; font-size:1.1rem; font-weight:600; color:var(--text-primary);">Configura Serie di Dati</h3>
|
||||||
|
<button id="chart-series-modal-close" style="background:none; border:none; font-size:1.2rem; cursor:pointer; color:var(--text-muted); padding:4px;">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Series List -->
|
||||||
|
<div id="modal-series-list-section" style="overflow-y:auto; flex-grow:1;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:var(--space-md);">
|
||||||
|
<span style="font-size:0.85rem; color:var(--text-secondary);">Serie di dati attive sul grafico:</span>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-add-chart-series">+ Aggiungi Serie</button>
|
||||||
|
</div>
|
||||||
|
<div id="modal-series-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<!-- populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Series Editor Form (hidden by default) -->
|
||||||
|
<form id="modal-series-form" style="display:none; flex-direction:column; gap:var(--space-md); overflow-y:auto; flex-grow:1;">
|
||||||
|
<input type="hidden" id="form-series-id">
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: 2fr 1fr; gap:var(--space-md);">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Nome Serie (legenda)</label>
|
||||||
|
<input type="text" id="form-series-name" class="form-input" placeholder="Es. Ticket urgenti" required style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Colore Grafico</label>
|
||||||
|
<input type="color" id="form-series-color" class="form-input" style="width:100%; height:36px; padding: 2px; cursor: pointer; border-radius: var(--radius-sm); border: 1px solid var(--border-light);" value="#4f46e5">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:var(--space-md);">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Stato (selezione multipla)</label>
|
||||||
|
<div class="multiselect-list" id="form-select-statuses"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Tipo (selezione multipla)</label>
|
||||||
|
<div class="multiselect-list" id="form-select-types"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:var(--space-md);">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Coda (selezione multipla)</label>
|
||||||
|
<div class="multiselect-list" id="form-select-queues"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Proprietario (selezione multipla)</label>
|
||||||
|
<div class="multiselect-list" id="form-select-owners"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Responsabile (selezione multipla)</label>
|
||||||
|
<div class="multiselect-list" id="form-select-responsibles"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-sm); margin-top:4px; background:var(--bg-offset); padding: var(--space-sm); border-radius: var(--radius-md); border: 1px dashed var(--border-light);">
|
||||||
|
<input type="checkbox" id="form-series-bypass-state" style="width:18px; height:18px; cursor:pointer; margin:0;">
|
||||||
|
<label for="form-series-bypass-state" style="font-weight:500; font-size:0.85rem; cursor:pointer; user-select:none; margin:0; display:flex; flex-direction:column;">
|
||||||
|
<span>Ignora lo stato dei ticket</span>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-tertiary); font-weight:normal;">Se attivo, mostra tutti i ticket creati nel periodo indipendentemente dal loro stato attuale.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:var(--space-sm); border-top:1px solid var(--border-subtle); padding-top:var(--space-md); margin-top:var(--space-xs); flex-shrink:0;">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="btn-series-form-cancel">Annulla</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Salva Serie</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Draw custom Chart.js lines
|
||||||
|
this.updateChartData();
|
||||||
|
|
||||||
|
// Bind controls listeners
|
||||||
|
const periodSelect = document.getElementById('chart-period-preset');
|
||||||
|
const customDatesDiv = document.getElementById('chart-custom-dates');
|
||||||
|
const dateFromInput = document.getElementById('chart-date-from');
|
||||||
|
const dateToInput = document.getElementById('chart-date-to');
|
||||||
|
|
||||||
|
periodSelect.addEventListener('change', () => {
|
||||||
|
if (periodSelect.value === 'custom') {
|
||||||
|
customDatesDiv.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
customDatesDiv.style.display = 'none';
|
||||||
|
this.updateChartData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dateFromInput.addEventListener('change', () => {
|
||||||
|
if (dateFromInput.value && dateToInput.value) {
|
||||||
|
this.updateChartData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dateToInput.addEventListener('change', () => {
|
||||||
|
if (dateFromInput.value && dateToInput.value) {
|
||||||
|
this.updateChartData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind Modal listeners
|
||||||
|
const modal = document.getElementById('chart-series-modal');
|
||||||
|
document.getElementById('configure-series-btn').addEventListener('click', () => {
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
this.loadModalSeriesList();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('export-series-excel-btn').addEventListener('click', () => {
|
||||||
|
const periodSelect = document.getElementById('chart-period-preset');
|
||||||
|
const dateFromInput = document.getElementById('chart-date-from');
|
||||||
|
const dateToInput = document.getElementById('chart-date-to');
|
||||||
|
const { start_date, end_date } = getDatesForPeriod(periodSelect.value, dateFromInput.value, dateToInput.value);
|
||||||
|
|
||||||
|
const visibleSeriesIds = [];
|
||||||
|
if (this.chartInstance && this.activeLines) {
|
||||||
|
this.activeLines.forEach((line) => {
|
||||||
|
const datasetIndex = this.chartInstance.data.datasets.findIndex(ds => ds.label === line.name);
|
||||||
|
if (datasetIndex !== -1 && this.chartInstance.isDatasetVisible(datasetIndex)) {
|
||||||
|
visibleSeriesIds.push(line.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleSeriesIds.length === 0) {
|
||||||
|
Toast.error('Nessuna serie visibile da esportare!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idsParam = visibleSeriesIds.join(',');
|
||||||
|
window.open(`/api/dashboard/export-excel?start_date=${start_date}&end_date=${end_date}&series_ids=${idsParam}`, '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('chart-series-modal-close').addEventListener('click', () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
this.closeSeriesForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
this.closeSeriesForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-add-chart-series').addEventListener('click', () => {
|
||||||
|
this.openSeriesForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-series-form-cancel').addEventListener('click', () => {
|
||||||
|
this.closeSeriesForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modal-series-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const seriesId = document.getElementById('form-series-id').value;
|
||||||
|
const name = document.getElementById('form-series-name').value;
|
||||||
|
const color = document.getElementById('form-series-color').value;
|
||||||
|
const statuses = this.getSelectedIds('form-select-statuses');
|
||||||
|
const types = this.getSelectedIds('form-select-types');
|
||||||
|
const queues = this.getSelectedIds('form-select-queues');
|
||||||
|
const owners = this.getSelectedIds('form-select-owners');
|
||||||
|
const responsibles = this.getSelectedIds('form-select-responsibles');
|
||||||
|
const bypass_state_filter = document.getElementById('form-series-bypass-state').checked ? 1 : 0;
|
||||||
|
|
||||||
|
// Preserve current visibility if editing
|
||||||
|
let is_visible = 1;
|
||||||
|
if (seriesId) {
|
||||||
|
const existing = this.activeLines.find(s => s.id === parseInt(seriesId, 10));
|
||||||
|
if (existing) is_visible = existing.is_visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { name, statuses, types, queues, owners, responsibles, color, is_visible, bypass_state_filter };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (seriesId) {
|
||||||
|
await App.api(`/api/dashboard/chart-lines/${seriesId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
Toast.success('Serie aggiornata!');
|
||||||
|
} else {
|
||||||
|
await App.api('/api/dashboard/chart-lines', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
Toast.success('Serie creata!');
|
||||||
|
}
|
||||||
|
this.closeSeriesForm();
|
||||||
|
this.loadModalSeriesList();
|
||||||
|
this.updateChartData();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante il salvataggio: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Animate bars after render
|
// Animate bars after render
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
document.querySelectorAll('.dist-bar-fill').forEach(bar => {
|
document.querySelectorAll('.dist-bar-fill').forEach(bar => {
|
||||||
@@ -184,4 +503,290 @@ const DashboardView = {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateChartData() {
|
||||||
|
const periodPreset = document.getElementById('chart-period-preset').value;
|
||||||
|
const customFrom = document.getElementById('chart-date-from').value;
|
||||||
|
const customTo = document.getElementById('chart-date-to').value;
|
||||||
|
|
||||||
|
// Save selections
|
||||||
|
localStorage.setItem('otrs_chart_period', periodPreset);
|
||||||
|
if (customFrom) localStorage.setItem('otrs_chart_date_from', customFrom);
|
||||||
|
if (customTo) localStorage.setItem('otrs_chart_date_to', customTo);
|
||||||
|
|
||||||
|
const { start_date, end_date } = getDatesForPeriod(periodPreset, customFrom, customTo);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await App.api(`/api/dashboard/chart-data?start_date=${start_date}&end_date=${end_date}`);
|
||||||
|
this.activeLines = data.lines;
|
||||||
|
this.drawChart(data.labels, data.lines);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading chart data:", err);
|
||||||
|
const wrapper = document.querySelector('.chart-canvas-wrapper');
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text-secondary); gap:8px;">
|
||||||
|
<span>⚠️ Errore durante il caricamento dei dati del grafico</span>
|
||||||
|
<small style="color:var(--text-muted);">${App.escapeHtml(err.message)}</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
drawChart(labels, lines) {
|
||||||
|
const canvas = document.getElementById('dashboard-ticket-chart');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
if (this.chartInstance) {
|
||||||
|
this.chartInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDarkMode = document.body.className.includes('theme-') && !document.body.className.includes('theme-light');
|
||||||
|
const textColor = isDarkMode ? '#a6adc8' : '#4b5563';
|
||||||
|
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)';
|
||||||
|
|
||||||
|
const datasets = lines.map((line) => {
|
||||||
|
const color = line.color || '#4f46e5';
|
||||||
|
return {
|
||||||
|
label: line.name,
|
||||||
|
data: line.data,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color + '15',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
pointBackgroundColor: color,
|
||||||
|
pointBorderColor: '#fff',
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointRadius: 4,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.chartInstance = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: datasets
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: textColor,
|
||||||
|
font: {
|
||||||
|
family: 'Inter',
|
||||||
|
size: 12,
|
||||||
|
weight: '500'
|
||||||
|
},
|
||||||
|
padding: 15,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
backgroundColor: isDarkMode ? '#1e1e2e' : '#fff',
|
||||||
|
titleColor: isDarkMode ? '#cdd6f4' : '#0f172a',
|
||||||
|
bodyColor: isDarkMode ? '#a6adc8' : '#475569',
|
||||||
|
borderColor: isDarkMode ? '#313244' : '#e2e8f0',
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 10,
|
||||||
|
titleFont: { family: 'Inter', weight: '600' },
|
||||||
|
bodyFont: { family: 'Inter' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: gridColor,
|
||||||
|
drawBorder: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: textColor,
|
||||||
|
font: { family: 'Inter', size: 10 },
|
||||||
|
maxRotation: 45,
|
||||||
|
minRotation: 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
color: gridColor,
|
||||||
|
drawBorder: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: textColor,
|
||||||
|
font: { family: 'Inter', size: 10 },
|
||||||
|
stepSize: 1,
|
||||||
|
precision: 0,
|
||||||
|
},
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadModalSeriesList() {
|
||||||
|
const listContainer = document.getElementById('modal-series-list');
|
||||||
|
if (!listContainer) return;
|
||||||
|
|
||||||
|
listContainer.innerHTML = '<div style="text-align:center; padding:10px;"><div class="spinner" style="width:20px;height:20px;"></div></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines = await App.api('/api/dashboard/chart-lines');
|
||||||
|
this.activeLines = lines;
|
||||||
|
|
||||||
|
listContainer.innerHTML = lines.map(line => `
|
||||||
|
<div class="line-config-row series-config-row" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||||
|
<span class="series-color-dot" style="width:14px; height:14px; border-radius:50%; background-color:${line.color || '#4f46e5'}; border:1px solid var(--border-light); display:inline-block;"></span>
|
||||||
|
<span class="line-name" style="${!line.is_visible ? 'text-decoration:line-through; color:var(--text-muted);' : ''}">${App.escapeHtml(line.name)} ${line.is_default ? '<small style="color:var(--text-muted);font-weight:normal;">(Predefinita)</small>' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="line-actions" style="display:flex; align-items:center; gap:6px;">
|
||||||
|
<button class="btn btn-ghost btn-sm btn-toggle-visibility" data-id="${line.id}" style="padding: 2px 6px; font-size:0.75rem;" title="${line.is_visible ? 'Nascondi' : 'Mostra'}">
|
||||||
|
${line.is_visible ? '👁️ Nascondi' : '❌ Mostra'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-edit-series" data-id="${line.id}" style="padding: 2px 6px; font-size:0.75rem;">Modifica</button>
|
||||||
|
${!line.is_default ? `<button class="btn btn-danger btn-sm btn-delete-series" data-id="${line.id}" style="padding: 2px 6px; font-size:0.75rem; background-color: var(--error); color: white; border: none;">Elimina</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Bind actions
|
||||||
|
listContainer.querySelectorAll('.btn-edit-series').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const lineId = parseInt(btn.dataset.id);
|
||||||
|
const line = this.activeLines.find(l => l.id === lineId);
|
||||||
|
if (line) this.openSeriesForm(line);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
listContainer.querySelectorAll('.btn-toggle-visibility').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const lineId = parseInt(btn.dataset.id);
|
||||||
|
const series = this.activeLines.find(l => l.id === lineId);
|
||||||
|
if (series) {
|
||||||
|
const nextVisibility = series.is_visible ? 0 : 1;
|
||||||
|
try {
|
||||||
|
await App.api(`/api/dashboard/chart-lines/${lineId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: series.name,
|
||||||
|
statuses: series.statuses,
|
||||||
|
types: series.types,
|
||||||
|
queues: series.queues,
|
||||||
|
owners: series.owners,
|
||||||
|
responsibles: series.responsibles,
|
||||||
|
color: series.color,
|
||||||
|
is_visible: nextVisibility
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Toast.success(nextVisibility ? 'Serie attivata!' : 'Serie disattivata!');
|
||||||
|
this.loadModalSeriesList();
|
||||||
|
this.updateChartData();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante l\'aggiornamento dello stato: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
listContainer.querySelectorAll('.btn-delete-series').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const lineId = parseInt(btn.dataset.id);
|
||||||
|
const confirmed = await App.confirm('Elimina serie', 'Sei sicuro di voler eliminare questa serie dal grafico?');
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await App.api(`/api/dashboard/chart-lines/${lineId}`, { method: 'DELETE' });
|
||||||
|
Toast.success('Serie eliminata con successo!');
|
||||||
|
this.loadModalSeriesList();
|
||||||
|
this.updateChartData();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante l\'eliminazione: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
listContainer.innerHTML = `<div style="color:var(--error); font-size:0.85rem; padding:10px;">Errore: ${App.escapeHtml(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openSeriesForm(line = null) {
|
||||||
|
const listSection = document.getElementById('modal-series-list-section');
|
||||||
|
const formSection = document.getElementById('modal-series-form');
|
||||||
|
|
||||||
|
listSection.style.display = 'none';
|
||||||
|
formSection.style.display = 'flex';
|
||||||
|
|
||||||
|
const idInput = document.getElementById('form-series-id');
|
||||||
|
const nameInput = document.getElementById('form-series-name');
|
||||||
|
const colorInput = document.getElementById('form-series-color');
|
||||||
|
|
||||||
|
const statuses = line ? line.statuses : [];
|
||||||
|
const types = line ? line.types : [];
|
||||||
|
const queues = line ? line.queues : [];
|
||||||
|
const owners = line ? line.owners : [];
|
||||||
|
const responsibles = line ? line.responsibles : [];
|
||||||
|
|
||||||
|
idInput.value = line ? line.id : '';
|
||||||
|
nameInput.value = line ? line.name : '';
|
||||||
|
colorInput.value = line ? line.color : '#4f46e5';
|
||||||
|
document.getElementById('form-series-bypass-state').checked = line ? !!line.bypass_state_filter : false;
|
||||||
|
|
||||||
|
this.renderMultiselect('form-select-statuses', App.lookups.states || [], statuses);
|
||||||
|
this.renderMultiselect('form-select-types', App.lookups.types || [], types);
|
||||||
|
this.renderMultiselect('form-select-queues', App.lookups.queues || [], queues);
|
||||||
|
this.renderMultiselect('form-select-owners', App.lookups.users || [], owners, 'user');
|
||||||
|
this.renderMultiselect('form-select-responsibles', App.lookups.users || [], responsibles, 'user');
|
||||||
|
},
|
||||||
|
|
||||||
|
closeSeriesForm() {
|
||||||
|
const listSection = document.getElementById('modal-series-list-section');
|
||||||
|
const formSection = document.getElementById('modal-series-form');
|
||||||
|
if (listSection && formSection) {
|
||||||
|
listSection.style.display = 'block';
|
||||||
|
formSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderMultiselect(containerId, items, selectedIds, type = 'normal') {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const sorted = [...items].sort((a, b) => {
|
||||||
|
const nameA = type === 'user' ? `${a.last_name || ''} ${a.first_name || ''}` : (a.name || '');
|
||||||
|
const nameB = type === 'user' ? `${b.last_name || ''} ${b.first_name || ''}` : (b.name || '');
|
||||||
|
return nameA.localeCompare(nameB, 'it', { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = sorted.map(item => {
|
||||||
|
const id = item.id;
|
||||||
|
const isSelected = selectedIds.includes(id) || selectedIds.includes(String(id)) || selectedIds.includes(parseInt(id));
|
||||||
|
let label = item.name;
|
||||||
|
if (type === 'user') {
|
||||||
|
label = `${item.first_name || ''} ${item.last_name || ''} (${item.login || ''})`;
|
||||||
|
}
|
||||||
|
return `<div class="multiselect-item ${isSelected ? 'selected' : ''}" data-id="${id}">${App.escapeHtml(label)}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Bind item click
|
||||||
|
container.querySelectorAll('.multiselect-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
el.classList.toggle('selected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedIds(containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return [];
|
||||||
|
return Array.from(container.querySelectorAll('.multiselect-item.selected')).map(el => {
|
||||||
|
const id = el.dataset.id;
|
||||||
|
return isNaN(parseInt(id)) ? id : parseInt(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -433,10 +433,8 @@ const EmailCompose = (() => {
|
|||||||
} else {
|
} else {
|
||||||
const tn = options.ticketTn || '';
|
const tn = options.ticketTn || '';
|
||||||
const title = options.ticketTitle || '';
|
const title = options.ticketTitle || '';
|
||||||
subjectEl.value = tn ? `Re: [Ticket#${tn}] ${title}` : title;
|
subjectEl.value = tn ? `[Ticket#${tn}] Re: ${title}` : title;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature and groups select
|
|
||||||
const sigSelect = document.getElementById('ec-signature-select');
|
const sigSelect = document.getElementById('ec-signature-select');
|
||||||
const groupsSelect = document.getElementById('ec-groups-select');
|
const groupsSelect = document.getElementById('ec-groups-select');
|
||||||
const agentId = App.currentAgentId || 0;
|
const agentId = App.currentAgentId || 0;
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
// 1. Owner & Responsible pre-population with active agent
|
// Active agent pre-population for Owner & Responsible
|
||||||
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
||||||
if (activeAgent) {
|
if (activeAgent) {
|
||||||
@@ -344,41 +344,6 @@ const TicketCreateView = {
|
|||||||
responsibleIdInput.value = activeAgent.id;
|
responsibleIdInput.value = activeAgent.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Customer User pre-population with first match
|
|
||||||
try {
|
|
||||||
const companies = await App.api('/api/customer-companies/search?q=');
|
|
||||||
if (companies.length > 0 && customerIdInput && companySearchInput) {
|
|
||||||
const defaultCompany = companies[0];
|
|
||||||
customerIdInput.value = defaultCompany.customer_id;
|
|
||||||
companySearchInput.value = defaultCompany.customer_id;
|
|
||||||
|
|
||||||
// Search users for this company
|
|
||||||
const users = await App.api(`/api/customer-users/search?q=&customer_company_id=${encodeURIComponent(defaultCompany.customer_id)}`);
|
|
||||||
if (users.length > 0 && userSearchInput && customerUserIdInput) {
|
|
||||||
const defaultUser = users[0];
|
|
||||||
userSearchInput.value = `${defaultUser.first_name} ${defaultUser.last_name}`;
|
|
||||||
customerUserIdInput.value = defaultUser.login;
|
|
||||||
} else {
|
|
||||||
// Fallback: use company name as customer user ID
|
|
||||||
userSearchInput.value = defaultCompany.name;
|
|
||||||
customerUserIdInput.value = defaultCompany.customer_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error pre-populating defaults:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Queue pre-population
|
|
||||||
try {
|
|
||||||
const queues = await App.api('/api/queues/search?q=');
|
|
||||||
if (queues.length > 0 && queueSearchInput && queueIdInput) {
|
|
||||||
queueSearchInput.value = queues[0].name;
|
|
||||||
queueIdInput.value = queues[0].id;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error pre-populating queues:', err);
|
|
||||||
}
|
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,13 +153,15 @@ const TicketDetailView = {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="ticket-header">
|
<div class="ticket-header">
|
||||||
<div class="ticket-header-info">
|
<div class="ticket-header-info">
|
||||||
<div class="ticket-number">#${ticket.tn}</div>
|
<div class="ticket-number" style="display:inline-flex; align-items:center; gap:6px;">
|
||||||
<h2 class="ticket-detail-title">
|
<span class="copy-ticket-btn" data-tn="${ticket.tn}" style="cursor: pointer; font-size: 0.85rem;" title="Copia numero ticket">📋</span>
|
||||||
${data.otrsWebUrl ? `
|
#${ticket.tn}
|
||||||
<a href="${data.otrsWebUrl}" target="_blank" style="color:inherit; text-decoration:none; border-bottom:1px dashed transparent; transition:border-bottom 0.1s ease;" onmouseover="this.style.borderBottom='1px dashed var(--text-primary)'" onmouseout="this.style.borderBottom='transparent'" title="Apri in OTRS">
|
-
|
||||||
|
</div>
|
||||||
|
<h2 class="ticket-detail-title" style="display:inline-flex; align-items:center; gap:6px; flex-wrap:wrap;">
|
||||||
|
<span class="copy-ticket-btn" data-tn="${App.escapeHtml(ticket.title || '')}" style="cursor: pointer; font-size: 0.85rem;" title="Copia titolo ticket">📋</span>
|
||||||
${App.escapeHtml(ticket.title || '(senza titolo)')}
|
${App.escapeHtml(ticket.title || '(senza titolo)')}
|
||||||
</a>
|
<button class="open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.openTab(${ticket.id}, '${ticket.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>
|
||||||
` : App.escapeHtml(ticket.title || '(senza titolo)')}
|
|
||||||
</h2>
|
</h2>
|
||||||
<div class="ticket-meta-badges">
|
<div class="ticket-meta-badges">
|
||||||
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
|
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
|
||||||
@@ -261,6 +263,7 @@ const TicketDetailView = {
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" id="btn-note-add-attachments" style="height:32px; padding: 4px 10px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px;">📎 Allega file</button>
|
<button type="button" class="btn btn-ghost btn-sm" id="btn-note-add-attachments" style="height:32px; padding: 4px 10px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px;">📎 Allega file</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:var(--space-md); align-items:center;">
|
<div style="display:flex; gap:var(--space-md); align-items:center;">
|
||||||
|
<input type="datetime-local" class="note-subject-input" id="note-create-time" title="Data/ora nota (opzionale per retrodatazione)" style="width:185px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.82rem;" />
|
||||||
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
|
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
|
||||||
<button class="btn btn-ghost btn-sm" id="btn-open-email-compose" style="height:32px; display:flex; align-items:center; gap:var(--space-xs); border-color:var(--accent-secondary); color:var(--accent-secondary);"
|
<button class="btn btn-ghost btn-sm" id="btn-open-email-compose" style="height:32px; display:flex; align-items:center; gap:var(--space-xs); border-color:var(--accent-secondary); color:var(--accent-secondary);"
|
||||||
data-ticket-id="${ticket.id}" data-ticket-tn="${ticket.tn}" data-ticket-title="${App.escapeHtml(ticket.title)}" data-customer-email="${App.escapeHtml(ticket.customer_email || '')}">
|
data-ticket-id="${ticket.id}" data-ticket-tn="${ticket.tn}" data-ticket-title="${App.escapeHtml(ticket.title)}" data-customer-email="${App.escapeHtml(ticket.customer_email || '')}">
|
||||||
@@ -521,7 +524,7 @@ const TicketDetailView = {
|
|||||||
modules: {
|
modules: {
|
||||||
toolbar: [
|
toolbar: [
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
['link', 'image'],
|
['link', 'image'],
|
||||||
['clean']
|
['clean']
|
||||||
],
|
],
|
||||||
@@ -529,7 +532,7 @@ const TicketDetailView = {
|
|||||||
bindings: {
|
bindings: {
|
||||||
tab: {
|
tab: {
|
||||||
key: 'Tab',
|
key: 'Tab',
|
||||||
handler: function() {
|
handler: function () {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,7 +568,7 @@ const TicketDetailView = {
|
|||||||
this.updateNoteAttachmentList();
|
this.updateNoteAttachmentList();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.bindEvents(ticket, articles, container, groupsData);
|
this.bindEvents(ticket, articles, container, groupsData, attachments);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
@@ -579,7 +582,7 @@ const TicketDetailView = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
bindEvents(ticket, articles, container, groupsData) {
|
bindEvents(ticket, articles, container, groupsData, attachments) {
|
||||||
// Quick-edit change detection
|
// Quick-edit change detection
|
||||||
const fields = document.querySelectorAll('.quick-edit-select:not(#qe-queue-search), #qe-queue, #qe-customer-user-id, #qe-customer-id');
|
const fields = document.querySelectorAll('.quick-edit-select:not(#qe-queue-search), #qe-queue, #qe-customer-user-id, #qe-customer-id');
|
||||||
const saveBtn = document.getElementById('qe-save');
|
const saveBtn = document.getElementById('qe-save');
|
||||||
@@ -828,6 +831,7 @@ const TicketDetailView = {
|
|||||||
}
|
}
|
||||||
let subject = document.getElementById('note-subject').value.trim();
|
let subject = document.getElementById('note-subject').value.trim();
|
||||||
const time_unit = document.getElementById('note-time-units').value.trim();
|
const time_unit = document.getElementById('note-time-units').value.trim();
|
||||||
|
const note_create_time = document.getElementById('note-create-time')?.value;
|
||||||
|
|
||||||
// If body is empty but subject is provided, fill body with subject text
|
// If body is empty but subject is provided, fill body with subject text
|
||||||
if (!body && subject) {
|
if (!body && subject) {
|
||||||
@@ -867,7 +871,22 @@ const TicketDetailView = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.noteAttachments = []; // Clear attachments
|
this.noteAttachments = []; // Clear attachments
|
||||||
|
|
||||||
|
if (note_create_time && res.article_id) {
|
||||||
|
try {
|
||||||
|
const formattedDate = note_create_time.replace('T', ' ');
|
||||||
|
await App.api(`/api/tickets/articles/${res.article_id}/retrodata-article`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ create_time: formattedDate })
|
||||||
|
});
|
||||||
|
Toast.success('Nota aggiunta e retrodatata!');
|
||||||
|
} catch (retErr) {
|
||||||
|
Toast.warning('Nota aggiunta, ma errore durante la retrodatazione: ' + retErr.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Toast.success(res.message || 'Nota aggiunta!');
|
Toast.success(res.message || 'Nota aggiunta!');
|
||||||
|
}
|
||||||
|
|
||||||
App.clearDraft(this.ticketId, 'note');
|
App.clearDraft(this.ticketId, 'note');
|
||||||
App.updateDailyTimer();
|
App.updateDailyTimer();
|
||||||
this.render(this.ticketId);
|
this.render(this.ticketId);
|
||||||
@@ -1292,7 +1311,7 @@ const TicketDetailView = {
|
|||||||
if (doc && doc.readyState === 'complete') {
|
if (doc && doc.readyState === 'complete') {
|
||||||
attachImageClick();
|
attachImageClick();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) { }
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.attachment-badge[data-is-image="true"]').forEach(badge => {
|
document.querySelectorAll('.attachment-badge[data-is-image="true"]').forEach(badge => {
|
||||||
@@ -1302,5 +1321,19 @@ const TicketDetailView = {
|
|||||||
this.openImageLightbox(badge.href);
|
this.openImageLightbox(badge.href);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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('Copiato negli appunti!');
|
||||||
|
}).catch(err => {
|
||||||
|
Toast.error('Errore durante la copia: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+178
-15
@@ -13,7 +13,9 @@ const TicketListView = {
|
|||||||
|
|
||||||
async render() {
|
async render() {
|
||||||
const container = document.getElementById('view-container');
|
const container = document.getElementById('view-container');
|
||||||
|
if (!container.querySelector('.ticket-table-wrapper') && !container.querySelector('.ticket-table')) {
|
||||||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch lookups for filter dropdowns
|
// Fetch lookups for filter dropdowns
|
||||||
@@ -34,7 +36,7 @@ const TicketListView = {
|
|||||||
Filters.currentMode = isMyTickets ? 'my' : 'general';
|
Filters.currentMode = isMyTickets ? 'my' : 'general';
|
||||||
Filters.load(); // Load state for current mode
|
Filters.load(); // Load state for current mode
|
||||||
|
|
||||||
if (isMyTickets && !Filters.state.user_id) {
|
if (isMyTickets) {
|
||||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
Filters.state.user_id = activeAgentId;
|
Filters.state.user_id = activeAgentId;
|
||||||
Filters.save();
|
Filters.save();
|
||||||
@@ -86,12 +88,11 @@ const TicketListView = {
|
|||||||
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group" style="position:relative;">
|
||||||
<span class="filter-label">Coda</span>
|
<span class="filter-label">Coda</span>
|
||||||
<select class="filter-select" id="batch-queue">
|
<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;" />
|
||||||
<option value="">—</option>
|
<input type="hidden" id="batch-queue" />
|
||||||
${(App.lookups.queues || []).map(q => `<option value="${q.id}">${q.name}</option>`).join('')}
|
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; width: 280px; z-index: 1001;"></div>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Owner</span>
|
<span class="filter-label">Owner</span>
|
||||||
@@ -100,15 +101,25 @@ const TicketListView = {
|
|||||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group" style="position:relative;">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Cliente</span>
|
<span class="filter-label">Responsabile</span>
|
||||||
<input type="text" class="form-input filter-select" id="batch-customer-search" placeholder="Cerca cliente..." autocomplete="off" style="width:160px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
<select class="filter-select" id="batch-responsible">
|
||||||
<input type="hidden" id="batch-customer-user-id" />
|
<option value="">—</option>
|
||||||
<input type="hidden" id="batch-customer-id" />
|
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||||
<div id="batch-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; width: 280px; z-index: 1001;"></div>
|
</select>
|
||||||
</div>
|
</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-apply">Applica</button>
|
||||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: var(--accent-secondary); border-color: var(--accent-secondary); margin-left: 8px;">Unisci Selezionati</button>
|
<button class="btn btn-primary btn-sm" id="batch-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-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>
|
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +164,7 @@ const TicketListView = {
|
|||||||
</td>
|
</td>
|
||||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</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="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
||||||
<td class="ticket-title-cell"><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
|
<td class="ticket-title-cell"><span class="copy-ticket-btn" data-tn="${t.title}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia titolo ticket">📋</span><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
|
||||||
<td class="queue-cell"><span class="badge badge-queue">${App.escapeHtml(shortQueue)}</span><div class="queue-tooltip">${App.escapeHtml(t.queue_name)}</div></td>
|
<td class="queue-cell"><span class="badge badge-queue">${App.escapeHtml(shortQueue)}</span><div class="queue-tooltip">${App.escapeHtml(t.queue_name)}</div></td>
|
||||||
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
|
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
|
||||||
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
|
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
|
||||||
@@ -300,6 +311,12 @@ const TicketListView = {
|
|||||||
batchApply.addEventListener('click', () => this.applyBatch());
|
batchApply.addEventListener('click', () => this.applyBatch());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch add to group
|
||||||
|
const batchAddGroup = document.getElementById('batch-add-group');
|
||||||
|
if (batchAddGroup) {
|
||||||
|
batchAddGroup.addEventListener('click', () => this.addToGroup());
|
||||||
|
}
|
||||||
|
|
||||||
// Batch merge (Issue #7)
|
// Batch merge (Issue #7)
|
||||||
const batchMerge = document.getElementById('batch-merge');
|
const batchMerge = document.getElementById('batch-merge');
|
||||||
if (batchMerge) {
|
if (batchMerge) {
|
||||||
@@ -425,10 +442,16 @@ const TicketListView = {
|
|||||||
if (batchCustomerId) batchCustomerId.value = '';
|
if (batchCustomerId) batchCustomerId.value = '';
|
||||||
const batchState = document.getElementById('batch-state');
|
const batchState = document.getElementById('batch-state');
|
||||||
if (batchState) batchState.value = '';
|
if (batchState) batchState.value = '';
|
||||||
|
const batchQueueSearch = document.getElementById('batch-queue-search');
|
||||||
|
if (batchQueueSearch) batchQueueSearch.value = '';
|
||||||
const batchQueue = document.getElementById('batch-queue');
|
const batchQueue = document.getElementById('batch-queue');
|
||||||
if (batchQueue) batchQueue.value = '';
|
if (batchQueue) batchQueue.value = '';
|
||||||
const batchOwner = document.getElementById('batch-owner');
|
const batchOwner = document.getElementById('batch-owner');
|
||||||
if (batchOwner) batchOwner.value = '';
|
if (batchOwner) batchOwner.value = '';
|
||||||
|
const batchResponsible = document.getElementById('batch-responsible');
|
||||||
|
if (batchResponsible) batchResponsible.value = '';
|
||||||
|
const batchType = document.getElementById('batch-type');
|
||||||
|
if (batchType) batchType.value = '';
|
||||||
|
|
||||||
this.updateBatchBar();
|
this.updateBatchBar();
|
||||||
});
|
});
|
||||||
@@ -488,11 +511,61 @@ const TicketListView = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch Queue Autocomplete
|
||||||
|
const batchQueueSearchInput = document.getElementById('batch-queue-search');
|
||||||
|
const batchQueueSuggestionsDiv = document.getElementById('batch-queue-suggestions');
|
||||||
|
const batchQueueIdInput = document.getElementById('batch-queue');
|
||||||
|
|
||||||
|
let batchQueueDebounce;
|
||||||
|
if (batchQueueSearchInput) {
|
||||||
|
batchQueueSearchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(batchQueueDebounce);
|
||||||
|
const q = batchQueueSearchInput.value.trim();
|
||||||
|
|
||||||
|
batchQueueDebounce = setTimeout(async () => {
|
||||||
|
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>';
|
||||||
|
batchQueueSuggestionsDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Bind click
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
batchQueueSearchInput.addEventListener('focus', () => {
|
||||||
|
batchQueueSearchInput.value = '';
|
||||||
|
if (batchQueueIdInput) batchQueueIdInput.value = '';
|
||||||
|
batchQueueSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Close suggestions on click outside
|
// Close suggestions on click outside
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (batchCustomerSearchInput && e.target !== batchCustomerSearchInput && e.target !== batchCustomerSuggestionsDiv) {
|
if (batchCustomerSearchInput && e.target !== batchCustomerSearchInput && e.target !== batchCustomerSuggestionsDiv) {
|
||||||
batchCustomerSuggestionsDiv.style.display = 'none';
|
batchCustomerSuggestionsDiv.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
if (batchQueueSearchInput && e.target !== batchQueueSearchInput && e.target !== batchQueueSuggestionsDiv) {
|
||||||
|
batchQueueSuggestionsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
@@ -531,6 +604,12 @@ const TicketListView = {
|
|||||||
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable/disable add to group button
|
||||||
|
const addGroupBtn = document.getElementById('batch-add-group');
|
||||||
|
if (addGroupBtn) {
|
||||||
|
addGroupBtn.disabled = this.selectedIds.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Enable/disable merge button
|
// Enable/disable merge button
|
||||||
const mergeBtn = document.getElementById('batch-merge');
|
const mergeBtn = document.getElementById('batch-merge');
|
||||||
if (mergeBtn) {
|
if (mergeBtn) {
|
||||||
@@ -559,6 +638,8 @@ const TicketListView = {
|
|||||||
const batchState = document.getElementById('batch-state')?.value;
|
const batchState = document.getElementById('batch-state')?.value;
|
||||||
const batchQueue = document.getElementById('batch-queue')?.value;
|
const batchQueue = document.getElementById('batch-queue')?.value;
|
||||||
const batchOwner = document.getElementById('batch-owner')?.value;
|
const batchOwner = document.getElementById('batch-owner')?.value;
|
||||||
|
const batchResponsible = document.getElementById('batch-responsible')?.value;
|
||||||
|
const batchType = document.getElementById('batch-type')?.value;
|
||||||
const batchCustomerSearch = document.getElementById('batch-customer-search')?.value.trim();
|
const batchCustomerSearch = document.getElementById('batch-customer-search')?.value.trim();
|
||||||
let batchCustomerUserId = document.getElementById('batch-customer-user-id')?.value;
|
let batchCustomerUserId = document.getElementById('batch-customer-user-id')?.value;
|
||||||
let batchCustomerId = document.getElementById('batch-customer-id')?.value;
|
let batchCustomerId = document.getElementById('batch-customer-id')?.value;
|
||||||
@@ -572,7 +653,13 @@ const TicketListView = {
|
|||||||
|
|
||||||
if (batchState) updates.ticket_state_id = parseInt(batchState);
|
if (batchState) updates.ticket_state_id = parseInt(batchState);
|
||||||
if (batchQueue) updates.queue_id = parseInt(batchQueue);
|
if (batchQueue) updates.queue_id = parseInt(batchQueue);
|
||||||
|
if (batchType) updates.type_id = parseInt(batchType);
|
||||||
if (batchOwner) updates.user_id = parseInt(batchOwner);
|
if (batchOwner) updates.user_id = parseInt(batchOwner);
|
||||||
|
if (batchResponsible) {
|
||||||
|
updates.responsible_user_id = parseInt(batchResponsible);
|
||||||
|
} else if (batchOwner) {
|
||||||
|
updates.responsible_user_id = parseInt(batchOwner);
|
||||||
|
}
|
||||||
if (batchCustomerUserId) updates.customer_user_id = batchCustomerUserId;
|
if (batchCustomerUserId) updates.customer_user_id = batchCustomerUserId;
|
||||||
if (batchCustomerId) updates.customer_id = batchCustomerId;
|
if (batchCustomerId) updates.customer_id = batchCustomerId;
|
||||||
|
|
||||||
@@ -640,10 +727,86 @@ const TicketListView = {
|
|||||||
Toast.success(res.message || 'Ticket uniti con successo');
|
Toast.success(res.message || 'Ticket uniti con successo');
|
||||||
this.selectedIds.clear();
|
this.selectedIds.clear();
|
||||||
this.selectedOrder = [];
|
this.selectedOrder = [];
|
||||||
this.render();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Toast.error('Errore durante l\'unione: ' + err.message);
|
Toast.error('Errore durante l\'unione: ' + err.message);
|
||||||
this.updateBatchBar();
|
this.updateBatchBar();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async addToGroup() {
|
||||||
|
if (this.selectedIds.size === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groups = await App.api('/api/groups');
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
Toast.warning('non sono presenti gruppi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show group selection prompt/dialog
|
||||||
|
const groupOptions = groups.map(g => `<option value="${g.id}">${App.escapeHtml(g.nome)}</option>`).join('');
|
||||||
|
const dialogHtml = `
|
||||||
|
<div id="batch-group-modal" style="position: fixed; inset: 0; z-index: 8000; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center;">
|
||||||
|
<div style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 400px; max-width: 90vw; padding: var(--space-lg); box-shadow: var(--shadow-lg);">
|
||||||
|
<h4 style="margin: 0 0 var(--space-md) 0; font-size: 1rem; font-weight: 600; color: var(--text-primary);">Aggiungi a Gruppo</h4>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: var(--space-md);">Seleziona il gruppo a cui aggiungere i <strong>${this.selectedIds.size}</strong> ticket selezionati:</p>
|
||||||
|
<select id="batch-group-select" class="form-select" style="width: 100%; margin-bottom: var(--space-lg);">
|
||||||
|
${groupOptions}
|
||||||
|
</select>
|
||||||
|
<div style="display: flex; gap: var(--space-sm); justify-content: flex-end;">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="batch-group-cancel">Annulla</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-group-confirm">Aggiungi</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Append modal to body
|
||||||
|
const modalContainer = document.createElement('div');
|
||||||
|
modalContainer.innerHTML = dialogHtml;
|
||||||
|
document.body.appendChild(modalContainer);
|
||||||
|
|
||||||
|
const closeModal = () => modalContainer.remove();
|
||||||
|
|
||||||
|
document.getElementById('batch-group-cancel').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('batch-group-confirm').addEventListener('click', async () => {
|
||||||
|
const groupId = document.getElementById('batch-group-select').value;
|
||||||
|
if (!groupId) return;
|
||||||
|
|
||||||
|
const confirmBtn = document.getElementById('batch-group-confirm');
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
confirmBtn.textContent = 'Aggiunta...';
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
let errorsCount = 0;
|
||||||
|
|
||||||
|
for (const ticketId of this.selectedIds) {
|
||||||
|
try {
|
||||||
|
await App.api(`/api/groups/${groupId}/tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ticket_identifier: ticketId })
|
||||||
|
});
|
||||||
|
addedCount++;
|
||||||
|
} catch (err) {
|
||||||
|
// Conflict (already in group) or other errors
|
||||||
|
errorsCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
|
||||||
|
if (addedCount > 0) {
|
||||||
|
Toast.success(`${addedCount} ticket aggiunti al gruppo!`);
|
||||||
|
this.selectedIds.clear();
|
||||||
|
this.selectedOrder = [];
|
||||||
|
this.render();
|
||||||
|
} else if (errorsCount > 0) {
|
||||||
|
Toast.warning('I ticket selezionati appartengono già a questo gruppo.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante il recupero dei gruppi: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+340
-18
@@ -5,6 +5,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { db } = require('../activityDb');
|
const { db } = require('../activityDb');
|
||||||
|
const XLSX = require('xlsx');
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-cbc';
|
const ALGORITHM = 'aes-256-cbc';
|
||||||
const SECRET_KEY = crypto.createHash('sha256').update(process.env.CRYPTO_KEY || 'default_secret_key_12345').digest();
|
const SECRET_KEY = crypto.createHash('sha256').update(process.env.CRYPTO_KEY || 'default_secret_key_12345').digest();
|
||||||
@@ -235,29 +236,350 @@ router.get('/settings', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/dashboard/settings — Save settings for the agent
|
// GET /api/dashboard/chart-lines
|
||||||
router.post('/settings', (req, res) => {
|
router.get('/chart-lines', (req, res) => {
|
||||||
const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
try {
|
||||||
const { preview_limit, tickets_per_page } = req.body;
|
const lines = db.prepare("SELECT * FROM dashboard_chart_lines ORDER BY is_default DESC, id ASC").all();
|
||||||
|
const formatted = lines.map(line => ({
|
||||||
|
...line,
|
||||||
|
statuses: JSON.parse(line.statuses || '[]'),
|
||||||
|
types: JSON.parse(line.types || '[]'),
|
||||||
|
queues: JSON.parse(line.queues || '[]'),
|
||||||
|
owners: JSON.parse(line.owners || '[]'),
|
||||||
|
responsibles: JSON.parse(line.responsibles || '[]')
|
||||||
|
}));
|
||||||
|
res.json(formatted);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching chart lines:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/dashboard/chart-lines
|
||||||
|
router.post('/chart-lines', (req, res) => {
|
||||||
|
const { name, statuses, types, queues, owners, responsibles, color, is_visible, bypass_state_filter } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let currentSettings = { preview_limit: 10, tickets_per_page: 50 };
|
const info = db.prepare(`
|
||||||
const row = db.prepare("SELECT preview_limit, tickets_per_page FROM agent_settings WHERE agent_id = ?").get(activeAgentId);
|
INSERT INTO dashboard_chart_lines (name, statuses, types, queues, owners, responsibles, color, is_visible, is_default, bypass_state_filter)
|
||||||
if (row) {
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
|
||||||
currentSettings = row;
|
`).run(
|
||||||
|
name,
|
||||||
|
JSON.stringify(statuses || []),
|
||||||
|
JSON.stringify(types || []),
|
||||||
|
JSON.stringify(queues || []),
|
||||||
|
JSON.stringify(owners || []),
|
||||||
|
JSON.stringify(responsibles || []),
|
||||||
|
color || '#4f46e5',
|
||||||
|
is_visible !== undefined ? parseInt(is_visible, 10) : 1,
|
||||||
|
bypass_state_filter !== undefined ? parseInt(bypass_state_filter, 10) : 0
|
||||||
|
);
|
||||||
|
res.json({ success: true, id: info.lastInsertRowid });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving chart line:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/dashboard/chart-lines/:id
|
||||||
|
router.put('/chart-lines/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, statuses, types, queues, owners, responsibles, color, is_visible, bypass_state_filter } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = db.prepare(`
|
||||||
|
UPDATE dashboard_chart_lines
|
||||||
|
SET name = ?, statuses = ?, types = ?, queues = ?, owners = ?, responsibles = ?, color = ?, is_visible = ?, bypass_state_filter = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
name,
|
||||||
|
JSON.stringify(statuses || []),
|
||||||
|
JSON.stringify(types || []),
|
||||||
|
JSON.stringify(queues || []),
|
||||||
|
JSON.stringify(owners || []),
|
||||||
|
JSON.stringify(responsibles || []),
|
||||||
|
color || '#4f46e5',
|
||||||
|
is_visible !== undefined ? parseInt(is_visible, 10) : 1,
|
||||||
|
bypass_state_filter !== undefined ? parseInt(bypass_state_filter, 10) : 0,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (info.changes === 0) return res.status(404).json({ error: 'Line not found' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating chart line:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/dashboard/chart-lines/:id
|
||||||
|
router.delete('/chart-lines/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const line = db.prepare("SELECT is_default FROM dashboard_chart_lines WHERE id = ?").get(id);
|
||||||
|
if (!line) return res.status(404).json({ error: 'Line not found' });
|
||||||
|
if (line.is_default === 1) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete default line' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPreviewLimit = preview_limit !== undefined ? parseInt(preview_limit, 10) : currentSettings.preview_limit;
|
db.prepare("DELETE FROM dashboard_chart_lines WHERE id = ?").run(id);
|
||||||
const newTicketsPerPage = tickets_per_page !== undefined ? parseInt(tickets_per_page, 10) : currentSettings.tickets_per_page;
|
res.json({ success: true });
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO agent_settings (agent_id, preview_limit, tickets_per_page)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`).run(activeAgentId, newPreviewLimit, newTicketsPerPage);
|
|
||||||
|
|
||||||
res.json({ success: true, preview_limit: newPreviewLimit, tickets_per_page: newTicketsPerPage });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving agent settings:', err);
|
console.error('Error deleting chart line:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/chart-data
|
||||||
|
router.get('/chart-data', async (req, res) => {
|
||||||
|
const { start_date, end_date } = req.query;
|
||||||
|
if (!start_date || !end_date) {
|
||||||
|
return res.status(400).json({ error: 'start_date and end_date are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only query data series that are configured as visible
|
||||||
|
const lines = db.prepare("SELECT * FROM dashboard_chart_lines WHERE is_visible = 1 ORDER BY is_default DESC, id ASC").all();
|
||||||
|
|
||||||
|
const dates = [];
|
||||||
|
let curr = new Date(start_date);
|
||||||
|
const endLimit = new Date(end_date);
|
||||||
|
while (curr <= endLimit) {
|
||||||
|
const y = curr.getFullYear();
|
||||||
|
const m = String(curr.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(curr.getDate()).padStart(2, '0');
|
||||||
|
dates.push(`${y}-${m}-${d}`);
|
||||||
|
curr.setDate(curr.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linesData = await Promise.all(lines.map(async (line) => {
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
let paramIdx = 1;
|
||||||
|
|
||||||
|
conditions.push(`t.create_time >= $${paramIdx++}`);
|
||||||
|
params.push(start_date + ' 00:00:00');
|
||||||
|
conditions.push(`t.create_time <= $${paramIdx++}`);
|
||||||
|
params.push(end_date + ' 23:59:59');
|
||||||
|
|
||||||
|
const statuses = JSON.parse(line.statuses || '[]');
|
||||||
|
if (statuses.length > 0 && !line.bypass_state_filter) {
|
||||||
|
const placeholders = statuses.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.ticket_state_id IN (${placeholders})`);
|
||||||
|
params.push(...statuses.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = JSON.parse(line.types || '[]');
|
||||||
|
if (types.length > 0) {
|
||||||
|
const placeholders = types.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.type_id IN (${placeholders})`);
|
||||||
|
params.push(...types.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const queues = JSON.parse(line.queues || '[]');
|
||||||
|
if (queues.length > 0) {
|
||||||
|
const placeholders = queues.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.queue_id IN (${placeholders})`);
|
||||||
|
params.push(...queues.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const owners = JSON.parse(line.owners || '[]');
|
||||||
|
if (owners.length > 0) {
|
||||||
|
const placeholders = owners.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.user_id IN (${placeholders})`);
|
||||||
|
params.push(...owners.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsibles = JSON.parse(line.responsibles || '[]');
|
||||||
|
if (responsibles.length > 0) {
|
||||||
|
const placeholders = responsibles.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.responsible_user_id IN (${placeholders})`);
|
||||||
|
params.push(...responsibles.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT CAST(t.create_time AS DATE) AS date_val, COUNT(*) AS count
|
||||||
|
FROM ticket t
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
GROUP BY CAST(t.create_time AS DATE)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(sql, params);
|
||||||
|
|
||||||
|
const countsByDate = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
let dateStr = row.date_val;
|
||||||
|
if (dateStr instanceof Date) {
|
||||||
|
const y = dateStr.getFullYear();
|
||||||
|
const m = String(dateStr.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(dateStr.getDate()).padStart(2, '0');
|
||||||
|
dateStr = `${y}-${m}-${d}`;
|
||||||
|
} else if (typeof dateStr === 'string') {
|
||||||
|
dateStr = dateStr.split('T')[0];
|
||||||
|
}
|
||||||
|
countsByDate[dateStr] = parseInt(row.count) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = dates.map(d => countsByDate[d] || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: line.id,
|
||||||
|
name: line.name,
|
||||||
|
color: line.color,
|
||||||
|
is_default: line.is_default,
|
||||||
|
data
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
labels: dates,
|
||||||
|
lines: linesData
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error generating chart data:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/export-excel
|
||||||
|
router.get('/export-excel', async (req, res) => {
|
||||||
|
const { start_date, end_date, series_ids } = req.query;
|
||||||
|
if (!start_date || !end_date) {
|
||||||
|
return res.status(400).json({ error: 'start_date and end_date are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let lines = [];
|
||||||
|
if (series_ids) {
|
||||||
|
const ids = series_ids.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id));
|
||||||
|
if (ids.length > 0) {
|
||||||
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
|
lines = db.prepare(`SELECT * FROM dashboard_chart_lines WHERE is_visible = 1 AND id IN (${placeholders}) ORDER BY is_default DESC, id ASC`).all(...ids);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines = db.prepare("SELECT * FROM dashboard_chart_lines WHERE is_visible = 1 ORDER BY is_default DESC, id ASC").all();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
const allRows = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
let paramIdx = 1;
|
||||||
|
|
||||||
|
conditions.push(`t.create_time >= $${paramIdx++}`);
|
||||||
|
params.push(start_date + ' 00:00:00');
|
||||||
|
conditions.push(`t.create_time <= $${paramIdx++}`);
|
||||||
|
params.push(end_date + ' 23:59:59');
|
||||||
|
|
||||||
|
const statuses = JSON.parse(line.statuses || '[]');
|
||||||
|
if (statuses.length > 0 && !line.bypass_state_filter) {
|
||||||
|
const placeholders = statuses.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.ticket_state_id IN (${placeholders})`);
|
||||||
|
params.push(...statuses.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = JSON.parse(line.types || '[]');
|
||||||
|
if (types.length > 0) {
|
||||||
|
const placeholders = types.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.type_id IN (${placeholders})`);
|
||||||
|
params.push(...types.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const queues = JSON.parse(line.queues || '[]');
|
||||||
|
if (queues.length > 0) {
|
||||||
|
const placeholders = queues.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.queue_id IN (${placeholders})`);
|
||||||
|
params.push(...queues.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const owners = JSON.parse(line.owners || '[]');
|
||||||
|
if (owners.length > 0) {
|
||||||
|
const placeholders = owners.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.user_id IN (${placeholders})`);
|
||||||
|
params.push(...owners.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsibles = JSON.parse(line.responsibles || '[]');
|
||||||
|
if (responsibles.length > 0) {
|
||||||
|
const placeholders = responsibles.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.responsible_user_id IN (${placeholders})`);
|
||||||
|
params.push(...responsibles.map(id => parseInt(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
t.id AS ticket_id,
|
||||||
|
t.tn,
|
||||||
|
t.title,
|
||||||
|
tt.name AS type_name,
|
||||||
|
ts.name AS state_name,
|
||||||
|
tst.name AS state_type_name,
|
||||||
|
q.name AS queue_name,
|
||||||
|
COALESCE(u.first_name || ' ' || u.last_name, u.login) AS owner_name,
|
||||||
|
COALESCE(ru.first_name || ' ' || ru.last_name, ru.login) AS responsible_name,
|
||||||
|
t.create_time,
|
||||||
|
t.change_time
|
||||||
|
FROM ticket t
|
||||||
|
LEFT JOIN ticket_type tt ON t.type_id = tt.id
|
||||||
|
LEFT JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||||
|
LEFT JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
LEFT JOIN queue q ON t.queue_id = q.id
|
||||||
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
|
LEFT JOIN users ru ON t.responsible_user_id = ru.id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY t.create_time DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(sql, params);
|
||||||
|
|
||||||
|
const rows = result.rows.map(t => {
|
||||||
|
const isClosed = t.state_type_name && (
|
||||||
|
t.state_type_name.toLowerCase().includes('closed') ||
|
||||||
|
t.state_name.toLowerCase().includes('closed') ||
|
||||||
|
t.state_name.toLowerCase().includes('chiuso') ||
|
||||||
|
t.state_name.toLowerCase().includes('chiusa')
|
||||||
|
);
|
||||||
|
const closureDate = isClosed ? t.change_time : '';
|
||||||
|
return {
|
||||||
|
'Origine della serie': line.name,
|
||||||
|
'ID Ticket': t.ticket_id,
|
||||||
|
'Numero (TN)': t.tn,
|
||||||
|
'Oggetto': t.title || '',
|
||||||
|
'Tipo': t.type_name || '',
|
||||||
|
'Stato': t.state_name || '',
|
||||||
|
'Coda': t.queue_name || '',
|
||||||
|
'Owner': t.owner_name || '',
|
||||||
|
'Responsabile': t.responsible_name || '',
|
||||||
|
'Data di Creazione': t.create_time,
|
||||||
|
'Data di Chiusura': closureDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
allRows.push(...rows);
|
||||||
|
|
||||||
|
const ws = XLSX.utils.json_to_sheet(rows);
|
||||||
|
const cleanName = line.name.replace(/[\\\/\?\*\:\[\]]/g, '').slice(0, 30);
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, cleanName || `Serie ${line.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allRows.length > 0) {
|
||||||
|
// Sort combined rows by Data di Creazione descending
|
||||||
|
allRows.sort((a, b) => new Date(b['Data di Creazione']) - new Date(a['Data di Creazione']));
|
||||||
|
const wsAll = XLSX.utils.json_to_sheet(allRows);
|
||||||
|
wb.SheetNames.unshift('Tutte le serie');
|
||||||
|
wb.Sheets['Tutte le serie'] = wsAll;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="Andamento_Ticket_${start_date}_${end_date}.xlsx"`);
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.send(buf);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error exporting excel:', err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-3
@@ -226,7 +226,7 @@ router.post('/send', async (req, res) => {
|
|||||||
if (!ticketResult.rows.length) return res.status(404).json({ error: 'Ticket non trovato' });
|
if (!ticketResult.rows.length) return res.status(404).json({ error: 'Ticket non trovato' });
|
||||||
|
|
||||||
const { tn, title } = ticketResult.rows[0];
|
const { tn, title } = ticketResult.rows[0];
|
||||||
const subject = customSubject || `Re: [Ticket#${tn}] ${title}`;
|
const subject = customSubject || `[Ticket#${tn}] Re: ${title}`;
|
||||||
|
|
||||||
// 2. Build BCC list (include OTRS system mailbox if keepHelpdeskCopy is true)
|
// 2. Build BCC list (include OTRS system mailbox if keepHelpdeskCopy is true)
|
||||||
const bccList = [...bcc];
|
const bccList = [...bcc];
|
||||||
@@ -263,7 +263,8 @@ 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)
|
||||||
await sendMail({ to, cc, bcc: bccList, subject, bodyHtml: processedBodyHtml, attachments, inlineImages: finalInlineImages, inReplyTo, references, messageId });
|
const mailResult = await sendMail({ to, cc, bcc: bccList, subject, bodyHtml: processedBodyHtml, attachments, inlineImages: finalInlineImages, inReplyTo, references, 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 {
|
||||||
@@ -308,7 +309,7 @@ router.post('/send', async (req, res) => {
|
|||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO article_data_mime (article_id, a_from, a_to, a_cc, a_bcc, a_subject, a_body, a_content_type, a_message_id, incoming_time, create_time, create_by, change_time, change_by)
|
INSERT INTO article_data_mime (article_id, a_from, a_to, a_cc, a_bcc, a_subject, a_body, a_content_type, a_message_id, incoming_time, create_time, create_by, change_time, change_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'text/html; charset=utf-8', $8, $9, $11, $10, $11, $10)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'text/html; charset=utf-8', $8, $9, $11, $10, $11, $10)`,
|
||||||
[articleId, aFrom, toList, cc.join(', '), bccList.join(', '), subject, processedBodyHtml, messageId, now, agentId || 1, localNow]
|
[articleId, aFrom, toList, cc.join(', '), bccList.join(', '), subject, processedBodyHtml, finalMessageId, now, agentId || 1, localNow]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper to strip HTML tags
|
// Helper to strip HTML tags
|
||||||
|
|||||||
+89
-2
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
const { db } = require('../activityDb');
|
const { db, logAttivita } = require('../activityDb');
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -34,11 +34,20 @@ async function fetchTicketsDetails(ticketIds) {
|
|||||||
// GET /api/groups - List all groups with member counts
|
// GET /api/groups - List all groups with member counts
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Ensure default group exists
|
||||||
|
const defaultGroup = db.prepare('SELECT id FROM ticket_groups WHERE UPPER(nome) = ?').get('CHIUDI A FINE GIORNATA');
|
||||||
|
if (!defaultGroup) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO ticket_groups (nome, descrizione)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`).run('CHIUDI A FINE GIORNATA', 'I ticket in questo gruppo verranno chiusi automaticamente alla consuntivazione di fine giornata');
|
||||||
|
}
|
||||||
|
|
||||||
const groups = db.prepare(`
|
const groups = db.prepare(`
|
||||||
SELECT g.*,
|
SELECT g.*,
|
||||||
(SELECT COUNT(*) FROM ticket_group_members WHERE group_id = g.id) AS member_count
|
(SELECT COUNT(*) FROM ticket_group_members WHERE group_id = g.id) AS member_count
|
||||||
FROM ticket_groups g
|
FROM ticket_groups g
|
||||||
ORDER BY g.nome ASC
|
ORDER BY CASE WHEN UPPER(g.nome) = 'CHIUDI A FINE GIORNATA' THEN 1 ELSE 0 END ASC, g.nome ASC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
res.json(groups);
|
res.json(groups);
|
||||||
@@ -237,5 +246,83 @@ router.delete('/:id/tickets/:ticket_id', (req, res) => {
|
|||||||
res.status(500).json({ error: 'Errore nella rimozione del ticket', message: err.message });
|
res.status(500).json({ error: 'Errore nella rimozione del ticket', message: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// POST /api/groups/close-end-of-day - Close all tickets in 'CHIUDI A FINE GIORNATA' group
|
||||||
|
router.post('/close-end-of-day', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
|
||||||
|
// Find default group 'CHIUDI A FINE GIORNATA'
|
||||||
|
const closeGroup = db.prepare('SELECT id FROM ticket_groups WHERE UPPER(nome) = ?').get('CHIUDI A FINE GIORNATA');
|
||||||
|
if (!closeGroup) {
|
||||||
|
return res.status(404).json({ error: 'Gruppo "CHIUDI A FINE GIORNATA" non trovato' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMembers = db.prepare('SELECT ticket_id FROM ticket_group_members WHERE group_id = ?').all(closeGroup.id);
|
||||||
|
if (groupMembers.length === 0) {
|
||||||
|
return res.json({ success: true, count: 0, message: 'Nessun ticket presente nel gruppo "CHIUDI A FINE GIORNATA"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupTicketIds = groupMembers.map(m => m.ticket_id);
|
||||||
|
const placeholders = groupTicketIds.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
|
||||||
|
// Get open tickets in group (not already in a closed state)
|
||||||
|
const openTicketsQuery = `
|
||||||
|
SELECT t.id
|
||||||
|
FROM ticket t
|
||||||
|
JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE t.id IN (${placeholders})
|
||||||
|
AND LOWER(tst.name) NOT LIKE '%closed%'
|
||||||
|
`;
|
||||||
|
const openRes = await pool.query(openTicketsQuery, groupTicketIds);
|
||||||
|
const openTicketIds = openRes.rows.map(r => r.id);
|
||||||
|
|
||||||
|
if (openTicketIds.length === 0) {
|
||||||
|
return res.json({ success: true, count: 0, message: 'Tutti i ticket nel gruppo "CHIUDI A FINE GIORNATA" risultano già chiusi' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve closed state ID (closed successful / closed fallback)
|
||||||
|
const closedStateRes = await pool.query(
|
||||||
|
`SELECT id FROM ticket_state WHERE name = 'closed successful' OR name = 'chiuso con successo' LIMIT 1`
|
||||||
|
);
|
||||||
|
let stateId;
|
||||||
|
if (closedStateRes.rows.length > 0) {
|
||||||
|
stateId = closedStateRes.rows[0].id;
|
||||||
|
} else {
|
||||||
|
const fallbackRes = await pool.query(
|
||||||
|
`SELECT ts.id FROM ticket_state ts JOIN ticket_state_type tst ON ts.type_id = tst.id WHERE tst.name = 'closed' LIMIT 1`
|
||||||
|
);
|
||||||
|
stateId = fallbackRes.rows[0]?.id || 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePlaceholders = openTicketIds.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE ticket
|
||||||
|
SET ticket_state_id = $${openTicketIds.length + 1},
|
||||||
|
ticket_lock_id = 1,
|
||||||
|
change_time = NOW(),
|
||||||
|
change_by = $${openTicketIds.length + 2}
|
||||||
|
WHERE id IN (${updatePlaceholders})
|
||||||
|
`;
|
||||||
|
await pool.query(updateQuery, [...openTicketIds, stateId, operatorId]);
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
titolo_azione: 'Chiusura ticket gruppo CHIUDI A FINE GIORNATA',
|
||||||
|
azione: { closed_count: openTicketIds.length, ticket_ids: openTicketIds },
|
||||||
|
esito: 'successo',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
count: openTicketIds.length,
|
||||||
|
message: `${openTicketIds.length} ticket del gruppo "CHIUDI A FINE GIORNATA" chius${openTicketIds.length === 1 ? 'o' : 'i'} con successo!`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Errore nella chiusura ticket fine giornata:', err);
|
||||||
|
res.status(500).json({ error: 'Errore nella chiusura ticket', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+39
-12
@@ -530,6 +530,8 @@ router.post('/', async (req, res) => {
|
|||||||
// Operator user for create_by (X-Agent-ID header or default to 1)
|
// Operator user for create_by (X-Agent-ID header or default to 1)
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -550,14 +552,14 @@ router.post('/', async (req, res) => {
|
|||||||
0, 0,
|
0, 0,
|
||||||
0, 0,
|
0, 0,
|
||||||
0,
|
0,
|
||||||
NOW(), $12, NOW(), $12
|
$12, $13, $12, $13
|
||||||
) RETURNING id, tn`,
|
) RETURNING id, tn`,
|
||||||
[
|
[
|
||||||
tn, title, queue_id, lockId, type_id || null,
|
tn, title, queue_id, lockId, type_id || null,
|
||||||
user_id || 1, responsibleUserId,
|
user_id || 1, responsibleUserId,
|
||||||
priority_id, state_id,
|
priority_id, state_id,
|
||||||
customer_id || null, customer_user_id || null,
|
customer_id || null, customer_user_id || null,
|
||||||
operatorId
|
localNow, operatorId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -578,13 +580,13 @@ router.post('/', 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
|
||||||
)`,
|
)`,
|
||||||
[
|
[
|
||||||
`%%`,
|
`%%`,
|
||||||
historyTypeId, ticketId, type_id || 1, queue_id,
|
historyTypeId, ticketId, type_id || 1, queue_id,
|
||||||
user_id || 1, priority_id, state_id,
|
user_id || 1, priority_id, state_id,
|
||||||
operatorId
|
localNow, operatorId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -643,9 +645,9 @@ router.post('/', 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`,
|
||||||
[ticketId, senderTypeId, channelId, operatorId]
|
[ticketId, senderTypeId, channelId, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const articleId = articleResult.rows[0].id;
|
const articleId = articleResult.rows[0].id;
|
||||||
@@ -660,9 +662,9 @@ router.post('/', async (req, res) => {
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, '', $3, $4,
|
$1, $2, '', $3, $4,
|
||||||
$5, EXTRACT(EPOCH FROM NOW())::INTEGER,
|
$5, EXTRACT(EPOCH FROM NOW())::INTEGER,
|
||||||
NOW(), $6, NOW(), $6
|
$6, $7, $6, $7
|
||||||
)`,
|
)`,
|
||||||
[articleId, customerFrom, subject || title, finalBody, contentType, operatorId]
|
[articleId, customerFrom, subject || title, finalBody, contentType, localNow, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// If HTML content, create article_data_mime_attachment for OTRS CE HTML rendering (file-1)
|
// If HTML content, create article_data_mime_attachment for OTRS CE HTML rendering (file-1)
|
||||||
@@ -678,9 +680,9 @@ router.post('/', 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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,13 +694,14 @@ router.post('/', 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, $2, $3, $4, 'attachment', $5, NOW(), $6, NOW(), $6)`,
|
) VALUES ($1, $2, $3, $4, 'attachment', $5, $6, $7, $6, $7)`,
|
||||||
[
|
[
|
||||||
articleId,
|
articleId,
|
||||||
att.filename,
|
att.filename,
|
||||||
contentBuffer.length,
|
contentBuffer.length,
|
||||||
att.content_type || 'application/octet-stream',
|
att.content_type || 'application/octet-stream',
|
||||||
att.content, // OTRS CE expects base64 string directly
|
att.content, // OTRS CE expects base64 string directly
|
||||||
|
localNow,
|
||||||
operatorId
|
operatorId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -1417,7 +1420,9 @@ router.patch('/batch/update', async (req, res) => {
|
|||||||
if (updates.ticket_state_id !== undefined) ticketFields.StateID = updates.ticket_state_id;
|
if (updates.ticket_state_id !== undefined) ticketFields.StateID = updates.ticket_state_id;
|
||||||
if (updates.ticket_priority_id !== undefined) ticketFields.PriorityID = updates.ticket_priority_id;
|
if (updates.ticket_priority_id !== undefined) ticketFields.PriorityID = updates.ticket_priority_id;
|
||||||
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
||||||
|
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||||
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
||||||
|
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
||||||
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
|
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
|
||||||
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id;
|
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id;
|
||||||
|
|
||||||
@@ -1470,7 +1475,7 @@ router.patch('/batch/update', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id', 'customer_id', 'customer_user_id'];
|
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'type_id', 'user_id', 'responsible_user_id', 'customer_id', 'customer_user_id'];
|
||||||
const setClauses = [];
|
const setClauses = [];
|
||||||
const setParams = [];
|
const setParams = [];
|
||||||
let pIdx = 1;
|
let pIdx = 1;
|
||||||
@@ -2184,6 +2189,28 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
[ticketId, articleId, remaining, operatorId]
|
[ticketId, articleId, remaining, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 6. Close tickets in default group 'CHIUDI A FINE GIORNATA'
|
||||||
|
try {
|
||||||
|
const closeGroup = db.prepare('SELECT id FROM ticket_groups WHERE UPPER(nome) = ?').get('CHIUDI A FINE GIORNATA');
|
||||||
|
if (closeGroup) {
|
||||||
|
const groupMembers = db.prepare('SELECT ticket_id FROM ticket_group_members WHERE group_id = ?').all(closeGroup.id);
|
||||||
|
if (groupMembers.length > 0) {
|
||||||
|
const groupTicketIds = groupMembers.map(m => m.ticket_id);
|
||||||
|
const idPlaceholders = groupTicketIds.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
|
||||||
|
// Update ticket state to closed in OTRS DB
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET ticket_state_id = $${groupTicketIds.length + 1}, change_time = NOW(), change_by = $${groupTicketIds.length + 2} WHERE id IN (${idPlaceholders})`,
|
||||||
|
[...groupTicketIds, stateId, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Auto-Time] Closed ${groupTicketIds.length} tickets from group 'CHIUDI A FINE GIORNATA'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (grpCloseErr) {
|
||||||
|
console.error('[Auto-Time] Error closing tickets from CHIUDI A FINE GIORNATA group:', grpCloseErr);
|
||||||
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
require('dotenv').config();
|
const path = require('path');
|
||||||
|
const baseDir = process.pkg ? path.dirname(process.execPath) : __dirname;
|
||||||
|
require('dotenv').config({ path: path.resolve(baseDir, '.env') });
|
||||||
|
require('dotenv').config({ path: path.resolve(__dirname, '.env') });
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const ticketsRouter = require('./routes/tickets');
|
const ticketsRouter = require('./routes/tickets');
|
||||||
const lookupsRouter = require('./routes/lookups');
|
const lookupsRouter = require('./routes/lookups');
|
||||||
|
|||||||
+26
-3
@@ -60,7 +60,7 @@ async function getAccessToken() {
|
|||||||
* @param {Array} [options.inlineImages] - [{ cid, content (base64), contentType }]
|
* @param {Array} [options.inlineImages] - [{ cid, content (base64), contentType }]
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references }) {
|
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) {
|
||||||
const sender = process.env.AZURE_MAIL_SENDER;
|
const sender = process.env.AZURE_MAIL_SENDER;
|
||||||
if (!sender) throw new Error('AZURE_MAIL_SENDER non configurato nel .env');
|
if (!sender) throw new Error('AZURE_MAIL_SENDER non configurato nel .env');
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
|
|||||||
attachments: allAttachments,
|
attachments: allAttachments,
|
||||||
internetMessageHeaders: headers.length ? headers : undefined,
|
internetMessageHeaders: headers.length ? headers : undefined,
|
||||||
},
|
},
|
||||||
saveToSentItems: false,
|
saveToSentItems: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/sendMail`;
|
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/sendMail`;
|
||||||
@@ -130,7 +130,30 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 202) {
|
if (res.status === 202) {
|
||||||
return; // Successo (Graph API risponde con 202 No Content)
|
let actualMessageId = messageId;
|
||||||
|
try {
|
||||||
|
// Wait 1.5 seconds for Exchange to process and place it in Sent Items
|
||||||
|
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 searchRes = await fetch(searchUrl, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (searchRes.ok) {
|
||||||
|
const searchData = await searchRes.json();
|
||||||
|
if (searchData.value && searchData.value.length > 0) {
|
||||||
|
actualMessageId = searchData.value[0].internetMessageId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (searchErr) {
|
||||||
|
console.warn('[Graph Mailer] Failed to retrieve actual InternetMessageId from Sent Items:', searchErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { internetMessageId: actualMessageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,7 @@ 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 }) {
|
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) {
|
||||||
const transporter = getTransporter();
|
const transporter = getTransporter();
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
@@ -41,6 +41,7 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
|
|||||||
html: bodyHtml,
|
html: bodyHtml,
|
||||||
inReplyTo,
|
inReplyTo,
|
||||||
references,
|
references,
|
||||||
|
messageId,
|
||||||
attachments: [
|
attachments: [
|
||||||
...attachments.map(a => ({
|
...attachments.map(a => ({
|
||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
@@ -57,6 +58,7 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
|
|||||||
};
|
};
|
||||||
|
|
||||||
await transporter.sendMail(mailOptions);
|
await transporter.sendMail(mailOptions);
|
||||||
|
return { internetMessageId: messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { sendMail };
|
module.exports = { sendMail };
|
||||||
|
|||||||
Reference in New Issue
Block a user