Compare commits

...
2 Commits
12 changed files with 1439 additions and 26 deletions
+1
View File
@@ -3,3 +3,4 @@ node_modules/
internal.db internal.db
internal.db-shm internal.db-shm
internal.db-wal internal.db-wal
dist
+2 -1
View File
@@ -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
+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", "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",
@@ -22,5 +37,8 @@
"ticket", "ticket",
"helpdesk" "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; 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);
+11 -1
View File
@@ -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>
+42 -1
View File
@@ -204,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);
@@ -481,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();
}); });
@@ -612,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(
+2 -3
View File
@@ -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) {
+1 -1
View File
@@ -36,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();
+79 -1
View File
@@ -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) {
@@ -246,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;
+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 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');