Compare commits

...
9 Commits
Author SHA1 Message Date
VVER440 35888400ed fix: la pressione del pulsante di chiusura dei ticket nel gruppo 'chiudi a fine giornata' non provoca più l'attivazione dell'azione di consuntivazione a fine giornata 2026-08-11 08:25:06 +02:00
VVER440 922a23c062 feat: reso programma indipendente. fix: ora il filtro owner della pagina ticket a mio carico segue l'agente selezionato (l'asciamo stare che la funzione di selezione dell'agente non dovrebbe proprio esistere XD) 2026-08-11 07:57:58 +02:00
VVER440 57175922c2 fix: corretta ora di creazione dei ticket che non considerava il fusorario. 2026-07-29 22:59:27 +02:00
VVER440 e2482159bc feat: aggiunto gruppo predefinito per la chiusura dei ticket al momento di consuntivare la giornata. 2026-07-29 22:50:49 +02:00
VVER440 99c4f450f4 feat: aggiunta possibilità di inserire massivamente i ticket selezionati all'interno dei gruppi. 2026-07-29 22:37:09 +02:00
VVER440 0cbd3ccdfc fix: nella pagina di creazione di un nuovo ticket, coda e cliente ora non sono più precompilati dal primo campo della lista. 2026-07-29 22:29:39 +02:00
VVER440 5ef7e66be5 style: modificato colore pulsante per ticket selezionati. (insomma questo è un vero commit). 2026-07-29 22:20:59 +02:00
VVER440 38b2645f6f fix: resa più rapida la navigazione fra i ticket, torna alla lista non ricarica tutto. 2026-07-29 22:11:48 +02:00
VVER440 7243b3da49 fix: la ricerca fulltext parte solamente alla pressione del pulsante invio. feat: aggiunta possibilità di specificare una data per le nuove note. 2026-07-29 21:52:18 +02:00
15 changed files with 1624 additions and 99 deletions
+1
View File
@@ -3,3 +3,4 @@ node_modules/
internal.db
internal.db-shm
internal.db-wal
dist
+2 -1
View File
@@ -8,7 +8,8 @@ const Database = require('better-sqlite3');
const path = require('path');
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);
// Ensure WAL mode for better concurrent access
+34
View File
@@ -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
+1220 -10
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -3,12 +3,27 @@
"version": "1.0.0",
"description": "Modern fast interface for OTRS ticket management - direct database access",
"main": "server.js",
"bin": "server.js",
"pkg": {
"scripts": [
"routes/*.js",
"utils/*.js",
"*.js"
],
"assets": [
"public/**/*"
],
"targets": [
"node18-win-x64"
]
},
"scripts": {
"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": {
"better-sqlite3": "^12.11.1",
"better-sqlite3": "^11.3.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
@@ -22,5 +37,8 @@
"ticket",
"helpdesk"
],
"license": "AGPL-3.0"
"license": "AGPL-3.0",
"devDependencies": {
"pkg": "^5.8.1"
}
}
+19
View File
@@ -354,6 +354,25 @@ body {
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 {
list-style: none;
padding: var(--space-md);
+13 -3
View File
@@ -18,9 +18,19 @@
<body>
<!-- Sidebar Navigation -->
<nav class="sidebar" id="sidebar">
<div class="sidebar-brand" style="border-bottom:none; padding-bottom:4px;">
<div class="brand-icon"></div>
<span class="brand-text">OTRS Turbo</span>
<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>
<span class="brand-text">OTRS Turbo</span>
</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"
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;">
+42 -13
View File
@@ -156,20 +156,8 @@ const App = {
}
};
let timeout;
searchInput.addEventListener('input', () => {
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) => {
@@ -216,6 +204,34 @@ const App = {
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
window.addEventListener('resize', () => this.updateHeaderHeight());
setTimeout(() => this.updateHeaderHeight(), 100);
@@ -493,11 +509,21 @@ const App = {
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
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}`);
this.updateDailyTimer();
this.updateSidebarBadges();
this.route();
});
@@ -624,6 +650,9 @@ const App = {
}
const triggerAction = async (e) => {
if (e && e.target && e.target.closest('#btn-close-end-of-day')) {
return;
}
e.preventDefault();
e.stopPropagation();
const confirmed = await this.confirm(
+14 -5
View File
@@ -55,11 +55,10 @@ const Filters = {
const savedMy = localStorage.getItem('otrs_turbo_filters_my');
if (savedMy) {
Object.assign(this.allStates.my, JSON.parse(savedMy));
} else {
// Default to active agent if not saved yet
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.allStates.my.user_id = activeAgentId;
}
// Always sync mode 'my' user_id to activeAgentId
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.allStates.my.user_id = activeAgentId;
const savedCache = localStorage.getItem('otrs_turbo_customer_cache');
if (savedCache) {
@@ -325,7 +324,10 @@ const Filters = {
<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;" />
</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>
</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');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
+1 -36
View File
@@ -331,7 +331,7 @@ const TicketCreateView = {
}
} else {
setTimeout(async () => {
// 1. Owner & Responsible pre-population with active agent
// Active agent pre-population for Owner & Responsible
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
if (activeAgent) {
@@ -344,41 +344,6 @@ const TicketCreateView = {
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);
}
+18 -1
View File
@@ -263,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>
</div>
<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;" />
<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 || '')}">
@@ -830,6 +831,7 @@ const TicketDetailView = {
}
let subject = document.getElementById('note-subject').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 && subject) {
@@ -869,7 +871,22 @@ const TicketDetailView = {
});
this.noteAttachments = []; // Clear attachments
Toast.success(res.message || 'Nota aggiunta!');
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!');
}
App.clearDraft(this.ticketId, 'note');
App.updateDailyTimer();
this.render(this.ticketId);
+108 -11
View File
@@ -13,7 +13,9 @@ const TicketListView = {
async render() {
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
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>';
}
try {
// Fetch lookups for filter dropdowns
@@ -34,7 +36,7 @@ const TicketListView = {
Filters.currentMode = isMyTickets ? 'my' : 'general';
Filters.load(); // Load state for current mode
if (isMyTickets && !Filters.state.user_id) {
if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
Filters.state.user_id = activeAgentId;
Filters.save();
@@ -106,15 +108,18 @@ const TicketListView = {
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
</select>
</div>
<div class="filter-group" style="position:relative;">
<span class="filter-label">Cliente</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;" />
<input type="hidden" id="batch-customer-user-id" />
<input type="hidden" id="batch-customer-id" />
<div id="batch-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; width: 280px; z-index: 1001;"></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-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-ghost btn-xs" id="batch-cancel">Annulla</button>
</div>
@@ -306,6 +311,12 @@ const TicketListView = {
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)
const batchMerge = document.getElementById('batch-merge');
if (batchMerge) {
@@ -439,6 +450,8 @@ const TicketListView = {
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();
});
@@ -591,6 +604,12 @@ const TicketListView = {
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
const mergeBtn = document.getElementById('batch-merge');
if (mergeBtn) {
@@ -620,6 +639,7 @@ const TicketListView = {
const batchQueue = document.getElementById('batch-queue')?.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();
let batchCustomerUserId = document.getElementById('batch-customer-user-id')?.value;
let batchCustomerId = document.getElementById('batch-customer-id')?.value;
@@ -633,6 +653,7 @@ const TicketListView = {
if (batchState) updates.ticket_state_id = parseInt(batchState);
if (batchQueue) updates.queue_id = parseInt(batchQueue);
if (batchType) updates.type_id = parseInt(batchType);
if (batchOwner) updates.user_id = parseInt(batchOwner);
if (batchResponsible) {
updates.responsible_user_id = parseInt(batchResponsible);
@@ -706,10 +727,86 @@ const TicketListView = {
Toast.success(res.message || 'Ticket uniti con successo');
this.selectedIds.clear();
this.selectedOrder = [];
this.render();
} catch (err) {
Toast.error('Errore durante l\'unione: ' + err.message);
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);
}
},
};
+89 -2
View File
@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
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
async function fetchTicketsDetails(ticketIds) {
@@ -34,11 +34,20 @@ async function fetchTicketsDetails(ticketIds) {
// GET /api/groups - List all groups with member counts
router.get('/', (req, res) => {
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(`
SELECT g.*,
(SELECT COUNT(*) FROM ticket_group_members WHERE group_id = g.id) AS member_count
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();
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 });
}
});
// 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;
+38 -12
View File
@@ -530,6 +530,8 @@ router.post('/', async (req, res) => {
// Operator user for create_by (X-Agent-ID header or default to 1)
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const localNow = getLocalTimestamp();
const ticketResult = await client.query(
`INSERT INTO ticket (
tn, title, queue_id, ticket_lock_id, type_id,
@@ -550,14 +552,14 @@ router.post('/', async (req, res) => {
0, 0,
0, 0,
0,
NOW(), $12, NOW(), $12
$12, $13, $12, $13
) RETURNING id, tn`,
[
tn, title, queue_id, lockId, type_id || null,
user_id || 1, responsibleUserId,
priority_id, state_id,
customer_id || null, customer_user_id || null,
operatorId
localNow, operatorId
]
);
@@ -578,13 +580,13 @@ router.post('/', async (req, res) => {
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
NOW(), $9, NOW(), $9
$9, $10, $9, $10
)`,
[
`%%`,
historyTypeId, ticketId, type_id || 1, queue_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,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, 0, 1, NOW(), $4, NOW(), $4
$1, $2, $3, 0, 1, $4, $5, $4, $5
) RETURNING id`,
[ticketId, senderTypeId, channelId, operatorId]
[ticketId, senderTypeId, channelId, localNow, operatorId]
);
const articleId = articleResult.rows[0].id;
@@ -660,9 +662,9 @@ router.post('/', async (req, res) => {
) VALUES (
$1, $2, '', $3, $4,
$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)
@@ -678,9 +680,9 @@ router.post('/', async (req, res) => {
create_time, create_by, change_time, change_by
) VALUES (
$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 (
article_id, filename, content_size, content_type, disposition, content,
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,
att.filename,
contentBuffer.length,
att.content_type || 'application/octet-stream',
att.content, // OTRS CE expects base64 string directly
localNow,
operatorId
]
);
@@ -1417,6 +1420,7 @@ router.patch('/batch/update', async (req, res) => {
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.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.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
@@ -1471,7 +1475,7 @@ router.patch('/batch/update', async (req, res) => {
try {
await client.query('BEGIN');
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id', 'responsible_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 setParams = [];
let pIdx = 1;
@@ -2185,6 +2189,28 @@ router.post('/auto-time', async (req, res) => {
[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');
// Log activity
+4 -2
View File
@@ -1,7 +1,9 @@
require('dotenv').config({ path: require('path').resolve(__dirname, '.env') });
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 cors = require('cors');
const path = require('path');
const ticketsRouter = require('./routes/tickets');
const lookupsRouter = require('./routes/lookups');