Prima importazione
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# OTRS Turbo - Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# Database Dialect: 'postgres' (default) or 'mysql' (for MySQL/MariaDB)
|
||||||
|
DB_TYPE=postgres
|
||||||
|
|
||||||
|
# Database Connection Details
|
||||||
|
DB_HOST=your_db_ip_here
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=otrs
|
||||||
|
DB_USER=otrs
|
||||||
|
DB_PASSWORD=your_password_here
|
||||||
|
|
||||||
|
# Server Port
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
OTRS_API_USER=root@localhost
|
||||||
|
OTRS_API_PASSWORD=your_password_here
|
||||||
|
OTRS_API_URL=http://your_otrs_host_or_ip/otrs/nph-genericinterface.pl/Webservice/GenericTicketConnectorREST
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
Debugger:
|
||||||
|
DebugThreshold: debug
|
||||||
|
TestMode: '0'
|
||||||
|
UseRegistry: '1'
|
||||||
|
Description: Ticket Connector REST Sample
|
||||||
|
FrameworkVersion: 7.2.x git
|
||||||
|
Provider:
|
||||||
|
Operation:
|
||||||
|
SessionCreate:
|
||||||
|
Description: Create a Session
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: Session::SessionCreate
|
||||||
|
SessionGet:
|
||||||
|
Description: Retreive Session data
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: Session::SessionGet
|
||||||
|
TicketCreate:
|
||||||
|
Description: Create a Ticket
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: Ticket::TicketCreate
|
||||||
|
TicketGet:
|
||||||
|
Description: Retrieve Ticket data
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: Ticket::TicketGet
|
||||||
|
TicketGetList:
|
||||||
|
Description: Retrieve Ticket data for a List of Ticket IDs
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: Ticket::TicketGet
|
||||||
|
TicketHistoryGet:
|
||||||
|
Description: Retrieve Ticket history data
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: Ticket::TicketHistoryGet
|
||||||
|
TicketSearch:
|
||||||
|
Description: Search for Tickets
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: Ticket::TicketSearch
|
||||||
|
TicketUpdate:
|
||||||
|
Description: Update a Ticket
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: Ticket::TicketUpdate
|
||||||
|
CustomerUserSearch:
|
||||||
|
Description: Search Customer Users
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: CustomerUser::CustomerUserSearch
|
||||||
|
CustomerUserGet:
|
||||||
|
Description: Retrieve Customer User data
|
||||||
|
MappingInbound: {}
|
||||||
|
MappingOutbound: {}
|
||||||
|
Type: CustomerUser::CustomerUserGet
|
||||||
|
Transport:
|
||||||
|
Config:
|
||||||
|
KeepAlive: ''
|
||||||
|
MaxLength: '100000000'
|
||||||
|
RouteOperationMapping:
|
||||||
|
SessionCreate:
|
||||||
|
RequestMethod:
|
||||||
|
- POST
|
||||||
|
Route: /Session
|
||||||
|
SessionGet:
|
||||||
|
RequestMethod:
|
||||||
|
- GET
|
||||||
|
Route: /Session/:SessionID
|
||||||
|
TicketCreate:
|
||||||
|
RequestMethod:
|
||||||
|
- POST
|
||||||
|
Route: /Ticket
|
||||||
|
TicketGet:
|
||||||
|
RequestMethod:
|
||||||
|
- GET
|
||||||
|
Route: /Ticket/:TicketID
|
||||||
|
TicketGetList:
|
||||||
|
RequestMethod:
|
||||||
|
- GET
|
||||||
|
Route: /TicketList
|
||||||
|
TicketHistoryGet:
|
||||||
|
RequestMethod:
|
||||||
|
- GET
|
||||||
|
Route: /TicketHistory/:TicketID
|
||||||
|
TicketSearch:
|
||||||
|
RequestMethod:
|
||||||
|
- GET
|
||||||
|
Route: /Ticket
|
||||||
|
TicketUpdate:
|
||||||
|
RequestMethod:
|
||||||
|
- PATCH
|
||||||
|
Route: /Ticket/:TicketID
|
||||||
|
CustomerUserSearch:
|
||||||
|
RequestMethod:
|
||||||
|
- POST
|
||||||
|
Route: /CustomerUserSearch
|
||||||
|
CustomerUserGet:
|
||||||
|
RequestMethod:
|
||||||
|
- POST
|
||||||
|
Route: /CustomerUserGet
|
||||||
|
Type: HTTP::REST
|
||||||
|
RemoteSystem: ''
|
||||||
|
Requester:
|
||||||
|
Transport:
|
||||||
|
Type: ''
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# OTRS Turbo
|
||||||
|
|
||||||
|
REST API client and ticket dashboard for OTRS / Znuny.
|
||||||
|
|
||||||
|
## Setup Customer User API Integration (LDAP support)
|
||||||
|
|
||||||
|
To retrieve customer users from external backends like LDAP or Active Directory, you must install custom Generic Interface operations on the OTRS server.
|
||||||
|
|
||||||
|
### 1. Copy Perl Backend Modules
|
||||||
|
Copy the custom Perl modules from the `otrs-backend-modules` folder to your OTRS server:
|
||||||
|
|
||||||
|
- Copy `otrs-backend-modules/CustomerUserSearch.pm` to:
|
||||||
|
`/opt/otrs/Kernel/GenericInterface/Operation/CustomerUser/CustomerUserSearch.pm`
|
||||||
|
|
||||||
|
- Copy `otrs-backend-modules/CustomerUserGet.pm` to:
|
||||||
|
`/opt/otrs/Kernel/GenericInterface/Operation/CustomerUser/CustomerUserGet.pm`
|
||||||
|
|
||||||
|
*(Note: Create the directory `/opt/otrs/Kernel/GenericInterface/Operation/CustomerUser/` if it does not exist).*
|
||||||
|
|
||||||
|
### 2. Copy XML Configuration Registration
|
||||||
|
Copy the configuration registration XML file to enable the new operation endpoints in OTRS system configuration:
|
||||||
|
|
||||||
|
- Copy `otrs-backend-modules/CustomerUserGenericInterface.xml` to:
|
||||||
|
`/opt/otrs/Kernel/Config/Files/XML/CustomerUserGenericInterface.xml`
|
||||||
|
|
||||||
|
### 3. Rebuild OTRS Configuration
|
||||||
|
Run the following commands on the OTRS server as the `otrs` user to apply the changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild the system configuration database
|
||||||
|
/opt/otrs/bin/otrs.Console.pl Maint::Config::Rebuild
|
||||||
|
|
||||||
|
# Reset permissions (if needed)
|
||||||
|
/opt/otrs/bin/otrs.SetPermissions.pl
|
||||||
|
|
||||||
|
# Delete OTRS cache
|
||||||
|
/opt/otrs/bin/otrs.Console.pl Maint::Cache::Delete
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Enable Operations in your Web Service
|
||||||
|
1. Log in to the OTRS/Znuny Admin interface.
|
||||||
|
2. Go to **Web Service Management** and select your web service (e.g., `GenericTicketConnectorREST`).
|
||||||
|
3. Add the two operations:
|
||||||
|
- **`CustomerUserSearch`** (Controller: `CustomerUser::CustomerUserSearch`)
|
||||||
|
- **`CustomerUserGet`** (Controller: `CustomerUser::CustomerUserGet`)
|
||||||
|
4. Configure the route mappings for these operations:
|
||||||
|
- `CustomerUserSearch` -> RequestMethod: `POST`, Route: `/CustomerUserSearch`
|
||||||
|
- `CustomerUserGet` -> RequestMethod: `POST`, Route: `/CustomerUserGet`
|
||||||
|
5. Save the web service.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const dbType = (process.env.DB_TYPE || 'postgres').toLowerCase();
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
if (dbType === 'mysql' || dbType === 'mariadb') {
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
// Query translation helper for MySQL/MariaDB compatibility
|
||||||
|
function translateQuery(sql, params = []) {
|
||||||
|
let translatedSql = sql;
|
||||||
|
let translatedParams = [...params];
|
||||||
|
|
||||||
|
// 1. Replace Postgres placeholders ($1, $2, ...) with MySQL placeholders (?)
|
||||||
|
// Reorder and duplicate parameters to match the sequence of ? placeholders
|
||||||
|
const placeholders = [...translatedSql.matchAll(/\$([0-9]+)/g)];
|
||||||
|
if (placeholders.length > 0) {
|
||||||
|
const newParams = [];
|
||||||
|
for (const match of placeholders) {
|
||||||
|
const index = parseInt(match[1], 10) - 1;
|
||||||
|
newParams.push(params[index]);
|
||||||
|
}
|
||||||
|
translatedParams = newParams;
|
||||||
|
translatedSql = translatedSql.replace(/\$[0-9]+/g, '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ILIKE -> LIKE (MySQL LIKE is case-insensitive by default)
|
||||||
|
translatedSql = translatedSql.replace(/\bILIKE\b/gi, 'LIKE');
|
||||||
|
|
||||||
|
// 3. PostgreSQL string concatenation '||' -> CONCAT(...) in ticket number generator
|
||||||
|
translatedSql = translatedSql.replace(/md5\(random\(\)::text\s*\|\|\s*clock_timestamp\(\)::text\)/gi, 'MD5(CONCAT(RAND(), NOW()))');
|
||||||
|
|
||||||
|
// 4. EXTRACT(EPOCH FROM NOW()) -> UNIX_TIMESTAMP()
|
||||||
|
translatedSql = translatedSql.replace(/EXTRACT\(EPOCH\s+FROM\s+NOW\(\)\)::INTEGER/gi, 'UNIX_TIMESTAMP()');
|
||||||
|
translatedSql = translatedSql.replace(/EXTRACT\(EPOCH\s+FROM\s+NOW\(\)\)/gi, 'UNIX_TIMESTAMP()');
|
||||||
|
|
||||||
|
// 5. date_trunc('week', CURRENT_DATE) -> DATE_SUB(CURRENT_DATE, INTERVAL WEEKDAY(CURRENT_DATE) DAY)
|
||||||
|
translatedSql = translatedSql.replace(/date_trunc\('week',\s*CURRENT_DATE\)/gi, 'DATE_SUB(CURRENT_DATE, INTERVAL WEEKDAY(CURRENT_DATE) DAY)');
|
||||||
|
|
||||||
|
// 6. Transaction commands
|
||||||
|
if (translatedSql.trim().toUpperCase() === 'BEGIN') {
|
||||||
|
translatedSql = 'START TRANSACTION';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. RETURNING clauses (MySQL doesn't support them)
|
||||||
|
let returningId = false;
|
||||||
|
let returningCounter = false;
|
||||||
|
let returningIdTn = false;
|
||||||
|
|
||||||
|
const returningMatch = translatedSql.match(/\bRETURNING\s+(.+)$/i);
|
||||||
|
if (returningMatch) {
|
||||||
|
const fields = returningMatch[1].trim().toLowerCase();
|
||||||
|
if (fields === 'id') {
|
||||||
|
returningId = true;
|
||||||
|
} else if (fields === 'counter') {
|
||||||
|
returningCounter = true;
|
||||||
|
} else if (fields === 'id, tn' || fields === 'id,tn') {
|
||||||
|
returningIdTn = true;
|
||||||
|
}
|
||||||
|
translatedSql = translatedSql.replace(/\bRETURNING\s+.+$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Subquery replacement to avoid MySQL "target table twice" error in counter insert
|
||||||
|
translatedSql = translatedSql.replace(/SELECT\s+MAX\(counter\)\s+FROM\s+ticket_number_counter/gi, 'SELECT MAX(counter) FROM (SELECT counter FROM ticket_number_counter) AS tmp_counter_val');
|
||||||
|
|
||||||
|
// 9. Remove Postgres-specific casts
|
||||||
|
translatedSql = translatedSql.replace(/::text/gi, '');
|
||||||
|
translatedSql = translatedSql.replace(/::integer/gi, '');
|
||||||
|
translatedSql = translatedSql.replace(/::bigint/gi, '');
|
||||||
|
translatedSql = translatedSql.replace(/::numeric/gi, '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
translatedSql,
|
||||||
|
translatedParams,
|
||||||
|
postProcess: async (result, connection) => {
|
||||||
|
const [rowsOrHeader] = result;
|
||||||
|
let rows = [];
|
||||||
|
let rowCount = 0;
|
||||||
|
|
||||||
|
if (Array.isArray(rowsOrHeader)) {
|
||||||
|
rows = rowsOrHeader;
|
||||||
|
rowCount = rows.length;
|
||||||
|
} else if (rowsOrHeader) {
|
||||||
|
rowCount = rowsOrHeader.affectedRows || 0;
|
||||||
|
if (returningId) {
|
||||||
|
rows = [{ id: rowsOrHeader.insertId }];
|
||||||
|
} else if (returningIdTn) {
|
||||||
|
rows = [{ id: rowsOrHeader.insertId, tn: params[0] }];
|
||||||
|
} else if (returningCounter) {
|
||||||
|
const [counterResult] = await connection.query('SELECT MAX(counter) AS counter FROM ticket_number_counter');
|
||||||
|
rows = [{ counter: counterResult[0] ? counterResult[0].counter : 1 }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
rowCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompatPool {
|
||||||
|
constructor() {
|
||||||
|
this.mysqlPool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
||||||
|
database: process.env.DB_NAME || 'otrs',
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
connectionLimit: 20,
|
||||||
|
idleTimeout: 30000,
|
||||||
|
connectTimeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(sql, params = []) {
|
||||||
|
const { translatedSql, translatedParams, postProcess } = translateQuery(sql, params);
|
||||||
|
const conn = await this.mysqlPool.getConnection();
|
||||||
|
try {
|
||||||
|
const res = await conn.query(translatedSql, translatedParams);
|
||||||
|
return await postProcess(res, conn);
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
const conn = await this.mysqlPool.getConnection();
|
||||||
|
return {
|
||||||
|
query: async (sql, params = []) => {
|
||||||
|
const { translatedSql, translatedParams, postProcess } = translateQuery(sql, params);
|
||||||
|
const res = await conn.query(translatedSql, translatedParams);
|
||||||
|
return await postProcess(res, conn);
|
||||||
|
},
|
||||||
|
release: () => {
|
||||||
|
conn.release();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async end() {
|
||||||
|
await this.mysqlPool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pool = new CompatPool();
|
||||||
|
} else {
|
||||||
|
// Standard PostgreSQL Pool
|
||||||
|
pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.DB_PORT, 10) || 5432,
|
||||||
|
database: process.env.DB_NAME || 'otrs',
|
||||||
|
user: process.env.DB_USER || 'otrs',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('Unexpected error on idle client', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = pool;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<otrs_config version="2.0" init="Config">
|
||||||
|
<Setting Name="GenericInterface::Operation::Module###CustomerUser::CustomerUserSearch" Required="0" Valid="1">
|
||||||
|
<Description Translatable="1">Registration for CustomerUserSearch operation.</Description>
|
||||||
|
<Navigation>GenericInterface::Operation::ModuleRegistration</Navigation>
|
||||||
|
<Value>
|
||||||
|
<Hash>
|
||||||
|
<Item Key="Name">CustomerUserSearch</Item>
|
||||||
|
<Item Key="Controller">CustomerUser</Item>
|
||||||
|
<Item Key="ConfigDialog">AdminGenericInterfaceOperationDefault</Item>
|
||||||
|
</Hash>
|
||||||
|
</Value>
|
||||||
|
</Setting>
|
||||||
|
<Setting Name="GenericInterface::Operation::Module###CustomerUser::CustomerUserGet" Required="0" Valid="1">
|
||||||
|
<Description Translatable="1">Registration for CustomerUserGet operation.</Description>
|
||||||
|
<Navigation>GenericInterface::Operation::ModuleRegistration</Navigation>
|
||||||
|
<Value>
|
||||||
|
<Hash>
|
||||||
|
<Item Key="Name">CustomerUserGet</Item>
|
||||||
|
<Item Key="Controller">CustomerUser</Item>
|
||||||
|
<Item Key="ConfigDialog">AdminGenericInterfaceOperationDefault</Item>
|
||||||
|
</Hash>
|
||||||
|
</Value>
|
||||||
|
</Setting>
|
||||||
|
</otrs_config>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package Kernel::GenericInterface::Operation::CustomerUser::CustomerUserGet;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use Kernel::System::ObjectManager;
|
||||||
|
|
||||||
|
sub new {
|
||||||
|
my ( $Type, %Param ) = @_;
|
||||||
|
my $Self = {%Param};
|
||||||
|
bless( $Self, $Type );
|
||||||
|
return $Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub Run {
|
||||||
|
my ( $Self, %Param ) = @_;
|
||||||
|
|
||||||
|
if ( !$Param{Data} || !$Param{Data}->{UserLogin} ) {
|
||||||
|
return {
|
||||||
|
Success => 0,
|
||||||
|
ErrorMessage => "UserLogin is required",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
my $UserLogin = $Param{Data}->{UserLogin};
|
||||||
|
my $CustomerUserObject = $Kernel::OM->Get('Kernel::System::CustomerUser');
|
||||||
|
|
||||||
|
my %User = $CustomerUserObject->CustomerUserDataGet(
|
||||||
|
User => $UserLogin,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( !%User ) {
|
||||||
|
return {
|
||||||
|
Success => 0,
|
||||||
|
ErrorMessage => "Customer user not found: $UserLogin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Success => 1,
|
||||||
|
Data => {
|
||||||
|
CustomerUser => {
|
||||||
|
UserLogin => $User{UserLogin},
|
||||||
|
UserFirstname => $User{UserFirstname},
|
||||||
|
UserLastname => $User{UserLastname},
|
||||||
|
UserEmail => $User{UserEmail},
|
||||||
|
UserCustomerID=> $User{UserCustomerID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
1;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package Kernel::GenericInterface::Operation::CustomerUser::CustomerUserSearch;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use Kernel::System::ObjectManager;
|
||||||
|
|
||||||
|
sub new {
|
||||||
|
my ( $Type, %Param ) = @_;
|
||||||
|
my $Self = {%Param};
|
||||||
|
bless( $Self, $Type );
|
||||||
|
return $Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub Run {
|
||||||
|
my ( $Self, %Param ) = @_;
|
||||||
|
|
||||||
|
if ( !$Param{Data} ) {
|
||||||
|
return {
|
||||||
|
Success => 0,
|
||||||
|
ErrorMessage => "No Data provided",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
my $Search = $Param{Data}->{Search} || '';
|
||||||
|
my $CustomerID = $Param{Data}->{CustomerID} || '';
|
||||||
|
my $Valid = defined $Param{Data}->{Valid} ? $Param{Data}->{Valid} : 1;
|
||||||
|
|
||||||
|
my $CustomerUserObject = $Kernel::OM->Get('Kernel::System::CustomerUser');
|
||||||
|
|
||||||
|
# Search customer users
|
||||||
|
my %Users = $CustomerUserObject->CustomerSearch(
|
||||||
|
Search => $Search,
|
||||||
|
CustomerID => $CustomerID,
|
||||||
|
Valid => $Valid,
|
||||||
|
);
|
||||||
|
|
||||||
|
my @CustomerUserIDs = keys %Users;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Success => 1,
|
||||||
|
Data => {
|
||||||
|
CustomerUserID => \@CustomerUserIDs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
1;
|
||||||
Generated
+1153
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "otrs-turbo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Modern fast interface for OTRS ticket management - direct database access",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "npx -y nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"mysql2": "^3.22.5",
|
||||||
|
"pg": "^8.13.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"otrs",
|
||||||
|
"ticket",
|
||||||
|
"helpdesk"
|
||||||
|
],
|
||||||
|
"license": "AGPL-3.0"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="OTRS Turbo — Interfaccia moderna per gestione rapida ticket. Accesso diretto al database OTRS.">
|
||||||
|
<title>OTRS Turbo — Gestione Ticket Veloce</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Sidebar Navigation -->
|
||||||
|
<nav class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="brand-icon">⚡</div>
|
||||||
|
<span class="brand-text">OTRS Turbo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav-menu">
|
||||||
|
<li>
|
||||||
|
<a href="#/dashboard" class="nav-link active" data-view="dashboard" id="nav-dashboard">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/tickets" class="nav-link" data-view="tickets" id="nav-tickets">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
<span>Ticket</span>
|
||||||
|
<span class="nav-badge" id="open-ticket-count"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/tickets/new" class="nav-link" data-view="new-ticket" id="nav-new-ticket">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M12 8v8M8 12h8"/>
|
||||||
|
</svg>
|
||||||
|
<span>Nuovo Ticket</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-footer-info">
|
||||||
|
<div class="connection-dot" id="connection-status"></div>
|
||||||
|
<span class="connection-text" id="connection-text">Connessione DB...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content" id="main-content">
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<header class="topbar" id="topbar">
|
||||||
|
<div class="topbar-left" style="display:flex; align-items:center; gap:var(--space-md);">
|
||||||
|
<h1 class="page-title" id="page-title">Dashboard</h1>
|
||||||
|
<select class="form-select" id="active-agent-select" style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 180px; margin: 0; background-position: right 10px center; border-color: var(--border-light);"></select>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div class="search-bar" id="global-search-container">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<path d="M21 21l-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" class="search-input" id="global-search" placeholder="Cerca ticket (numero o titolo)..." />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="topbar-new-ticket" onclick="window.location.hash='#/tickets/new'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Nuovo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- View Container -->
|
||||||
|
<div class="view-container" id="view-container">
|
||||||
|
<!-- Dynamic content rendered here by JS -->
|
||||||
|
<div class="loading-screen" id="loading-screen">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="/js/components/toast.js"></script>
|
||||||
|
<script src="/js/components/filters.js"></script>
|
||||||
|
<script src="/js/views/dashboard.js"></script>
|
||||||
|
<script src="/js/views/ticketList.js"></script>
|
||||||
|
<script src="/js/views/ticketDetail.js"></script>
|
||||||
|
<script src="/js/views/ticketCreate.js"></script>
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* OTRS Turbo — Core Application
|
||||||
|
* SPA router, API client, lookup cache, and utility functions.
|
||||||
|
*/
|
||||||
|
const App = {
|
||||||
|
lookups: {
|
||||||
|
queues: [],
|
||||||
|
states: [],
|
||||||
|
priorities: [],
|
||||||
|
users: [],
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
lookupsLoaded: false,
|
||||||
|
|
||||||
|
/** Initialize the application */
|
||||||
|
init() {
|
||||||
|
Toast.init();
|
||||||
|
|
||||||
|
// Hash-based SPA router
|
||||||
|
window.addEventListener('hashchange', () => this.route());
|
||||||
|
|
||||||
|
// Global search
|
||||||
|
const searchInput = document.getElementById('global-search');
|
||||||
|
if (searchInput) {
|
||||||
|
let timeout;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
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) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (!hash.startsWith('#/tickets') || hash.includes('/new') || hash.match(/#\/tickets\/\d+/)) {
|
||||||
|
window.location.hash = '#/tickets';
|
||||||
|
} else {
|
||||||
|
TicketListView.currentPage = 1;
|
||||||
|
TicketListView.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DB connection
|
||||||
|
this.checkConnection();
|
||||||
|
|
||||||
|
// Init active agent selector
|
||||||
|
this.initAgentSelector();
|
||||||
|
|
||||||
|
// Initial route
|
||||||
|
if (!window.location.hash || window.location.hash === '#/') {
|
||||||
|
window.location.hash = '#/dashboard';
|
||||||
|
} else {
|
||||||
|
this.route();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Route based on current hash */
|
||||||
|
route() {
|
||||||
|
const hash = window.location.hash || '#/dashboard';
|
||||||
|
const titleEl = document.getElementById('page-title');
|
||||||
|
|
||||||
|
// Update active nav link
|
||||||
|
document.querySelectorAll('.nav-link').forEach(link => {
|
||||||
|
link.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hash === '#/dashboard') {
|
||||||
|
document.getElementById('nav-dashboard')?.classList.add('active');
|
||||||
|
titleEl.textContent = 'Dashboard';
|
||||||
|
DashboardView.render();
|
||||||
|
|
||||||
|
} else if (hash === '#/tickets') {
|
||||||
|
document.getElementById('nav-tickets')?.classList.add('active');
|
||||||
|
titleEl.textContent = 'Ticket';
|
||||||
|
TicketListView.render();
|
||||||
|
|
||||||
|
} else if (hash === '#/tickets/new') {
|
||||||
|
document.getElementById('nav-new-ticket')?.classList.add('active');
|
||||||
|
titleEl.textContent = 'Nuovo Ticket';
|
||||||
|
TicketCreateView.render();
|
||||||
|
|
||||||
|
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
||||||
|
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
||||||
|
document.getElementById('nav-tickets')?.classList.add('active');
|
||||||
|
titleEl.textContent = `Ticket #${id}`;
|
||||||
|
TicketDetailView.render(id);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Fallback to dashboard
|
||||||
|
window.location.hash = '#/dashboard';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** API fetch wrapper */
|
||||||
|
async api(url, options = {}) {
|
||||||
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
|
const defaultOptions = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Agent-ID': activeAgentId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = { ...defaultOptions.headers, ...(options.headers || {}) };
|
||||||
|
const response = await fetch(url, { ...defaultOptions, ...options, headers });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || errData.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Ensure lookup data is loaded (cached) */
|
||||||
|
async ensureLookups() {
|
||||||
|
if (this.lookupsLoaded) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [queues, states, priorities, users, types] = await Promise.all([
|
||||||
|
this.api('/api/queues'),
|
||||||
|
this.api('/api/states'),
|
||||||
|
this.api('/api/priorities'),
|
||||||
|
this.api('/api/users'),
|
||||||
|
this.api('/api/types'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.lookups = { queues, states, priorities, users, types };
|
||||||
|
this.lookupsLoaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load lookups:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Check database connection */
|
||||||
|
async checkConnection() {
|
||||||
|
const dot = document.getElementById('connection-status');
|
||||||
|
const text = document.getElementById('connection-text');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api('/api/queues');
|
||||||
|
dot.classList.add('connected');
|
||||||
|
dot.classList.remove('error');
|
||||||
|
text.textContent = 'DB connesso';
|
||||||
|
} catch (err) {
|
||||||
|
dot.classList.add('error');
|
||||||
|
dot.classList.remove('connected');
|
||||||
|
text.textContent = 'DB non raggiungibile';
|
||||||
|
Toast.error('Impossibile connettersi al database OTRS');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Escape HTML to prevent XSS */
|
||||||
|
escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Format date for display */
|
||||||
|
formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Format date+time for display */
|
||||||
|
formatDateTime(dateStr) {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Initialize active agent dropdown */
|
||||||
|
async initAgentSelector() {
|
||||||
|
const select = document.getElementById('active-agent-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure lookups are loaded
|
||||||
|
await this.ensureLookups();
|
||||||
|
|
||||||
|
// Populate select dropdown
|
||||||
|
select.innerHTML = (this.lookups.users || []).map(u =>
|
||||||
|
`<option value="${u.id}">${u.first_name} ${u.last_name} (${u.login})</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Load saved agent ID or default to the first available
|
||||||
|
const savedAgentId = localStorage.getItem('activeAgentId');
|
||||||
|
if (savedAgentId && (this.lookups.users || []).some(u => String(u.id) === String(savedAgentId))) {
|
||||||
|
select.value = savedAgentId;
|
||||||
|
} else if ((this.lookups.users || []).length > 0) {
|
||||||
|
select.value = this.lookups.users[0].id;
|
||||||
|
localStorage.setItem('activeAgentId', select.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dropdown change event
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
localStorage.setItem('activeAgentId', select.value);
|
||||||
|
Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to init agent selector:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Map priority name to a 1-5 index for styling */
|
||||||
|
priorityIndex(name) {
|
||||||
|
if (!name) return 3;
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (lower.includes('very low') || lower.includes('1')) return 1;
|
||||||
|
if (lower.includes('low') || lower.includes('2')) return 2;
|
||||||
|
if (lower.includes('normal') || lower.includes('3')) return 3;
|
||||||
|
if (lower.includes('high') && !lower.includes('very') || lower.includes('4')) return 4;
|
||||||
|
if (lower.includes('very high') || lower.includes('5')) return 5;
|
||||||
|
return 3;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the app when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => App.init());
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Filters Component
|
||||||
|
* Manages ticket list filter state and renders filter dropdowns.
|
||||||
|
*/
|
||||||
|
const Filters = {
|
||||||
|
state: {
|
||||||
|
queue_id: '',
|
||||||
|
state_id: '',
|
||||||
|
priority_id: '',
|
||||||
|
user_id: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Load saved filters from localStorage */
|
||||||
|
load() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('otrs_turbo_filters');
|
||||||
|
if (saved) {
|
||||||
|
Object.assign(this.state, JSON.parse(saved));
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Save filters to localStorage */
|
||||||
|
save() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('otrs_turbo_filters', JSON.stringify(this.state));
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reset all filters */
|
||||||
|
reset() {
|
||||||
|
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '' };
|
||||||
|
this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get filters as query string params (non-empty only) */
|
||||||
|
toQueryParams() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, val] of Object.entries(this.state)) {
|
||||||
|
if (val) params.set(key, val);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render filter bar HTML.
|
||||||
|
* @param {Object} lookups - { queues, states, priorities, users }
|
||||||
|
* @returns {string} HTML string
|
||||||
|
*/
|
||||||
|
renderBar(lookups) {
|
||||||
|
const makeOptions = (items, valueKey, labelKey, selectedVal) => {
|
||||||
|
return items.map(item => {
|
||||||
|
const val = item[valueKey];
|
||||||
|
const label = typeof labelKey === 'function' ? labelKey(item) : item[labelKey];
|
||||||
|
const sel = String(val) === String(selectedVal) ? 'selected' : '';
|
||||||
|
return `<option value="${val}" ${sel}>${label}</option>`;
|
||||||
|
}).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="filters-bar" id="filters-bar">
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Stato</span>
|
||||||
|
<select class="filter-select" data-filter="state_id" id="filter-state">
|
||||||
|
<option value="">Tutti</option>
|
||||||
|
${makeOptions(lookups.states || [], 'id', 'name', this.state.state_id)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Coda</span>
|
||||||
|
<select class="filter-select" data-filter="queue_id" id="filter-queue">
|
||||||
|
<option value="">Tutte</option>
|
||||||
|
${makeOptions(lookups.queues || [], 'id', 'name', this.state.queue_id)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Priorità</span>
|
||||||
|
<select class="filter-select" data-filter="priority_id" id="filter-priority">
|
||||||
|
<option value="">Tutte</option>
|
||||||
|
${makeOptions(lookups.priorities || [], 'id', 'name', this.state.priority_id)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Owner</span>
|
||||||
|
<select class="filter-select" data-filter="user_id" id="filter-owner">
|
||||||
|
<option value="">Tutti</option>
|
||||||
|
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filters-actions">
|
||||||
|
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Bind change events to filter selects */
|
||||||
|
bindEvents(onFilterChange) {
|
||||||
|
const selects = document.querySelectorAll('.filter-select[data-filter]');
|
||||||
|
selects.forEach(sel => {
|
||||||
|
sel.addEventListener('change', (e) => {
|
||||||
|
this.state[e.target.dataset.filter] = e.target.value;
|
||||||
|
this.save();
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetBtn = document.getElementById('filter-reset');
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener('click', () => {
|
||||||
|
this.reset();
|
||||||
|
selects.forEach(s => s.value = '');
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load saved filters on script load
|
||||||
|
Filters.load();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Toast Notification System
|
||||||
|
* Usage: Toast.success('Message'), Toast.error('Message'), Toast.info('Message')
|
||||||
|
*/
|
||||||
|
const Toast = {
|
||||||
|
container: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.container = document.getElementById('toast-container');
|
||||||
|
},
|
||||||
|
|
||||||
|
show(message, type = 'info', duration = 3500) {
|
||||||
|
if (!this.container) this.init();
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
info: 'ℹ',
|
||||||
|
warning: '⚠',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.innerHTML = `
|
||||||
|
<span style="font-size:1.1rem;line-height:1;">${icons[type] || ''}</span>
|
||||||
|
<span>${message}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.container.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto-dismiss
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('toast-exit');
|
||||||
|
toast.addEventListener('animationend', () => toast.remove());
|
||||||
|
}, duration);
|
||||||
|
},
|
||||||
|
|
||||||
|
success(msg) { this.show(msg, 'success'); },
|
||||||
|
error(msg) { this.show(msg, 'error', 5000); },
|
||||||
|
info(msg) { this.show(msg, 'info'); },
|
||||||
|
warning(msg) { this.show(msg, 'warning', 4000); },
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard View
|
||||||
|
* Shows stats overview, distribution charts, and recent tickets.
|
||||||
|
*/
|
||||||
|
const DashboardView = {
|
||||||
|
async render() {
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dashboard...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await App.api('/api/dashboard/stats');
|
||||||
|
|
||||||
|
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 maxByQueue = Math.max(...(stats.by_queue || []).map(s => parseInt(s.count)), 1);
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card accent">
|
||||||
|
<div class="stat-label">Ticket Aperti</div>
|
||||||
|
<div class="stat-value">${stats.total_open}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Creati Oggi</div>
|
||||||
|
<div class="stat-value">${stats.created_today}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Creati Settimana</div>
|
||||||
|
<div class="stat-value">${stats.created_this_week}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card ${stats.escalated > 0 ? 'danger' : ''}">
|
||||||
|
<div class="stat-label">Escalated</div>
|
||||||
|
<div class="stat-value">${stats.escalated}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distributions -->
|
||||||
|
<div class="distribution-section">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Per Stato</div>
|
||||||
|
${(stats.by_state || []).map(s => `
|
||||||
|
<div class="dist-bar-container">
|
||||||
|
<div class="dist-bar-header">
|
||||||
|
<span class="dist-bar-label">${s.state}</span>
|
||||||
|
<span class="dist-bar-value">${s.count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dist-bar-track">
|
||||||
|
<div class="dist-bar-fill" style="width: ${(parseInt(s.count) / maxByState * 100).toFixed(1)}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
${(stats.by_state || []).length === 0 ? '<p style="color:var(--text-tertiary);font-size:0.85rem;">Nessun dato</p>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Per Priorità</div>
|
||||||
|
${(stats.by_priority || []).map((p, idx) => `
|
||||||
|
<div class="dist-bar-container">
|
||||||
|
<div class="dist-bar-header">
|
||||||
|
<span class="dist-bar-label">${p.priority}</span>
|
||||||
|
<span class="dist-bar-value">${p.count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dist-bar-track">
|
||||||
|
<div class="dist-bar-fill" style="width: ${(parseInt(p.count) / maxByPriority * 100).toFixed(1)}%; background: linear-gradient(90deg, var(--priority-${idx + 1}-text, var(--accent-primary)), var(--accent-secondary));"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
${(stats.by_priority || []).length === 0 ? '<p style="color:var(--text-tertiary);font-size:0.85rem;">Nessun dato</p>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Per Coda (Top 10)</div>
|
||||||
|
${(stats.by_queue || []).map(q => `
|
||||||
|
<div class="dist-bar-container">
|
||||||
|
<div class="dist-bar-header">
|
||||||
|
<span class="dist-bar-label">${q.queue}</span>
|
||||||
|
<span class="dist-bar-value">${q.count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dist-bar-track">
|
||||||
|
<div class="dist-bar-fill" style="width: ${(parseInt(q.count) / maxByQueue * 100).toFixed(1)}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
${(stats.by_queue || []).length === 0 ? '<p style="color:var(--text-tertiary);font-size:0.85rem;">Nessun dato</p>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Tickets -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Ticket Recenti</div>
|
||||||
|
${(stats.recent_tickets || []).length > 0 ? `
|
||||||
|
<table class="recent-tickets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Numero</th>
|
||||||
|
<th>Titolo</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Priorità</th>
|
||||||
|
<th>Coda</th>
|
||||||
|
<th>Data</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${stats.recent_tickets.map(t => `
|
||||||
|
<tr onclick="window.location.hash='#/tickets/${t.id}'">
|
||||||
|
<td><span class="ticket-tn">${t.tn}</span></td>
|
||||||
|
<td class="ticket-title-cell">${App.escapeHtml(t.title || '')}</td>
|
||||||
|
<td><span class="badge badge-state" data-state-type="${(t.state_name || '').toLowerCase()}">${t.state_name}</span></td>
|
||||||
|
<td><span class="badge badge-priority" data-priority="${t.priority_name ? App.priorityIndex(t.priority_name) : 3}">${t.priority_name}</span></td>
|
||||||
|
<td><span class="badge badge-queue">${t.queue_name}</span></td>
|
||||||
|
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDate(t.create_time)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
` : `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">📭</div>
|
||||||
|
<div class="empty-state-text">Nessun ticket recente</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Animate bars after render
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.querySelectorAll('.dist-bar-fill').forEach(bar => {
|
||||||
|
const w = bar.style.width;
|
||||||
|
bar.style.width = '0%';
|
||||||
|
requestAnimationFrame(() => { bar.style.width = w; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update open ticket count in sidebar badge
|
||||||
|
const badge = document.getElementById('open-ticket-count');
|
||||||
|
if (badge && stats.total_open > 0) {
|
||||||
|
badge.textContent = stats.total_open;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-text">Errore caricamento dashboard</div>
|
||||||
|
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
/**
|
||||||
|
* Ticket Create View
|
||||||
|
* Minimal, fast form for creating new tickets.
|
||||||
|
*/
|
||||||
|
const TicketCreateView = {
|
||||||
|
async render() {
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento form...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await App.ensureLookups();
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<a class="back-link" onclick="history.back()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||||
|
Torna indietro
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="card create-form">
|
||||||
|
<div class="card-title" style="margin-bottom:var(--space-lg); font-size:0.9rem; display:flex; justify-content:space-between; align-items:center; width:100%; gap: var(--space-md); flex-wrap: wrap;">
|
||||||
|
<span>Crea Nuovo Ticket</span>
|
||||||
|
<select class="form-select" id="create-state" style="width:200px; padding: 6px 12px; height: 32px; font-size: 0.85rem; margin: 0; line-height: 1;">
|
||||||
|
${(App.lookups.states || []).map(s => {
|
||||||
|
const sel = s.type_name === 'new' ? 'selected' : '';
|
||||||
|
return `<option value="${s.id}" ${sel}>${s.name}</option>`;
|
||||||
|
}).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label">Titolo <span class="required">*</span></label>
|
||||||
|
<input type="text" class="form-input" id="create-title" placeholder="Descrizione breve del problema" autofocus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="position:relative;">
|
||||||
|
<label class="form-label">Coda <span class="required">*</span></label>
|
||||||
|
<input type="text" class="form-input" id="create-queue-search" placeholder="Cerca coda..." autocomplete="off" />
|
||||||
|
<input type="hidden" id="create-queue" />
|
||||||
|
<div id="queue-suggestions" class="autocomplete-suggestions" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tipo</label>
|
||||||
|
<select class="form-select" id="create-type">
|
||||||
|
<option value="">—</option>
|
||||||
|
${(App.lookups.types || []).map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="position:relative;">
|
||||||
|
<label class="form-label">Owner (Proprietario)</label>
|
||||||
|
<input type="text" class="form-input" id="create-owner-search" placeholder="Cerca proprietario..." autocomplete="off" />
|
||||||
|
<input type="hidden" id="create-owner" />
|
||||||
|
<div id="owner-suggestions" class="autocomplete-suggestions" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="position:relative;">
|
||||||
|
<label class="form-label">Responsabile</label>
|
||||||
|
<input type="text" class="form-input" id="create-responsible-search" placeholder="Cerca responsabile..." autocomplete="off" />
|
||||||
|
<input type="hidden" id="create-responsible" />
|
||||||
|
<div id="responsible-suggestions" class="autocomplete-suggestions" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full-width" style="position:relative;">
|
||||||
|
<label class="form-label">Utente Cliente (Persona) <span class="required">*</span></label>
|
||||||
|
<input type="text" class="form-input" id="create-user-search" placeholder="Cerca utente (nome, email, login)..." autocomplete="off" />
|
||||||
|
<input type="hidden" id="create-customer-user-id" />
|
||||||
|
<div id="user-suggestions" class="autocomplete-suggestions" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsible Advanced Options -->
|
||||||
|
<div class="full-width" id="advanced-options" style="display: none; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--space-md); padding: var(--space-md); background: rgba(255,255,255,0.02); border: 1px dashed var(--border-light); border-radius: var(--radius-md); margin-top: var(--space-md); margin-bottom: var(--space-md);">
|
||||||
|
<div class="form-group" style="margin-bottom:0;">
|
||||||
|
<label class="form-label">Azienda Cliente (Società)</label>
|
||||||
|
<input type="text" class="form-input" id="create-company-search" readonly disabled placeholder="Auto-assegnata dal cliente" style="cursor: not-allowed; background: rgba(255,255,255,0.05); color: var(--text-secondary); margin-bottom:0;" />
|
||||||
|
<input type="hidden" id="create-customer-id" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0;">
|
||||||
|
<label class="form-label">Priorità</label>
|
||||||
|
<select class="form-select" id="create-priority" style="margin-bottom:0;">
|
||||||
|
${(App.lookups.priorities || []).map(p => {
|
||||||
|
const sel = p.id === 3 ? 'selected' : '';
|
||||||
|
return `<option value="${p.id}" ${sel}>${p.name}</option>`;
|
||||||
|
}).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label">Oggetto</label>
|
||||||
|
<input type="text" class="form-input" id="create-subject" placeholder="Oggetto del primo articolo (opzionale)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label">Messaggio / Nota iniziale</label>
|
||||||
|
<textarea class="form-textarea" id="create-body" placeholder="Descrivi il problema in dettaglio..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="toggle-advanced" style="display: flex; align-items: center; gap: var(--space-xs); padding: 6px 12px; height: auto; margin:0;">
|
||||||
|
<svg id="advanced-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px; transition: transform var(--transition-normal);"><path d="M9 18l6-6-6-6"/></svg>
|
||||||
|
Opzioni Avanzate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:var(--space-sm);">
|
||||||
|
<button class="btn btn-ghost" onclick="history.back()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" id="create-submit">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Crea Ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-text">Errore caricamento form</div>
|
||||||
|
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
const submitBtn = document.getElementById('create-submit');
|
||||||
|
const companySearchInput = document.getElementById('create-company-search');
|
||||||
|
const customerIdInput = document.getElementById('create-customer-id');
|
||||||
|
|
||||||
|
const userSearchInput = document.getElementById('create-user-search');
|
||||||
|
const userSuggestionsDiv = document.getElementById('user-suggestions');
|
||||||
|
const customerUserIdInput = document.getElementById('create-customer-user-id');
|
||||||
|
|
||||||
|
const ownerSearchInput = document.getElementById('create-owner-search');
|
||||||
|
const ownerSuggestionsDiv = document.getElementById('owner-suggestions');
|
||||||
|
const ownerIdInput = document.getElementById('create-owner');
|
||||||
|
|
||||||
|
const responsibleSearchInput = document.getElementById('create-responsible-search');
|
||||||
|
const responsibleSuggestionsDiv = document.getElementById('responsible-suggestions');
|
||||||
|
const responsibleIdInput = document.getElementById('create-responsible');
|
||||||
|
|
||||||
|
const queueSearchInput = document.getElementById('create-queue-search');
|
||||||
|
const queueSuggestionsDiv = document.getElementById('queue-suggestions');
|
||||||
|
const queueIdInput = document.getElementById('create-queue');
|
||||||
|
|
||||||
|
const stateIdInput = document.getElementById('create-state');
|
||||||
|
|
||||||
|
const toggleBtn = document.getElementById('toggle-advanced');
|
||||||
|
const advancedOptions = document.getElementById('advanced-options');
|
||||||
|
const arrow = document.getElementById('advanced-arrow');
|
||||||
|
|
||||||
|
// Pre-populate default values asynchronously
|
||||||
|
setTimeout(async () => {
|
||||||
|
// 1. Owner & Responsible pre-population with active agent
|
||||||
|
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
|
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
||||||
|
if (activeAgent) {
|
||||||
|
if (ownerSearchInput && ownerIdInput) {
|
||||||
|
ownerSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||||
|
ownerIdInput.value = activeAgent.id;
|
||||||
|
}
|
||||||
|
if (responsibleSearchInput && responsibleIdInput) {
|
||||||
|
responsibleSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||||
|
responsibleIdInput.value = activeAgent.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Customer User pre-population with first match
|
||||||
|
try {
|
||||||
|
const companies = await App.api('/api/customer-companies/search?q=cliente');
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Collapsible Options Toggle
|
||||||
|
if (toggleBtn && advancedOptions && arrow) {
|
||||||
|
toggleBtn.addEventListener('click', () => {
|
||||||
|
const isHidden = advancedOptions.style.display === 'none';
|
||||||
|
advancedOptions.style.display = isHidden ? 'grid' : 'none';
|
||||||
|
arrow.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Autocomplete
|
||||||
|
let userDebounce;
|
||||||
|
if (userSearchInput) {
|
||||||
|
userSearchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(userDebounce);
|
||||||
|
const q = userSearchInput.value.trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
userSuggestionsDiv.style.display = 'none';
|
||||||
|
customerUserIdInput.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userDebounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (users.length === 0) {
|
||||||
|
userSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
|
||||||
|
userSuggestionsDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userSuggestionsDiv.innerHTML = users.map(u => `
|
||||||
|
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login)}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
|
||||||
|
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)} | Azienda: ${App.escapeHtml(u.customer_id || '—')})</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
userSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// Bind click
|
||||||
|
userSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
|
if (item.dataset.login) {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
userSearchInput.value = item.dataset.name;
|
||||||
|
customerUserIdInput.value = item.dataset.login;
|
||||||
|
userSuggestionsDiv.style.display = 'none';
|
||||||
|
|
||||||
|
// Auto-fill company inside Advanced Options
|
||||||
|
if (item.dataset.customerId) {
|
||||||
|
customerIdInput.value = item.dataset.customerId;
|
||||||
|
companySearchInput.value = item.dataset.customerId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner Autocomplete (dynamic backend search)
|
||||||
|
let ownerDebounce;
|
||||||
|
if (ownerSearchInput) {
|
||||||
|
ownerSearchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(ownerDebounce);
|
||||||
|
const q = ownerSearchInput.value.trim();
|
||||||
|
// Do not block empty query to allow all agent results on focus
|
||||||
|
|
||||||
|
|
||||||
|
ownerDebounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const agents = await App.api(`/api/agents/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (agents.length === 0) {
|
||||||
|
ownerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun agente trovato</div>';
|
||||||
|
ownerSuggestionsDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerSuggestionsDiv.innerHTML = agents.map(u => `
|
||||||
|
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(u.id)}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
|
||||||
|
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)})</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
ownerSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// Bind click
|
||||||
|
ownerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
|
if (item.dataset.id) {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
ownerSearchInput.value = item.dataset.name;
|
||||||
|
ownerIdInput.value = item.dataset.id;
|
||||||
|
ownerSuggestionsDiv.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
ownerSearchInput.addEventListener('focus', () => {
|
||||||
|
ownerSearchInput.value = '';
|
||||||
|
ownerIdInput.value = '';
|
||||||
|
ownerSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsible Autocomplete (dynamic backend search)
|
||||||
|
let responsibleDebounce;
|
||||||
|
if (responsibleSearchInput) {
|
||||||
|
responsibleSearchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(responsibleDebounce);
|
||||||
|
const q = responsibleSearchInput.value.trim();
|
||||||
|
// Do not block empty query to allow all agent results on focus
|
||||||
|
|
||||||
|
|
||||||
|
responsibleDebounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const agents = await App.api(`/api/agents/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (agents.length === 0) {
|
||||||
|
responsibleSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun agente trovato</div>';
|
||||||
|
responsibleSuggestionsDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
responsibleSuggestionsDiv.innerHTML = agents.map(u => `
|
||||||
|
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(u.id)}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
|
||||||
|
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)})</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
responsibleSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// Bind click
|
||||||
|
responsibleSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
|
if (item.dataset.id) {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
responsibleSearchInput.value = item.dataset.name;
|
||||||
|
responsibleIdInput.value = item.dataset.id;
|
||||||
|
responsibleSuggestionsDiv.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
responsibleSearchInput.addEventListener('focus', () => {
|
||||||
|
responsibleSearchInput.value = '';
|
||||||
|
responsibleIdInput.value = '';
|
||||||
|
responsibleSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue Autocomplete
|
||||||
|
let queueDebounce;
|
||||||
|
if (queueSearchInput) {
|
||||||
|
queueSearchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(queueDebounce);
|
||||||
|
const q = queueSearchInput.value.trim();
|
||||||
|
queueDebounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (queues.length === 0) {
|
||||||
|
queueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessuna coda trovata</div>';
|
||||||
|
queueSuggestionsDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueSuggestionsDiv.innerHTML = queues.map(q => `
|
||||||
|
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}">
|
||||||
|
<strong>${App.escapeHtml(q.name)}</strong>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
queueSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// Bind click
|
||||||
|
queueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
|
if (item.dataset.id) {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
queueSearchInput.value = item.dataset.name;
|
||||||
|
queueIdInput.value = item.dataset.id;
|
||||||
|
queueSuggestionsDiv.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
queueSearchInput.addEventListener('focus', () => {
|
||||||
|
queueSearchInput.value = '';
|
||||||
|
queueIdInput.value = '';
|
||||||
|
queueSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close suggestions on click outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (userSearchInput && e.target !== userSearchInput && e.target !== userSuggestionsDiv) {
|
||||||
|
userSuggestionsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (ownerSearchInput && e.target !== ownerSearchInput && e.target !== ownerSuggestionsDiv) {
|
||||||
|
ownerSuggestionsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (responsibleSearchInput && e.target !== responsibleSearchInput && e.target !== responsibleSuggestionsDiv) {
|
||||||
|
responsibleSuggestionsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (queueSearchInput && e.target !== queueSearchInput && e.target !== queueSuggestionsDiv) {
|
||||||
|
queueSuggestionsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
submitBtn.addEventListener('click', async () => {
|
||||||
|
const title = document.getElementById('create-title').value.trim();
|
||||||
|
const queue_id = queueIdInput.value;
|
||||||
|
const state_id = stateIdInput.value;
|
||||||
|
const priority_id = document.getElementById('create-priority').value;
|
||||||
|
const type_id = document.getElementById('create-type')?.value;
|
||||||
|
|
||||||
|
const customerId = customerIdInput.value;
|
||||||
|
let customerUserId = customerUserIdInput.value;
|
||||||
|
|
||||||
|
const ownerId = ownerIdInput.value;
|
||||||
|
const responsibleId = responsibleIdInput.value;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!title) {
|
||||||
|
Toast.warning('Il titolo è obbligatorio');
|
||||||
|
document.getElementById('create-title').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!queue_id) {
|
||||||
|
Toast.warning('Seleziona una coda');
|
||||||
|
queueSearchInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state_id) {
|
||||||
|
Toast.warning('Seleziona uno stato');
|
||||||
|
document.getElementById('create-state').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!customerId && !customerUserId) {
|
||||||
|
Toast.warning('Seleziona un Utente Cliente');
|
||||||
|
userSearchInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company fallback if no individual user selected
|
||||||
|
if (customerId && !customerUserId) {
|
||||||
|
customerUserId = customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
queue_id: parseInt(queue_id),
|
||||||
|
state_id: parseInt(state_id),
|
||||||
|
priority_id: parseInt(priority_id),
|
||||||
|
user_id: ownerId ? parseInt(ownerId) : undefined,
|
||||||
|
responsible_user_id: responsibleId ? parseInt(responsibleId) : undefined,
|
||||||
|
type_id: type_id ? parseInt(type_id) : undefined,
|
||||||
|
customer_id: customerId || undefined,
|
||||||
|
customer_user_id: customerUserId || undefined,
|
||||||
|
subject: document.getElementById('create-subject').value.trim() || undefined,
|
||||||
|
body: document.getElementById('create-body').value.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;"></div> Creazione...';
|
||||||
|
|
||||||
|
const result = await App.api('/api/tickets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(`Ticket #${result.tn} creato!`);
|
||||||
|
|
||||||
|
// Navigate to the new ticket
|
||||||
|
window.location.hash = `#/tickets/${result.id}`;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore creazione: ' + err.message);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Crea Ticket
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Ticket Detail View
|
||||||
|
* Shows full ticket info with quick-edit dropdowns, article timeline, and add-note form.
|
||||||
|
*/
|
||||||
|
const TicketDetailView = {
|
||||||
|
ticketId: null,
|
||||||
|
originalValues: {},
|
||||||
|
|
||||||
|
async render(id) {
|
||||||
|
this.ticketId = id;
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await App.ensureLookups();
|
||||||
|
const data = await App.api(`/api/tickets/${id}`);
|
||||||
|
const { ticket, articles } = data;
|
||||||
|
const totalTime = articles.reduce((sum, a) => {
|
||||||
|
const val = parseFloat(a.time_unit);
|
||||||
|
return sum + (isNaN(val) ? 0 : val);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
this.originalValues = {
|
||||||
|
ticket_state_id: ticket.ticket_state_id,
|
||||||
|
ticket_priority_id: ticket.ticket_priority_id,
|
||||||
|
queue_id: ticket.queue_id,
|
||||||
|
user_id: ticket.user_id,
|
||||||
|
type_id: ticket.type_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<a class="back-link" onclick="history.back()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||||
|
Torna alla lista
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="ticket-detail">
|
||||||
|
<div class="ticket-detail-main">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="ticket-header">
|
||||||
|
<div class="ticket-header-info">
|
||||||
|
<div class="ticket-number">#${ticket.tn}</div>
|
||||||
|
<h2 class="ticket-detail-title">${App.escapeHtml(ticket.title || '(senza titolo)')}</h2>
|
||||||
|
<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-priority" data-priority="${App.priorityIndex(ticket.priority_name)}">${ticket.priority_name}</span>
|
||||||
|
<span class="badge badge-queue">${ticket.queue_name}</span>
|
||||||
|
${ticket.lock_name === 'lock' ? '<span class="badge" style="background:var(--warning-bg);color:var(--warning);">🔒 Bloccato</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Edit -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Modifica Rapida</div>
|
||||||
|
<div class="quick-edit">
|
||||||
|
<div class="quick-edit-field">
|
||||||
|
<label class="quick-edit-label">Stato</label>
|
||||||
|
<select class="quick-edit-select" id="qe-state" data-field="ticket_state_id">
|
||||||
|
${(App.lookups.states || []).map(s =>
|
||||||
|
`<option value="${s.id}" ${s.id === ticket.ticket_state_id ? 'selected' : ''}>${s.name}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="quick-edit-field">
|
||||||
|
<label class="quick-edit-label">Priorità</label>
|
||||||
|
<select class="quick-edit-select" id="qe-priority" data-field="ticket_priority_id">
|
||||||
|
${(App.lookups.priorities || []).map(p =>
|
||||||
|
`<option value="${p.id}" ${p.id === ticket.ticket_priority_id ? 'selected' : ''}>${p.name}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="quick-edit-field">
|
||||||
|
<label class="quick-edit-label">Coda</label>
|
||||||
|
<select class="quick-edit-select" id="qe-queue" data-field="queue_id">
|
||||||
|
${(App.lookups.queues || []).map(q =>
|
||||||
|
`<option value="${q.id}" ${q.id === ticket.queue_id ? 'selected' : ''}>${q.name}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="quick-edit-field">
|
||||||
|
<label class="quick-edit-label">Owner</label>
|
||||||
|
<select class="quick-edit-select" id="qe-owner" data-field="user_id">
|
||||||
|
${(App.lookups.users || []).map(u =>
|
||||||
|
`<option value="${u.id}" ${u.id === ticket.user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
${(App.lookups.types || []).length > 0 ? `
|
||||||
|
<div class="quick-edit-field">
|
||||||
|
<label class="quick-edit-label">Tipo</label>
|
||||||
|
<select class="quick-edit-select" id="qe-type" data-field="type_id">
|
||||||
|
<option value="">—</option>
|
||||||
|
${App.lookups.types.map(t =>
|
||||||
|
`<option value="${t.id}" ${t.id === ticket.type_id ? 'selected' : ''}>${t.name}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:var(--space-md);display:flex;gap:var(--space-sm);justify-content:flex-end;">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="qe-reset">Reset</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="qe-save" disabled>Salva Modifiche</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Note Form -->
|
||||||
|
<div class="add-note-form" style="margin-bottom: var(--space-lg);">
|
||||||
|
<div class="card-title">Aggiungi Nota</div>
|
||||||
|
<div style="display:flex; gap:var(--space-md); margin-bottom:var(--space-md);">
|
||||||
|
<input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto (opzionale)" style="flex:1; margin-bottom:0;" />
|
||||||
|
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0;" />
|
||||||
|
</div>
|
||||||
|
<textarea class="note-textarea" id="note-body" placeholder="Scrivi una nota interna..."></textarea>
|
||||||
|
<div style="display:flex;gap:var(--space-sm);justify-content:flex-end;">
|
||||||
|
<button class="btn btn-primary btn-sm" id="note-send">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
||||||
|
Invia Nota
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Articles Timeline -->
|
||||||
|
<div>
|
||||||
|
<div class="card-title" style="margin-bottom:var(--space-md);">Articoli & Note (${articles.length})</div>
|
||||||
|
<div class="articles-timeline">
|
||||||
|
${articles.length > 0 ? articles.map(a => `
|
||||||
|
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
||||||
|
<div class="article-header">
|
||||||
|
<div class="article-sender">
|
||||||
|
<span class="article-sender-badge ${(a.sender_type || 'system').toLowerCase()}">${a.sender_type || 'System'}</span>
|
||||||
|
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
|
||||||
|
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
||||||
|
${a.time_unit ? `<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ ${parseFloat(a.time_unit)} min</span>` : ''}
|
||||||
|
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
|
||||||
|
<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>
|
||||||
|
</div>
|
||||||
|
`).join('') : `
|
||||||
|
<div class="empty-state" style="padding:var(--space-lg);">
|
||||||
|
<div class="empty-state-icon">💬</div>
|
||||||
|
<div class="empty-state-text">Nessun articolo</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="ticket-sidebar">
|
||||||
|
<div class="sidebar-panel">
|
||||||
|
<div class="sidebar-panel-title">Dettagli</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Numero</span>
|
||||||
|
<span class="meta-value" style="font-family:monospace;">${ticket.tn}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Creato</span>
|
||||||
|
<span class="meta-value">${App.formatDateTime(ticket.create_time)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Modificato</span>
|
||||||
|
<span class="meta-value">${App.formatDateTime(ticket.change_time)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Lock</span>
|
||||||
|
<span class="meta-value">${ticket.lock_name || 'unlock'}</span>
|
||||||
|
</div>
|
||||||
|
${ticket.type_name ? `
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Tipo</span>
|
||||||
|
<span class="meta-value">${ticket.type_name}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${ticket.responsible_first ? `
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Responsabile</span>
|
||||||
|
<span class="meta-value">${ticket.responsible_first} ${ticket.responsible_last}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${totalTime > 0 ? `
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Tempo Totale</span>
|
||||||
|
<span class="meta-value" style="font-weight:bold;color:var(--info);">⏱ ${totalTime} min</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${ticket.customer_user_id || ticket.customer_id ? `
|
||||||
|
<div class="sidebar-panel">
|
||||||
|
<div class="sidebar-panel-title">Cliente</div>
|
||||||
|
${ticket.customer_first ? `
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Nome</span>
|
||||||
|
<span class="meta-value">${ticket.customer_first} ${ticket.customer_last}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${ticket.customer_email ? `
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Email</span>
|
||||||
|
<span class="meta-value" style="font-size:0.78rem;">${ticket.customer_email}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${ticket.customer_phone ? `
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Telefono</span>
|
||||||
|
<span class="meta-value">${ticket.customer_phone}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${ticket.customer_id ? `
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Customer ID</span>
|
||||||
|
<span class="meta-value" style="font-size:0.78rem;">${ticket.customer_id}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${ticket.escalation_time > 0 ? `
|
||||||
|
<div class="sidebar-panel" style="border-color: rgba(239,68,68,0.3);">
|
||||||
|
<div class="sidebar-panel-title" style="color:var(--error);">⚠ Escalation</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Tempo</span>
|
||||||
|
<span class="meta-value" style="color:var(--error);">${new Date(ticket.escalation_time * 1000).toLocaleString('it-IT')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-text">Errore caricamento ticket</div>
|
||||||
|
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
||||||
|
<button class="btn btn-ghost" style="margin-top:var(--space-md);" onclick="history.back()">Torna indietro</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Quick-edit change detection
|
||||||
|
const selects = document.querySelectorAll('.quick-edit-select');
|
||||||
|
const saveBtn = document.getElementById('qe-save');
|
||||||
|
const resetBtn = document.getElementById('qe-reset');
|
||||||
|
|
||||||
|
const checkChanges = () => {
|
||||||
|
let hasChanges = false;
|
||||||
|
selects.forEach(sel => {
|
||||||
|
const field = sel.dataset.field;
|
||||||
|
const original = String(this.originalValues[field] || '');
|
||||||
|
const current = sel.value;
|
||||||
|
const changed = current !== original;
|
||||||
|
sel.classList.toggle('changed', changed);
|
||||||
|
if (changed) hasChanges = true;
|
||||||
|
});
|
||||||
|
saveBtn.disabled = !hasChanges;
|
||||||
|
};
|
||||||
|
|
||||||
|
selects.forEach(sel => sel.addEventListener('change', checkChanges));
|
||||||
|
|
||||||
|
// Reset quick-edit
|
||||||
|
resetBtn.addEventListener('click', () => {
|
||||||
|
selects.forEach(sel => {
|
||||||
|
sel.value = this.originalValues[sel.dataset.field] || '';
|
||||||
|
sel.classList.remove('changed');
|
||||||
|
});
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save quick-edit
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const updates = {};
|
||||||
|
selects.forEach(sel => {
|
||||||
|
const field = sel.dataset.field;
|
||||||
|
const val = sel.value ? parseInt(sel.value) : null;
|
||||||
|
if (val !== null && val !== this.originalValues[field]) {
|
||||||
|
updates[field] = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Salvando...';
|
||||||
|
const res = await App.api(`/api/tickets/${this.ticketId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
Toast.success(res.message || 'Ticket aggiornato!');
|
||||||
|
// Refresh the view
|
||||||
|
this.render(this.ticketId);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore: ' + err.message);
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Salva Modifiche';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send note
|
||||||
|
const noteSendBtn = document.getElementById('note-send');
|
||||||
|
noteSendBtn.addEventListener('click', async () => {
|
||||||
|
const body = document.getElementById('note-body').value.trim();
|
||||||
|
const subject = document.getElementById('note-subject').value.trim();
|
||||||
|
const time_unit = document.getElementById('note-time-units').value.trim();
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
Toast.warning('Scrivi qualcosa prima di inviare');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
noteSendBtn.disabled = true;
|
||||||
|
noteSendBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;"></div> Invio...';
|
||||||
|
|
||||||
|
const res = await App.api(`/api/tickets/${this.ticketId}/articles`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ subject, body, time_unit }),
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(res.message || 'Nota aggiunta!');
|
||||||
|
this.render(this.ticketId);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore: ' + err.message);
|
||||||
|
noteSendBtn.disabled = false;
|
||||||
|
noteSendBtn.innerHTML = 'Invia Nota';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Ticket List View
|
||||||
|
* Full-featured ticket list with filters, sorting, batch actions, and pagination.
|
||||||
|
*/
|
||||||
|
const TicketListView = {
|
||||||
|
currentPage: 1,
|
||||||
|
perPage: 50,
|
||||||
|
sortBy: 'create_time',
|
||||||
|
sortDir: 'DESC',
|
||||||
|
selectedIds: new Set(),
|
||||||
|
searchTimeout: null,
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch lookups for filter dropdowns
|
||||||
|
await App.ensureLookups();
|
||||||
|
|
||||||
|
// Build query params
|
||||||
|
const params = Filters.toQueryParams();
|
||||||
|
params.set('page', this.currentPage);
|
||||||
|
params.set('per_page', this.perPage);
|
||||||
|
params.set('sort_by', this.sortBy);
|
||||||
|
params.set('sort_dir', this.sortDir);
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('global-search');
|
||||||
|
if (searchInput && searchInput.value.trim()) {
|
||||||
|
params.set('search', searchInput.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await App.api(`/api/tickets?${params.toString()}`);
|
||||||
|
|
||||||
|
this.renderContent(container, data);
|
||||||
|
this.bindEvents(data);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-text">Errore caricamento ticket</div>
|
||||||
|
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderContent(container, data) {
|
||||||
|
const tickets = data.tickets || [];
|
||||||
|
const { total, page, per_page, total_pages } = data;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
${Filters.renderBar(App.lookups)}
|
||||||
|
|
||||||
|
<!-- Batch Actions Bar -->
|
||||||
|
<div class="batch-bar" id="batch-bar">
|
||||||
|
<span class="batch-count" id="batch-count">0 selezionati</span>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Stato</span>
|
||||||
|
<select class="filter-select" id="batch-state">
|
||||||
|
<option value="">—</option>
|
||||||
|
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Coda</span>
|
||||||
|
<select class="filter-select" id="batch-queue">
|
||||||
|
<option value="">—</option>
|
||||||
|
${(App.lookups.queues || []).map(q => `<option value="${q.id}">${q.name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Owner</span>
|
||||||
|
<select class="filter-select" id="batch-owner">
|
||||||
|
<option value="">—</option>
|
||||||
|
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Table -->
|
||||||
|
<div class="ticket-table-wrapper">
|
||||||
|
<table class="ticket-table" id="ticket-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="checkbox-cell">
|
||||||
|
<input type="checkbox" id="select-all" title="Seleziona tutti" />
|
||||||
|
</th>
|
||||||
|
<th class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
|
||||||
|
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
|
||||||
|
<th class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
|
||||||
|
<th class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
|
||||||
|
<th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
||||||
|
<th class="sortable ${this.sortBy === 'change_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="change_time">Modificato</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${tickets.length > 0 ? tickets.map(t => `
|
||||||
|
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''}">
|
||||||
|
<td class="checkbox-cell" onclick="event.stopPropagation()">
|
||||||
|
<input type="checkbox" class="ticket-checkbox" value="${t.id}" ${this.selectedIds.has(String(t.id)) ? 'checked' : ''} />
|
||||||
|
</td>
|
||||||
|
<td><span class="ticket-tn">${t.tn}</span></td>
|
||||||
|
<td class="ticket-title-cell">${App.escapeHtml(t.title || '(senza titolo)')}</td>
|
||||||
|
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
||||||
|
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${t.priority_name}</span></td>
|
||||||
|
<td><span class="badge badge-queue">${t.queue_name}</span></td>
|
||||||
|
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
|
||||||
|
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDate(t.create_time)}</td>
|
||||||
|
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDate(t.change_time)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('') : `
|
||||||
|
<tr>
|
||||||
|
<td colspan="9">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">📭</div>
|
||||||
|
<div class="empty-state-text">Nessun ticket trovato</div>
|
||||||
|
<div class="empty-state-sub">Prova a cambiare i filtri o crea un nuovo ticket.</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
${total_pages > 1 ? `
|
||||||
|
<div class="pagination">
|
||||||
|
<div class="pagination-info">
|
||||||
|
Mostrando ${((page - 1) * per_page) + 1}–${Math.min(page * per_page, total)} di ${total} ticket
|
||||||
|
</div>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button class="pagination-btn" data-page="1" ${page <= 1 ? 'disabled' : ''}>«</button>
|
||||||
|
<button class="pagination-btn" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>‹</button>
|
||||||
|
${this.renderPageButtons(page, total_pages)}
|
||||||
|
<button class="pagination-btn" data-page="${page + 1}" ${page >= total_pages ? 'disabled' : ''}>›</button>
|
||||||
|
<button class="pagination-btn" data-page="${total_pages}" ${page >= total_pages ? 'disabled' : ''}>»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="pagination">
|
||||||
|
<div class="pagination-info">${total} ticket totali</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPageButtons(current, total) {
|
||||||
|
const pages = [];
|
||||||
|
const start = Math.max(1, current - 2);
|
||||||
|
const end = Math.min(total, current + 2);
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(`<button class="pagination-btn ${i === current ? 'active' : ''}" data-page="${i}">${i}</button>`);
|
||||||
|
}
|
||||||
|
return pages.join('');
|
||||||
|
},
|
||||||
|
|
||||||
|
bindEvents(data) {
|
||||||
|
// Filter events
|
||||||
|
Filters.bindEvents(() => {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.selectedIds.clear();
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort events
|
||||||
|
document.querySelectorAll('.ticket-table th.sortable').forEach(th => {
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
const sortKey = th.dataset.sort;
|
||||||
|
if (this.sortBy === sortKey) {
|
||||||
|
this.sortDir = this.sortDir === 'ASC' ? 'DESC' : 'ASC';
|
||||||
|
} else {
|
||||||
|
this.sortBy = sortKey;
|
||||||
|
this.sortDir = 'DESC';
|
||||||
|
}
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row click → detail
|
||||||
|
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(row => {
|
||||||
|
row.addEventListener('click', (e) => {
|
||||||
|
if (e.target.type === 'checkbox' || e.target.closest('.checkbox-cell')) return;
|
||||||
|
window.location.hash = `#/tickets/${row.dataset.ticketId}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkbox selection
|
||||||
|
const selectAll = document.getElementById('select-all');
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener('change', (e) => {
|
||||||
|
const checkboxes = document.querySelectorAll('.ticket-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = e.target.checked;
|
||||||
|
const id = cb.value;
|
||||||
|
if (e.target.checked) {
|
||||||
|
this.selectedIds.add(id);
|
||||||
|
} else {
|
||||||
|
this.selectedIds.delete(id);
|
||||||
|
}
|
||||||
|
cb.closest('tr').classList.toggle('selected', e.target.checked);
|
||||||
|
});
|
||||||
|
this.updateBatchBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.ticket-checkbox').forEach(cb => {
|
||||||
|
cb.addEventListener('change', (e) => {
|
||||||
|
const id = e.target.value;
|
||||||
|
if (e.target.checked) {
|
||||||
|
this.selectedIds.add(id);
|
||||||
|
} else {
|
||||||
|
this.selectedIds.delete(id);
|
||||||
|
}
|
||||||
|
e.target.closest('tr').classList.toggle('selected', e.target.checked);
|
||||||
|
this.updateBatchBar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch apply
|
||||||
|
const batchApply = document.getElementById('batch-apply');
|
||||||
|
if (batchApply) {
|
||||||
|
batchApply.addEventListener('click', () => this.applyBatch());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch cancel
|
||||||
|
const batchCancel = document.getElementById('batch-cancel');
|
||||||
|
if (batchCancel) {
|
||||||
|
batchCancel.addEventListener('click', () => {
|
||||||
|
this.selectedIds.clear();
|
||||||
|
document.querySelectorAll('.ticket-checkbox').forEach(cb => {
|
||||||
|
cb.checked = false;
|
||||||
|
cb.closest('tr').classList.remove('selected');
|
||||||
|
});
|
||||||
|
const selectAll = document.getElementById('select-all');
|
||||||
|
if (selectAll) selectAll.checked = false;
|
||||||
|
this.updateBatchBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
document.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.currentPage = parseInt(btn.dataset.page);
|
||||||
|
this.selectedIds.clear();
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBatchBar() {
|
||||||
|
const bar = document.getElementById('batch-bar');
|
||||||
|
const count = document.getElementById('batch-count');
|
||||||
|
if (this.selectedIds.size > 0) {
|
||||||
|
bar.classList.add('visible');
|
||||||
|
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
||||||
|
} else {
|
||||||
|
bar.classList.remove('visible');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyBatch() {
|
||||||
|
if (this.selectedIds.size === 0) return;
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
const batchState = document.getElementById('batch-state')?.value;
|
||||||
|
const batchQueue = document.getElementById('batch-queue')?.value;
|
||||||
|
const batchOwner = document.getElementById('batch-owner')?.value;
|
||||||
|
|
||||||
|
if (batchState) updates.ticket_state_id = parseInt(batchState);
|
||||||
|
if (batchQueue) updates.queue_id = parseInt(batchQueue);
|
||||||
|
if (batchOwner) updates.user_id = parseInt(batchOwner);
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
Toast.warning('Seleziona almeno un campo da modificare');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await App.api('/api/tickets/batch/update', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticket_ids: Array.from(this.selectedIds),
|
||||||
|
updates,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Toast.success(res.message || `${this.selectedIds.size} ticket aggiornati`);
|
||||||
|
this.selectedIds.clear();
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore aggiornamento batch: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
// GET /api/dashboard/stats — Dashboard statistics
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// All queries in parallel for speed
|
||||||
|
const [
|
||||||
|
byState,
|
||||||
|
byPriority,
|
||||||
|
byQueue,
|
||||||
|
todayCount,
|
||||||
|
weekCount,
|
||||||
|
totalOpen,
|
||||||
|
recentTickets,
|
||||||
|
escalated,
|
||||||
|
] = await Promise.all([
|
||||||
|
// Tickets by state (only open-ish states)
|
||||||
|
pool.query(
|
||||||
|
`SELECT ts.name AS state, tst.name AS state_type, COUNT(*) AS count
|
||||||
|
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 tst.name IN ('new', 'open', 'pending reminder', 'pending auto')
|
||||||
|
GROUP BY ts.name, tst.name
|
||||||
|
ORDER BY count DESC`
|
||||||
|
),
|
||||||
|
// Tickets by priority (open only)
|
||||||
|
pool.query(
|
||||||
|
`SELECT tp.name AS priority, tp.color, COUNT(*) AS count
|
||||||
|
FROM ticket t
|
||||||
|
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
|
||||||
|
JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')
|
||||||
|
GROUP BY tp.name, tp.color, tp.id
|
||||||
|
ORDER BY tp.id`
|
||||||
|
),
|
||||||
|
// Tickets by queue (open only, top 10)
|
||||||
|
pool.query(
|
||||||
|
`SELECT q.name AS queue, COUNT(*) AS count
|
||||||
|
FROM ticket t
|
||||||
|
JOIN queue q ON t.queue_id = q.id
|
||||||
|
JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')
|
||||||
|
GROUP BY q.name
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10`
|
||||||
|
),
|
||||||
|
// Created today
|
||||||
|
pool.query(
|
||||||
|
`SELECT COUNT(*) AS count FROM ticket
|
||||||
|
WHERE create_time >= CURRENT_DATE`
|
||||||
|
),
|
||||||
|
// Created this week
|
||||||
|
pool.query(
|
||||||
|
`SELECT COUNT(*) AS count FROM ticket
|
||||||
|
WHERE create_time >= date_trunc('week', CURRENT_DATE)`
|
||||||
|
),
|
||||||
|
// Total open
|
||||||
|
pool.query(
|
||||||
|
`SELECT COUNT(*) AS count
|
||||||
|
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 tst.name IN ('new', 'open', 'pending reminder', 'pending auto')`
|
||||||
|
),
|
||||||
|
// 10 most recent tickets
|
||||||
|
pool.query(
|
||||||
|
`SELECT t.id, t.tn, t.title, ts.name AS state_name,
|
||||||
|
tp.name AS priority_name, tp.color AS priority_color,
|
||||||
|
q.name AS queue_name, t.create_time
|
||||||
|
FROM ticket t
|
||||||
|
JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||||
|
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
|
||||||
|
JOIN queue q ON t.queue_id = q.id
|
||||||
|
ORDER BY t.create_time DESC
|
||||||
|
LIMIT 10`
|
||||||
|
),
|
||||||
|
// Escalated tickets
|
||||||
|
pool.query(
|
||||||
|
`SELECT COUNT(*) AS count FROM ticket
|
||||||
|
WHERE escalation_time > 0
|
||||||
|
AND escalation_time < EXTRACT(EPOCH FROM NOW())`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
by_state: byState.rows,
|
||||||
|
by_priority: byPriority.rows,
|
||||||
|
by_queue: byQueue.rows,
|
||||||
|
created_today: parseInt(todayCount.rows[0].count),
|
||||||
|
created_this_week: parseInt(weekCount.rows[0].count),
|
||||||
|
total_open: parseInt(totalOpen.rows[0].count),
|
||||||
|
recent_tickets: recentTickets.rows,
|
||||||
|
escalated: parseInt(escalated.rows[0].count),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching dashboard stats:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
// Helper for OTRS CE GenericInterface REST API calls
|
||||||
|
async function otrsRequest(method, path, bodyData = {}) {
|
||||||
|
const OTRS_API_USER = process.env.OTRS_API_USER;
|
||||||
|
const OTRS_API_PASSWORD = process.env.OTRS_API_PASSWORD;
|
||||||
|
const OTRS_API_URL = process.env.OTRS_API_URL;
|
||||||
|
|
||||||
|
if (!OTRS_API_URL || !OTRS_API_USER) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const url = `${OTRS_API_URL.replace(/\/$/, '')}${path.startsWith('/') ? path : '/' + path}`;
|
||||||
|
const payload = {
|
||||||
|
UserLogin: OTRS_API_USER,
|
||||||
|
Password: OTRS_API_PASSWORD,
|
||||||
|
...bodyData
|
||||||
|
};
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// GET /api/queues — Active queues
|
||||||
|
router.get('/queues', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT q.id, q.name, q.comments
|
||||||
|
FROM queue q
|
||||||
|
WHERE q.valid_id = 1
|
||||||
|
ORDER BY q.name`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching queues:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/states — Ticket states with state type
|
||||||
|
router.get('/states', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT ts.id, ts.name, tst.name AS type_name
|
||||||
|
FROM ticket_state ts
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE ts.valid_id = 1
|
||||||
|
ORDER BY ts.id`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching states:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/priorities — Ticket priorities
|
||||||
|
router.get('/priorities', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, color
|
||||||
|
FROM ticket_priority
|
||||||
|
WHERE valid_id = 1
|
||||||
|
ORDER BY id`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching priorities:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/users — Active agents/operators
|
||||||
|
router.get('/users', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, login, first_name, last_name, title
|
||||||
|
FROM users
|
||||||
|
WHERE valid_id = 1
|
||||||
|
ORDER BY last_name, first_name`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching users:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/types — Ticket types
|
||||||
|
router.get('/types', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name
|
||||||
|
FROM ticket_type
|
||||||
|
WHERE valid_id = 1
|
||||||
|
ORDER BY name`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching types:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/lock-types — Ticket lock types
|
||||||
|
router.get('/lock-types', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name FROM ticket_lock_type WHERE valid_id = 1 ORDER BY id`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching lock types:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/customer-companies/search — Search customer companies
|
||||||
|
router.get('/customer-companies/search', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
if (!q) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
const searchTerm = `%${q}%`;
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT customer_id, name
|
||||||
|
FROM customer_company
|
||||||
|
WHERE valid_id = 1 AND (
|
||||||
|
customer_id ILIKE $1 OR
|
||||||
|
name ILIKE $1
|
||||||
|
)
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 20`,
|
||||||
|
[searchTerm]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching customer companies:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/customer-users/search — Search customer users
|
||||||
|
router.get('/customer-users/search', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q, customer_company_id } = req.query;
|
||||||
|
if (!q) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try OTRS API first if configured
|
||||||
|
const OTRS_API_URL = process.env.OTRS_API_URL;
|
||||||
|
const OTRS_API_USER = process.env.OTRS_API_USER;
|
||||||
|
if (OTRS_API_URL && OTRS_API_USER) {
|
||||||
|
try {
|
||||||
|
const searchParams = {
|
||||||
|
Search: `*${q}*`,
|
||||||
|
Valid: 1
|
||||||
|
};
|
||||||
|
if (customer_company_id) {
|
||||||
|
searchParams.CustomerID = customer_company_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchRes = await otrsRequest('POST', '/CustomerUserSearch', searchParams);
|
||||||
|
let logins = [];
|
||||||
|
if (searchRes) {
|
||||||
|
if (Array.isArray(searchRes.CustomerUserID)) {
|
||||||
|
logins = searchRes.CustomerUserID;
|
||||||
|
} else if (searchRes.Data && Array.isArray(searchRes.Data.CustomerUserID)) {
|
||||||
|
logins = searchRes.Data.CustomerUserID;
|
||||||
|
} else if (Array.isArray(searchRes)) {
|
||||||
|
logins = searchRes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logins.length > 0) {
|
||||||
|
// Limit to top 20 logins to avoid rate/performance issues
|
||||||
|
const limitedLogins = logins.slice(0, 20);
|
||||||
|
const detailPromises = limitedLogins.map(async (login) => {
|
||||||
|
try {
|
||||||
|
const detailRes = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: login });
|
||||||
|
const userObj = detailRes?.CustomerUser;
|
||||||
|
if (userObj) {
|
||||||
|
return {
|
||||||
|
login: userObj.UserLogin || login,
|
||||||
|
email: userObj.UserEmail || '',
|
||||||
|
first_name: userObj.UserFirstname || '',
|
||||||
|
last_name: userObj.UserLastname || '',
|
||||||
|
customer_id: userObj.UserCustomerID || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching details for user ${login}:`, err.message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const details = await Promise.all(detailPromises);
|
||||||
|
const validUsers = details.filter(u => u !== null);
|
||||||
|
if (validUsers.length > 0) {
|
||||||
|
return res.json(validUsers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (apiErr) {
|
||||||
|
console.warn('OTRS CustomerUserSearch API request failed, falling back to local DB:', apiErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: local DB query
|
||||||
|
const searchTerm = `%${q}%`;
|
||||||
|
let queryText = `
|
||||||
|
SELECT login, email, first_name, last_name, customer_id
|
||||||
|
FROM customer_user
|
||||||
|
WHERE valid_id = 1 AND (
|
||||||
|
login ILIKE $1 OR
|
||||||
|
email ILIKE $1 OR
|
||||||
|
first_name ILIKE $1 OR
|
||||||
|
last_name ILIKE $1
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const queryParams = [searchTerm];
|
||||||
|
|
||||||
|
if (customer_company_id) {
|
||||||
|
queryText += ` AND customer_id = $2`;
|
||||||
|
queryParams.push(customer_company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryText += ` ORDER BY last_name, first_name LIMIT 20`;
|
||||||
|
|
||||||
|
const result = await pool.query(queryText, queryParams);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching customer users:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/agents/search — Search active agents
|
||||||
|
router.get('/agents/search', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
const searchTerm = q ? `%${q}%` : '%';
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, login, first_name, last_name
|
||||||
|
FROM users
|
||||||
|
WHERE valid_id = 1 AND (
|
||||||
|
login ILIKE $1 OR
|
||||||
|
first_name ILIKE $1 OR
|
||||||
|
last_name ILIKE $1
|
||||||
|
)
|
||||||
|
ORDER BY last_name, first_name
|
||||||
|
LIMIT 20`,
|
||||||
|
[searchTerm]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching agents:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/queues/search — Search active queues
|
||||||
|
router.get('/queues/search', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
const searchTerm = q ? `%${q}%` : '%';
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name
|
||||||
|
FROM queue
|
||||||
|
WHERE valid_id = 1 AND name ILIKE $1
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 20`,
|
||||||
|
[searchTerm]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching queues:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/states/search — Search active ticket states
|
||||||
|
router.get('/states/search', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
const searchTerm = q ? `%${q}%` : '%';
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name
|
||||||
|
FROM ticket_state
|
||||||
|
WHERE valid_id = 1 AND name ILIKE $1
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 20`,
|
||||||
|
[searchTerm]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching states:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,868 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
// Helper for OTRS CE GenericInterface REST API calls
|
||||||
|
async function otrsRequest(method, path, bodyData = {}) {
|
||||||
|
const OTRS_API_USER = process.env.OTRS_API_USER;
|
||||||
|
const OTRS_API_PASSWORD = process.env.OTRS_API_PASSWORD;
|
||||||
|
const OTRS_API_URL = process.env.OTRS_API_URL;
|
||||||
|
|
||||||
|
if (!OTRS_API_URL || !OTRS_API_USER) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const url = `${OTRS_API_URL}${path}`;
|
||||||
|
const payload = {
|
||||||
|
UserLogin: OTRS_API_USER,
|
||||||
|
Password: OTRS_API_PASSWORD,
|
||||||
|
...bodyData
|
||||||
|
};
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/tickets — List tickets with filters & pagination
|
||||||
|
// ============================================================
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
queue_id, state_id, priority_id, user_id, type_id,
|
||||||
|
search, sort_by = 'create_time', sort_dir = 'DESC',
|
||||||
|
page = 1, per_page = 50
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
let paramIdx = 1;
|
||||||
|
|
||||||
|
if (queue_id) {
|
||||||
|
conditions.push(`t.queue_id = $${paramIdx++}`);
|
||||||
|
params.push(parseInt(queue_id));
|
||||||
|
}
|
||||||
|
if (state_id) {
|
||||||
|
conditions.push(`t.ticket_state_id = $${paramIdx++}`);
|
||||||
|
params.push(parseInt(state_id));
|
||||||
|
}
|
||||||
|
if (priority_id) {
|
||||||
|
conditions.push(`t.ticket_priority_id = $${paramIdx++}`);
|
||||||
|
params.push(parseInt(priority_id));
|
||||||
|
}
|
||||||
|
if (user_id) {
|
||||||
|
conditions.push(`t.user_id = $${paramIdx++}`);
|
||||||
|
params.push(parseInt(user_id));
|
||||||
|
}
|
||||||
|
if (type_id) {
|
||||||
|
conditions.push(`t.type_id = $${paramIdx++}`);
|
||||||
|
params.push(parseInt(type_id));
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
conditions.push(`(t.title ILIKE $${paramIdx} OR t.tn ILIKE $${paramIdx})`);
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0
|
||||||
|
? 'WHERE ' + conditions.join(' AND ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Whitelist sortable columns
|
||||||
|
const sortableColumns = {
|
||||||
|
create_time: 't.create_time',
|
||||||
|
change_time: 't.change_time',
|
||||||
|
title: 't.title',
|
||||||
|
tn: 't.tn',
|
||||||
|
priority: 't.ticket_priority_id',
|
||||||
|
state: 't.ticket_state_id',
|
||||||
|
queue: 'q.name',
|
||||||
|
};
|
||||||
|
const sortColumn = sortableColumns[sort_by] || 't.create_time';
|
||||||
|
const sortDirection = sort_dir.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
const offset = (parseInt(page) - 1) * parseInt(per_page);
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`SELECT COUNT(*) as total FROM ticket t
|
||||||
|
JOIN queue q ON t.queue_id = q.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = parseInt(countResult.rows[0].total);
|
||||||
|
|
||||||
|
// Fetch tickets
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
t.id, t.tn, t.title,
|
||||||
|
t.queue_id, q.name AS queue_name,
|
||||||
|
t.ticket_state_id, ts.name AS state_name, tst.name AS state_type,
|
||||||
|
t.ticket_priority_id, tp.name AS priority_name, tp.color AS priority_color,
|
||||||
|
t.user_id, u.first_name AS owner_first, u.last_name AS owner_last,
|
||||||
|
t.type_id, tt.name AS type_name,
|
||||||
|
t.customer_id, t.customer_user_id,
|
||||||
|
t.ticket_lock_id,
|
||||||
|
t.create_time, t.change_time,
|
||||||
|
t.escalation_time
|
||||||
|
FROM ticket t
|
||||||
|
JOIN queue q ON t.queue_id = q.id
|
||||||
|
JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
|
||||||
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
|
LEFT JOIN ticket_type tt ON t.type_id = tt.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY ${sortColumn} ${sortDirection}
|
||||||
|
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
|
||||||
|
[...params, parseInt(per_page), offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
tickets: result.rows,
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
per_page: parseInt(per_page),
|
||||||
|
total_pages: Math.ceil(total / parseInt(per_page)),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching tickets:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/tickets/:id — Single ticket detail
|
||||||
|
// ============================================================
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const ticketResult = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
t.id, t.tn, t.title,
|
||||||
|
t.queue_id, q.name AS queue_name,
|
||||||
|
t.ticket_state_id, ts.name AS state_name, tst.name AS state_type,
|
||||||
|
t.ticket_priority_id, tp.name AS priority_name, tp.color AS priority_color,
|
||||||
|
t.user_id, u.first_name AS owner_first, u.last_name AS owner_last, u.login AS owner_login,
|
||||||
|
t.responsible_user_id,
|
||||||
|
ru.first_name AS responsible_first, ru.last_name AS responsible_last,
|
||||||
|
t.type_id, tt.name AS type_name,
|
||||||
|
t.ticket_lock_id, tlt.name AS lock_name,
|
||||||
|
t.customer_id, t.customer_user_id,
|
||||||
|
t.service_id, t.sla_id,
|
||||||
|
t.escalation_time, t.escalation_update_time,
|
||||||
|
t.escalation_response_time, t.escalation_solution_time,
|
||||||
|
t.create_time, t.change_time,
|
||||||
|
cu.first_name AS customer_first, cu.last_name AS customer_last,
|
||||||
|
cu.email AS customer_email, cu.phone AS customer_phone
|
||||||
|
FROM ticket t
|
||||||
|
JOIN queue q ON t.queue_id = q.id
|
||||||
|
JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
|
||||||
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
|
LEFT JOIN users ru ON t.responsible_user_id = ru.id
|
||||||
|
LEFT JOIN ticket_type tt ON t.type_id = tt.id
|
||||||
|
LEFT JOIN ticket_lock_type tlt ON t.ticket_lock_id = tlt.id
|
||||||
|
LEFT JOIN customer_user cu ON t.customer_user_id = cu.login
|
||||||
|
WHERE t.id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ticketResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch articles
|
||||||
|
const articlesResult = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
a.id AS article_id,
|
||||||
|
a.ticket_id,
|
||||||
|
a.is_visible_for_customer,
|
||||||
|
ast.name AS sender_type,
|
||||||
|
cc.name AS channel_name,
|
||||||
|
adm.a_from, adm.a_to, adm.a_cc, adm.a_subject, adm.a_body,
|
||||||
|
adm.a_content_type, adm.incoming_time,
|
||||||
|
a.create_time,
|
||||||
|
creator.first_name AS creator_first, creator.last_name AS creator_last,
|
||||||
|
ta.time_unit
|
||||||
|
FROM article a
|
||||||
|
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
|
||||||
|
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
||||||
|
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
||||||
|
LEFT JOIN users creator ON a.create_by = creator.id
|
||||||
|
LEFT JOIN time_accounting ta ON a.id = ta.article_id
|
||||||
|
WHERE a.ticket_id = $1
|
||||||
|
ORDER BY a.create_time ASC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ticket: ticketResult.rows[0],
|
||||||
|
articles: articlesResult.rows,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching ticket detail:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/tickets — Create new ticket
|
||||||
|
// ============================================================
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title, queue_id, state_id, priority_id, type_id,
|
||||||
|
user_id, customer_id, customer_user_id, body, subject,
|
||||||
|
responsible_user_id
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate ticket number: get next counter value
|
||||||
|
const counterResult = await client.query(
|
||||||
|
`INSERT INTO ticket_number_counter (counter, counter_uid, create_time)
|
||||||
|
VALUES (
|
||||||
|
COALESCE((SELECT MAX(counter) FROM ticket_number_counter), 0) + 1,
|
||||||
|
md5(random()::text || clock_timestamp()::text),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
RETURNING counter`
|
||||||
|
);
|
||||||
|
const counter = counterResult.rows[0].counter;
|
||||||
|
const now = new Date();
|
||||||
|
const tn = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${String(counter).padStart(10, '0')}`;
|
||||||
|
|
||||||
|
// Determine lock type (1 = unlock by default)
|
||||||
|
const lockId = 1;
|
||||||
|
// Default responsible user = responsible_user_id or user_id or 1 (admin)
|
||||||
|
const responsibleUserId = responsible_user_id || user_id || 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 ticketResult = await client.query(
|
||||||
|
`INSERT INTO ticket (
|
||||||
|
tn, title, queue_id, ticket_lock_id, type_id,
|
||||||
|
user_id, responsible_user_id,
|
||||||
|
ticket_priority_id, ticket_state_id,
|
||||||
|
customer_id, customer_user_id,
|
||||||
|
timeout, until_time,
|
||||||
|
escalation_time, escalation_update_time,
|
||||||
|
escalation_response_time, escalation_solution_time,
|
||||||
|
archive_flag,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5,
|
||||||
|
$6, $7,
|
||||||
|
$8, $9,
|
||||||
|
$10, $11,
|
||||||
|
0, 0,
|
||||||
|
0, 0,
|
||||||
|
0, 0,
|
||||||
|
0,
|
||||||
|
NOW(), $12, NOW(), $12
|
||||||
|
) 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
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ticketId = ticketResult.rows[0].id;
|
||||||
|
|
||||||
|
// Get the history type ID for "NewTicket"
|
||||||
|
const htResult = await client.query(
|
||||||
|
`SELECT id FROM ticket_history_type WHERE name = 'NewTicket'`
|
||||||
|
);
|
||||||
|
const historyTypeId = htResult.rows.length > 0 ? htResult.rows[0].id : 1;
|
||||||
|
|
||||||
|
// Insert ticket history
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ticket_history (
|
||||||
|
name, history_type_id, ticket_id, type_id, queue_id,
|
||||||
|
owner_id, priority_id, state_id,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5,
|
||||||
|
$6, $7, $8,
|
||||||
|
NOW(), $9, NOW(), $9
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
`%%`,
|
||||||
|
historyTypeId, ticketId, type_id || 1, queue_id,
|
||||||
|
user_id || 1, priority_id, state_id,
|
||||||
|
operatorId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create initial article if body is provided
|
||||||
|
if (body) {
|
||||||
|
// Get sender type ID for "agent"
|
||||||
|
const senderResult = await client.query(
|
||||||
|
`SELECT id FROM article_sender_type WHERE name = 'agent'`
|
||||||
|
);
|
||||||
|
const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1;
|
||||||
|
|
||||||
|
// Get communication channel ID for "Internal"
|
||||||
|
const channelResult = await client.query(
|
||||||
|
`SELECT id FROM communication_channel WHERE name = 'Internal'`
|
||||||
|
);
|
||||||
|
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
|
||||||
|
|
||||||
|
const articleResult = await client.query(
|
||||||
|
`INSERT INTO article (
|
||||||
|
ticket_id, article_sender_type_id, communication_channel_id,
|
||||||
|
is_visible_for_customer, search_index_needs_rebuild,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, 0, 1, NOW(), $4, NOW(), $4
|
||||||
|
) RETURNING id`,
|
||||||
|
[ticketId, senderTypeId, channelId, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const articleId = articleResult.rows[0].id;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO article_data_mime (
|
||||||
|
article_id, a_from, a_to, a_subject, a_body,
|
||||||
|
a_content_type, incoming_time,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, '', $3, $4,
|
||||||
|
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER,
|
||||||
|
NOW(), $5, NOW(), $5
|
||||||
|
)`,
|
||||||
|
[articleId, 'OTRS Turbo Agent', subject || title, body, operatorId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: ticketId,
|
||||||
|
tn: ticketResult.rows[0].tn,
|
||||||
|
message: 'Ticket created successfully',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error creating ticket:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PATCH /api/tickets/:id — Quick-edit ticket fields
|
||||||
|
// ============================================================
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body; // { ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title }
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
|
// 1. Try to update via REST API if configured
|
||||||
|
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
|
||||||
|
try {
|
||||||
|
const ticketFields = {};
|
||||||
|
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.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
||||||
|
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||||
|
if (updates.title !== undefined) ticketFields.Title = updates.title;
|
||||||
|
if (updates.ticket_lock_id !== undefined) ticketFields.LockID = updates.ticket_lock_id;
|
||||||
|
|
||||||
|
// Auto sblocco check
|
||||||
|
if (updates.ticket_state_id) {
|
||||||
|
const stateTypeRes = await pool.query(
|
||||||
|
`SELECT tst.name AS type_name
|
||||||
|
FROM ticket_state ts
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE ts.id = $1`,
|
||||||
|
[updates.ticket_state_id]
|
||||||
|
);
|
||||||
|
if (stateTypeRes.rows.length > 0) {
|
||||||
|
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
|
||||||
|
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
|
||||||
|
ticketFields.LockID = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(ticketFields).length > 0) {
|
||||||
|
const result = await otrsRequest('PATCH', `/Ticket/${id}`, {
|
||||||
|
Ticket: ticketFields
|
||||||
|
});
|
||||||
|
return res.json({ message: 'Ticket aggiornato! (via API REST)', result });
|
||||||
|
}
|
||||||
|
return res.json({ message: 'Nessuna modifica rilevata' });
|
||||||
|
} catch (restErr) {
|
||||||
|
console.warn('Failed to update ticket via REST API, falling back to database update:', restErr.message);
|
||||||
|
// Fall through to standard direct database update below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Direct database update fallback
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body; // { ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title }
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Fetch current ticket for history comparison
|
||||||
|
const currentResult = await client.query(
|
||||||
|
`SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title, ticket_lock_id
|
||||||
|
FROM ticket WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (currentResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
const current = currentResult.rows[0];
|
||||||
|
|
||||||
|
// If state is changing, check if the target state is a closed state type to auto-unlock
|
||||||
|
if (updates.ticket_state_id && updates.ticket_state_id !== current.ticket_state_id) {
|
||||||
|
const stateTypeRes = await client.query(
|
||||||
|
`SELECT tst.name AS type_name
|
||||||
|
FROM ticket_state ts
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE ts.id = $1`,
|
||||||
|
[updates.ticket_state_id]
|
||||||
|
);
|
||||||
|
if (stateTypeRes.rows.length > 0) {
|
||||||
|
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
|
||||||
|
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
|
||||||
|
updates.ticket_lock_id = 1; // 1 = unlock in OTRS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic UPDATE
|
||||||
|
const setClauses = [];
|
||||||
|
const setParams = [];
|
||||||
|
let pIdx = 1;
|
||||||
|
|
||||||
|
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id', 'type_id', 'title', 'ticket_lock_id'];
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (updates[field] !== undefined && updates[field] !== current[field]) {
|
||||||
|
setClauses.push(`${field} = $${pIdx++}`);
|
||||||
|
setParams.push(updates[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.json({ message: 'No changes detected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update change_time and change_by
|
||||||
|
setClauses.push(`change_time = NOW()`);
|
||||||
|
setClauses.push(`change_by = $${pIdx++}`);
|
||||||
|
setParams.push(operatorId);
|
||||||
|
setParams.push(parseInt(id));
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET ${setClauses.join(', ')} WHERE id = $${pIdx}`,
|
||||||
|
setParams
|
||||||
|
);
|
||||||
|
|
||||||
|
// Record history entries for each changed field
|
||||||
|
const historyTypeMap = {
|
||||||
|
ticket_state_id: 'StateUpdate',
|
||||||
|
ticket_priority_id: 'PriorityUpdate',
|
||||||
|
queue_id: 'Move',
|
||||||
|
user_id: 'OwnerUpdate',
|
||||||
|
type_id: 'TypeUpdate',
|
||||||
|
ticket_lock_id: 'Lock',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (updates[field] !== undefined && updates[field] !== current[field]) {
|
||||||
|
const historyTypeName = historyTypeMap[field];
|
||||||
|
if (!historyTypeName) continue;
|
||||||
|
|
||||||
|
const htResult = await client.query(
|
||||||
|
`SELECT id FROM ticket_history_type WHERE name = $1`,
|
||||||
|
[historyTypeName]
|
||||||
|
);
|
||||||
|
if (htResult.rows.length === 0) continue;
|
||||||
|
|
||||||
|
const newStateId = updates.ticket_state_id || current.ticket_state_id;
|
||||||
|
const newPriorityId = updates.ticket_priority_id || current.ticket_priority_id;
|
||||||
|
const newQueueId = updates.queue_id || current.queue_id;
|
||||||
|
const newOwnerId = updates.user_id || current.user_id;
|
||||||
|
const newTypeId = updates.type_id || current.type_id || 1;
|
||||||
|
|
||||||
|
let historyName = '%%';
|
||||||
|
if (field === 'ticket_lock_id') {
|
||||||
|
historyName = updates[field] === 1 ? '%%unlock' : '%%lock';
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ticket_history (
|
||||||
|
name, history_type_id, ticket_id, type_id, queue_id,
|
||||||
|
owner_id, priority_id, state_id,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5,
|
||||||
|
$6, $7, $8,
|
||||||
|
NOW(), $9, NOW(), $9
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
historyName,
|
||||||
|
htResult.rows[0].id, parseInt(id), newTypeId, newQueueId,
|
||||||
|
newOwnerId, newPriorityId, newStateId,
|
||||||
|
operatorId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ message: 'Ticket aggiornato! (via DB)' });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error updating ticket:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/tickets/:id/articles — Articles for a ticket
|
||||||
|
// ============================================================
|
||||||
|
router.get('/:id/articles', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
a.id AS article_id,
|
||||||
|
a.is_visible_for_customer,
|
||||||
|
ast.name AS sender_type,
|
||||||
|
cc.name AS channel_name,
|
||||||
|
adm.a_from, adm.a_to, adm.a_cc, adm.a_subject, adm.a_body,
|
||||||
|
adm.a_content_type, adm.incoming_time,
|
||||||
|
a.create_time,
|
||||||
|
creator.first_name AS creator_first, creator.last_name AS creator_last,
|
||||||
|
ta.time_unit
|
||||||
|
FROM article a
|
||||||
|
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
|
||||||
|
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
||||||
|
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
||||||
|
LEFT JOIN users creator ON a.create_by = creator.id
|
||||||
|
LEFT JOIN time_accounting ta ON a.id = ta.article_id
|
||||||
|
WHERE a.ticket_id = $1
|
||||||
|
ORDER BY a.create_time ASC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching articles:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/tickets/:id/articles — Add internal note
|
||||||
|
// ============================================================
|
||||||
|
router.post('/:id/articles', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { subject, body, is_visible_for_customer = 0, time_unit } = req.body;
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
|
// 1. Try to add note via REST API if configured
|
||||||
|
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
Article: {
|
||||||
|
CommunicationChannel: 'Internal',
|
||||||
|
SenderType: 'agent',
|
||||||
|
IsVisibleForCustomer: is_visible_for_customer ? '1' : '0',
|
||||||
|
Subject: subject || 'Nota interna',
|
||||||
|
Body: body,
|
||||||
|
ContentType: 'text/plain; charset=utf8',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (time_unit) {
|
||||||
|
payload.Article.TimeUnit = parseFloat(time_unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await otrsRequest('PATCH', `/Ticket/${id}`, payload);
|
||||||
|
return res.status(201).json({
|
||||||
|
message: 'Nota aggiunta! (via API REST)',
|
||||||
|
article_id: result.ArticleID,
|
||||||
|
result
|
||||||
|
});
|
||||||
|
} catch (restErr) {
|
||||||
|
console.warn('Failed to add article via REST API, falling back to database insert:', restErr.message);
|
||||||
|
// Fall through to standard direct database update below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Direct database update fallback
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { subject, body, is_visible_for_customer = 0, time_unit } = req.body;
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Verify ticket exists
|
||||||
|
const ticketCheck = await client.query('SELECT id FROM ticket WHERE id = $1', [id]);
|
||||||
|
if (ticketCheck.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sender type for "agent"
|
||||||
|
const senderResult = await client.query(
|
||||||
|
`SELECT id FROM article_sender_type WHERE name = 'agent'`
|
||||||
|
);
|
||||||
|
const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1;
|
||||||
|
|
||||||
|
// Get channel for "Internal"
|
||||||
|
const channelResult = await client.query(
|
||||||
|
`SELECT id FROM communication_channel WHERE name = 'Internal'`
|
||||||
|
);
|
||||||
|
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
|
||||||
|
|
||||||
|
// Create article
|
||||||
|
const articleResult = await client.query(
|
||||||
|
`INSERT INTO article (
|
||||||
|
ticket_id, article_sender_type_id, communication_channel_id,
|
||||||
|
is_visible_for_customer, search_index_needs_rebuild,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, 1, NOW(), $5, NOW(), $5
|
||||||
|
) RETURNING id`,
|
||||||
|
[id, senderTypeId, channelId, is_visible_for_customer ? 1 : 0, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const articleId = articleResult.rows[0].id;
|
||||||
|
|
||||||
|
// Calculate date path for OTRS CE compatibility
|
||||||
|
const now = new Date();
|
||||||
|
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Create article_data_mime
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO article_data_mime (
|
||||||
|
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
|
||||||
|
a_message_id, a_in_reply_to, a_references,
|
||||||
|
a_content_type, incoming_time, content_path,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, '', '', '', '', $3, $4,
|
||||||
|
'', '', '',
|
||||||
|
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
||||||
|
NOW(), $6, NOW(), $6
|
||||||
|
)`,
|
||||||
|
[articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentPath, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and raw Buffer)
|
||||||
|
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${body}</body></html>`;
|
||||||
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
||||||
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`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, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
|
||||||
|
NOW(), $4, NOW(), $4
|
||||||
|
)`,
|
||||||
|
[articleId, String(contentSize), binaryBody, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// If time_unit is provided, insert into time_accounting
|
||||||
|
if (time_unit !== undefined && time_unit !== null && time_unit !== '') {
|
||||||
|
const parsedTime = parseFloat(time_unit);
|
||||||
|
if (!isNaN(parsedTime) && parsedTime > 0) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO time_accounting (
|
||||||
|
ticket_id, article_id, time_unit,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
|
||||||
|
[id, articleId, parsedTime, operatorId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ticket change_time
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET change_time = NOW(), change_by = $1 WHERE id = $2`,
|
||||||
|
[operatorId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add history entry
|
||||||
|
const htResult = await client.query(
|
||||||
|
`SELECT id FROM ticket_history_type WHERE name = 'AddNote'`
|
||||||
|
);
|
||||||
|
if (htResult.rows.length > 0) {
|
||||||
|
const ticketData = await client.query(
|
||||||
|
`SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, type_id
|
||||||
|
FROM ticket WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const t = ticketData.rows[0];
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ticket_history (
|
||||||
|
name, history_type_id, ticket_id, article_id, type_id, queue_id,
|
||||||
|
owner_id, priority_id, state_id,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
$7, $8, $9,
|
||||||
|
NOW(), $10, NOW(), $10
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
`%%`,
|
||||||
|
htResult.rows[0].id, id, articleId, t.type_id || 1, t.queue_id,
|
||||||
|
t.user_id, t.ticket_priority_id, t.ticket_state_id,
|
||||||
|
operatorId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
article_id: articleId,
|
||||||
|
message: 'Nota aggiunta! (via DB)',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error adding article:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PATCH /api/tickets/batch — Batch update multiple tickets
|
||||||
|
// ============================================================
|
||||||
|
router.patch('/batch/update', async (req, res) => {
|
||||||
|
const { ticket_ids, updates } = req.body;
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
|
if (!ticket_ids || !Array.isArray(ticket_ids) || ticket_ids.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'ticket_ids array required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Try to update via REST API if configured
|
||||||
|
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
|
||||||
|
try {
|
||||||
|
const ticketFields = {};
|
||||||
|
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.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
||||||
|
|
||||||
|
// Auto sblocco check
|
||||||
|
if (updates.ticket_state_id) {
|
||||||
|
const stateTypeRes = await pool.query(
|
||||||
|
`SELECT tst.name AS type_name
|
||||||
|
FROM ticket_state ts
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE ts.id = $1`,
|
||||||
|
[updates.ticket_state_id]
|
||||||
|
);
|
||||||
|
if (stateTypeRes.rows.length > 0) {
|
||||||
|
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
|
||||||
|
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
|
||||||
|
ticketFields.LockID = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(ticketFields).length > 0) {
|
||||||
|
for (const ticketId of ticket_ids) {
|
||||||
|
await otrsRequest('PATCH', `/Ticket/${ticketId}`, {
|
||||||
|
Ticket: ticketFields
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json({
|
||||||
|
message: `${ticket_ids.length} ticket aggiornati! (via API REST)`,
|
||||||
|
updated_count: ticket_ids.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: 'No valid update fields provided' });
|
||||||
|
} catch (restErr) {
|
||||||
|
console.warn('Failed to batch update tickets via REST API, falling back to database update:', restErr.message);
|
||||||
|
// Fall through to standard direct database update below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Direct database update fallback
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id'];
|
||||||
|
const setClauses = [];
|
||||||
|
const setParams = [];
|
||||||
|
let pIdx = 1;
|
||||||
|
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (updates[field] !== undefined) {
|
||||||
|
setClauses.push(`${field} = $${pIdx++}`);
|
||||||
|
setParams.push(updates[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: 'No valid update fields provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setClauses.push(`change_time = NOW()`);
|
||||||
|
setClauses.push(`change_by = $${pIdx++}`);
|
||||||
|
setParams.push(operatorId);
|
||||||
|
|
||||||
|
// Build IN clause for ticket IDs
|
||||||
|
const idPlaceholders = ticket_ids.map((_, i) => `$${pIdx + i}`).join(', ');
|
||||||
|
setParams.push(...ticket_ids.map(id => parseInt(id)));
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET ${setClauses.join(', ')} WHERE id IN (${idPlaceholders})`,
|
||||||
|
setParams
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `${ticket_ids.length} ticket aggiornati! (via DB)`,
|
||||||
|
updated_count: ticket_ids.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error batch updating tickets:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ticketsRouter = require('./routes/tickets');
|
||||||
|
const lookupsRouter = require('./routes/lookups');
|
||||||
|
const dashboardRouter = require('./routes/dashboard');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Serve static frontend
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/api/tickets', ticketsRouter);
|
||||||
|
app.use('/api', lookupsRouter);
|
||||||
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
|
|
||||||
|
// SPA fallback — serve index.html for all non-API routes
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
if (!req.path.startsWith('/api')) {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Server error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', message: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`\n ⚡ OTRS Turbo running at http://localhost:${PORT}\n`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user