From 457c3eacf6493abebdacd8a25deffa14a5806bf3 Mon Sep 17 00:00:00 2001 From: Gabriele Cimaschi Date: Sun, 5 Jul 2026 10:55:40 +0200 Subject: [PATCH] Prima importazione --- .env.example | 19 + .gitignore | 2 + GenericTicketConnectorREST.yml | 109 ++ README.md | 49 + db.js | 167 ++ .../CustomerUserGenericInterface.xml | 25 + otrs-backend-modules/CustomerUserGet.pm | 53 + otrs-backend-modules/CustomerUserSearch.pm | 48 + package-lock.json | 1153 ++++++++++++ package.json | 23 + public/css/style.css | 1588 +++++++++++++++++ public/index.html | 110 ++ public/js/app.js | 244 +++ public/js/components/filters.js | 120 ++ public/js/components/toast.js | 42 + public/js/views/dashboard.js | 151 ++ public/js/views/ticketCreate.js | 510 ++++++ public/js/views/ticketDetail.js | 342 ++++ public/js/views/ticketList.js | 303 ++++ routes/dashboard.js | 106 ++ routes/lookups.js | 312 ++++ routes/tickets.js | 868 +++++++++ server.js | 40 + 23 files changed, 6384 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 GenericTicketConnectorREST.yml create mode 100644 README.md create mode 100644 db.js create mode 100644 otrs-backend-modules/CustomerUserGenericInterface.xml create mode 100644 otrs-backend-modules/CustomerUserGet.pm create mode 100644 otrs-backend-modules/CustomerUserSearch.pm create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/css/style.css create mode 100644 public/index.html create mode 100644 public/js/app.js create mode 100644 public/js/components/filters.js create mode 100644 public/js/components/toast.js create mode 100644 public/js/views/dashboard.js create mode 100644 public/js/views/ticketCreate.js create mode 100644 public/js/views/ticketDetail.js create mode 100644 public/js/views/ticketList.js create mode 100644 routes/dashboard.js create mode 100644 routes/lookups.js create mode 100644 routes/tickets.js create mode 100644 server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0b6768 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/GenericTicketConnectorREST.yml b/GenericTicketConnectorREST.yml new file mode 100644 index 0000000..5d78452 --- /dev/null +++ b/GenericTicketConnectorREST.yml @@ -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: '' diff --git a/README.md b/README.md new file mode 100644 index 0000000..d297d86 --- /dev/null +++ b/README.md @@ -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. diff --git a/db.js b/db.js new file mode 100644 index 0000000..1b2c9b1 --- /dev/null +++ b/db.js @@ -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; diff --git a/otrs-backend-modules/CustomerUserGenericInterface.xml b/otrs-backend-modules/CustomerUserGenericInterface.xml new file mode 100644 index 0000000..9253f58 --- /dev/null +++ b/otrs-backend-modules/CustomerUserGenericInterface.xml @@ -0,0 +1,25 @@ + + + + Registration for CustomerUserSearch operation. + GenericInterface::Operation::ModuleRegistration + + + CustomerUserSearch + CustomerUser + AdminGenericInterfaceOperationDefault + + + + + Registration for CustomerUserGet operation. + GenericInterface::Operation::ModuleRegistration + + + CustomerUserGet + CustomerUser + AdminGenericInterfaceOperationDefault + + + + diff --git a/otrs-backend-modules/CustomerUserGet.pm b/otrs-backend-modules/CustomerUserGet.pm new file mode 100644 index 0000000..902a3d8 --- /dev/null +++ b/otrs-backend-modules/CustomerUserGet.pm @@ -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; diff --git a/otrs-backend-modules/CustomerUserSearch.pm b/otrs-backend-modules/CustomerUserSearch.pm new file mode 100644 index 0000000..cd7c525 --- /dev/null +++ b/otrs-backend-modules/CustomerUserSearch.pm @@ -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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8562f07 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1153 @@ +{ + "name": "otrs-turbo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "otrs-turbo", + "version": "1.0.0", + "license": "AGPL-3.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "mysql2": "^3.22.5", + "pg": "^8.13.0" + } + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz", + "integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..44b2c2e --- /dev/null +++ b/package.json @@ -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" +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..bc5a5ef --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,1588 @@ +/* ============================================================ + OTRS Turbo — Design System + Premium dark theme with glassmorphism and micro-animations + ============================================================ */ + +/* ---- CSS Variables / Design Tokens ---- */ +:root { + /* Base colors */ + --bg-primary: #0b0e14; + --bg-secondary: #111827; + --bg-tertiary: #1a2234; + --bg-card: rgba(17, 24, 39, 0.7); + --bg-card-hover: rgba(26, 34, 52, 0.85); + --bg-glass: rgba(17, 24, 39, 0.55); + + /* Accent */ + --accent-primary: #6366f1; + --accent-primary-hover: #818cf8; + --accent-primary-glow: rgba(99, 102, 241, 0.3); + --accent-secondary: #8b5cf6; + + /* Text */ + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + --text-muted: #475569; + + /* Borders */ + --border-subtle: rgba(148, 163, 184, 0.08); + --border-light: rgba(148, 163, 184, 0.15); + --border-accent: rgba(99, 102, 241, 0.3); + + /* Priority colors */ + --priority-1-bg: rgba(30, 58, 95, 0.5); + --priority-1-text: #60a5fa; + --priority-2-bg: rgba(30, 70, 50, 0.5); + --priority-2-text: #4ade80; + --priority-3-bg: rgba(80, 65, 20, 0.5); + --priority-3-text: #facc15; + --priority-4-bg: rgba(100, 50, 15, 0.5); + --priority-4-text: #fb923c; + --priority-5-bg: rgba(100, 20, 20, 0.5); + --priority-5-text: #f87171; + + /* State colors */ + --state-new: #38bdf8; + --state-open: #6366f1; + --state-pending: #f59e0b; + --state-closed: #64748b; + --state-removed: #ef4444; + + /* Semantic */ + --success: #22c55e; + --success-bg: rgba(34, 197, 94, 0.12); + --warning: #f59e0b; + --warning-bg: rgba(245, 158, 11, 0.12); + --error: #ef4444; + --error-bg: rgba(239, 68, 68, 0.12); + --info: #38bdf8; + --info-bg: rgba(56, 189, 248, 0.12); + + /* Spacing */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + + /* Radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-xl: 24px; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 20px var(--accent-primary-glow); + + /* Layout */ + --sidebar-width: 260px; + --topbar-height: 64px; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ---- Reset & Base ---- */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 14px; + scroll-behavior: smooth; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + min-height: 100vh; + overflow-x: hidden; + line-height: 1.6; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--text-muted); border-radius: var(--radius-full); } +::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); } + +/* ---- Sidebar ---- */ +.sidebar { + width: var(--sidebar-width); + height: 100vh; + position: fixed; + left: 0; + top: 0; + z-index: 100; + background: var(--bg-glass); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-right: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + transition: width var(--transition-base); +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-lg); + border-bottom: 1px solid var(--border-subtle); +} + +.brand-icon { + font-size: 1.7rem; + line-height: 1; + filter: drop-shadow(0 0 8px rgba(99, 102, 241, 0.5)); +} + +.brand-text { + font-size: 1.25rem; + font-weight: 800; + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.02em; +} + +.nav-menu { + list-style: none; + padding: var(--space-md); + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.nav-link { + display: flex; + align-items: center; + gap: var(--space-md); + padding: 10px var(--space-md); + border-radius: var(--radius-md); + color: var(--text-secondary); + text-decoration: none; + font-weight: 500; + font-size: 0.92rem; + transition: all var(--transition-fast); + position: relative; + overflow: hidden; +} + +.nav-link::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, var(--accent-primary-glow), transparent); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.nav-link:hover { + color: var(--text-primary); + background: var(--bg-card-hover); +} + +.nav-link:hover::before { + opacity: 1; +} + +.nav-link.active { + color: var(--text-primary); + background: linear-gradient(135deg, rgba(99, 102, 241, 0.15), rgba(139, 92, 246, 0.08)); + box-shadow: inset 0 0 0 1px var(--border-accent); +} + +.nav-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + position: relative; + z-index: 1; +} + +.nav-link span { + position: relative; + z-index: 1; +} + +.nav-badge { + margin-left: auto; + background: var(--accent-primary); + color: white; + font-size: 0.7rem; + font-weight: 700; + padding: 2px 7px; + border-radius: var(--radius-full); + min-width: 22px; + text-align: center; + position: relative; + z-index: 1; + display: none; +} + +.nav-badge:not(:empty) { + display: inline-block; +} + +.sidebar-footer { + padding: var(--space-md) var(--space-lg); + border-top: 1px solid var(--border-subtle); +} + +.sidebar-footer-info { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--warning); + transition: background var(--transition-base); +} + +.connection-dot.connected { + background: var(--success); + box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); + animation: pulse-glow 2s ease-in-out infinite; +} + +.connection-dot.error { + background: var(--error); +} + +.connection-text { + font-size: 0.75rem; + color: var(--text-tertiary); +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 8px rgba(34, 197, 94, 0.3); } + 50% { box-shadow: 0 0 14px rgba(34, 197, 94, 0.6); } +} + +/* ---- Main Content ---- */ +.main-content { + margin-left: var(--sidebar-width); + flex: 1; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ---- Topbar ---- */ +.topbar { + height: var(--topbar-height); + padding: 0 var(--space-xl); + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border-subtle); + position: sticky; + top: 0; + z-index: 50; + gap: var(--space-lg); +} + +.topbar-left { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.page-title { + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.02em; + white-space: nowrap; +} + +.topbar-right { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.search-bar { + position: relative; + width: 320px; +} + +.search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--text-tertiary); + pointer-events: none; +} + +.search-input { + width: 100%; + padding: 8px 12px 8px 36px; + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 0.85rem; + outline: none; + transition: all var(--transition-fast); +} + +.search-input:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-glow); +} + +.search-input::placeholder { + color: var(--text-muted); +} + +/* ---- View Container ---- */ +.view-container { + flex: 1; + padding: var(--space-xl); + animation: fadeIn var(--transition-base); +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ---- Loading ---- */ +.loading-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-md); + padding: var(--space-2xl); + color: var(--text-tertiary); +} + +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-light); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ---- Buttons ---- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: 9px 18px; + font-family: inherit; + font-size: 0.85rem; + font-weight: 600; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + outline: none; + text-decoration: none; + white-space: nowrap; +} + +.btn-sm { + padding: 6px 14px; + font-size: 0.8rem; +} + +.btn-xs { + padding: 4px 10px; + font-size: 0.75rem; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + color: white; + box-shadow: var(--shadow-sm), 0 0 12px var(--accent-primary-glow); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md), 0 0 20px var(--accent-primary-glow); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-light); +} + +.btn-ghost:hover { + background: var(--bg-card-hover); + color: var(--text-primary); + border-color: var(--border-accent); +} + +.btn-danger { + background: var(--error-bg); + color: var(--error); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.2); +} + +.btn-success { + background: var(--success-bg); + color: var(--success); + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.btn-success:hover { + background: rgba(34, 197, 94, 0.2); +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none !important; +} + +/* ---- Cards / Glass Panels ---- */ +.card { + background: var(--bg-card); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: var(--space-lg); + transition: all var(--transition-base); +} + +.card:hover { + border-color: var(--border-light); + box-shadow: var(--shadow-md); +} + +.card-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: var(--space-md); +} + +/* ---- Stats Cards ---- */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-xl); +} + +.stat-card { + background: var(--bg-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: var(--space-lg); + transition: all var(--transition-base); + position: relative; + overflow: hidden; +} + +.stat-card::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); + opacity: 0; + transition: opacity var(--transition-base); +} + +.stat-card:hover { + transform: translateY(-2px); + border-color: var(--border-accent); + box-shadow: var(--shadow-lg), var(--shadow-glow); +} + +.stat-card:hover::after { + opacity: 1; +} + +.stat-label { + font-size: 0.78rem; + font-weight: 500; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-sm); +} + +.stat-value { + font-size: 2rem; + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1; + background: linear-gradient(135deg, var(--text-primary), var(--text-secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.stat-card.accent .stat-value { + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.stat-card.warning .stat-value { + background: linear-gradient(135deg, var(--warning), #fbbf24); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.stat-card.danger .stat-value { + background: linear-gradient(135deg, var(--error), #fb7185); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ---- Distribution Bars ---- */ +.distribution-section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--space-lg); + margin-bottom: var(--space-xl); +} + +.dist-bar-container { + margin-bottom: var(--space-md); +} + +.dist-bar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-xs); +} + +.dist-bar-label { + font-size: 0.82rem; + color: var(--text-secondary); + font-weight: 500; +} + +.dist-bar-value { + font-size: 0.82rem; + color: var(--text-primary); + font-weight: 700; +} + +.dist-bar-track { + height: 6px; + background: var(--bg-tertiary); + border-radius: var(--radius-full); + overflow: hidden; +} + +.dist-bar-fill { + height: 100%; + border-radius: var(--radius-full); + background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); + transition: width 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); + min-width: 2px; +} + +/* ---- Ticket Table ---- */ +.ticket-table-wrapper { + overflow-x: auto; + border-radius: var(--radius-lg); + border: 1px solid var(--border-subtle); + background: var(--bg-card); + backdrop-filter: blur(12px); +} + +.ticket-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.ticket-table thead { + background: rgba(0, 0, 0, 0.2); + position: sticky; + top: 0; + z-index: 10; +} + +.ticket-table th { + padding: 12px var(--space-md); + text-align: left; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--border-subtle); + white-space: nowrap; + cursor: pointer; + user-select: none; + transition: color var(--transition-fast); +} + +.ticket-table th:hover { + color: var(--text-primary); +} + +.ticket-table th.sortable::after { + content: '↕'; + margin-left: 4px; + font-size: 0.7rem; + opacity: 0.4; +} + +.ticket-table th.sort-asc::after { + content: '↑'; + opacity: 1; + color: var(--accent-primary); +} + +.ticket-table th.sort-desc::after { + content: '↓'; + opacity: 1; + color: var(--accent-primary); +} + +.ticket-table td { + padding: 10px var(--space-md); + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ticket-table tbody tr { + transition: background var(--transition-fast); + cursor: pointer; +} + +.ticket-table tbody tr:hover { + background: var(--bg-card-hover); +} + +.ticket-table tbody tr.selected { + background: rgba(99, 102, 241, 0.08); + box-shadow: inset 3px 0 0 var(--accent-primary); +} + +.ticket-table .checkbox-cell { + width: 40px; + text-align: center; +} + +.ticket-table input[type="checkbox"] { + appearance: none; + width: 16px; + height: 16px; + border: 2px solid var(--border-light); + border-radius: 4px; + cursor: pointer; + transition: all var(--transition-fast); + position: relative; +} + +.ticket-table input[type="checkbox"]:checked { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.ticket-table input[type="checkbox"]:checked::after { + content: '✓'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 0.65rem; + font-weight: 700; +} + +.ticket-tn { + font-weight: 600; + color: var(--accent-primary-hover); + font-family: 'Inter', monospace; + font-size: 0.82rem; +} + +.ticket-title-cell { + color: var(--text-primary); + font-weight: 500; +} + +/* ---- Badges ---- */ +.badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: var(--radius-full); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.badge-state { + background: rgba(99, 102, 241, 0.12); + color: var(--accent-primary-hover); +} + +.badge-state[data-state-type="new"] { + background: rgba(56, 189, 248, 0.12); + color: var(--state-new); +} + +.badge-state[data-state-type="open"] { + background: rgba(99, 102, 241, 0.12); + color: var(--state-open); +} + +.badge-state[data-state-type="pending reminder"], +.badge-state[data-state-type="pending auto"] { + background: rgba(245, 158, 11, 0.12); + color: var(--state-pending); +} + +.badge-state[data-state-type="closed"] { + background: rgba(100, 116, 139, 0.12); + color: var(--state-closed); +} + +.badge-state[data-state-type="removed"], +.badge-state[data-state-type="merged"] { + background: rgba(239, 68, 68, 0.12); + color: var(--state-removed); +} + +.badge-priority { + font-weight: 700; +} + +.badge-priority[data-priority="1"] { + background: var(--priority-1-bg); + color: var(--priority-1-text); +} + +.badge-priority[data-priority="2"] { + background: var(--priority-2-bg); + color: var(--priority-2-text); +} + +.badge-priority[data-priority="3"] { + background: var(--priority-3-bg); + color: var(--priority-3-text); +} + +.badge-priority[data-priority="4"] { + background: var(--priority-4-bg); + color: var(--priority-4-text); +} + +.badge-priority[data-priority="5"] { + background: var(--priority-5-bg); + color: var(--priority-5-text); +} + +.badge-queue { + background: rgba(139, 92, 246, 0.1); + color: var(--accent-secondary); +} + +/* ---- Filters Bar ---- */ +.filters-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); + margin-bottom: var(--space-md); + padding: var(--space-md); + background: var(--bg-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} + +.filter-group { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.filter-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.filter-select { + padding: 6px 28px 6px 10px; + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: inherit; + font-size: 0.82rem; + outline: none; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + transition: border-color var(--transition-fast); +} + +.filter-select:focus { + border-color: var(--accent-primary); +} + +.filter-select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.filters-actions { + margin-left: auto; + display: flex; + gap: var(--space-sm); +} + +/* ---- Batch Actions Bar ---- */ +.batch-bar { + display: none; + align-items: center; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + margin-bottom: var(--space-md); + background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.08)); + border: 1px solid var(--border-accent); + border-radius: var(--radius-lg); + animation: slideDown 0.2s ease-out; +} + +.batch-bar.visible { + display: flex; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +.batch-count { + font-size: 0.85rem; + font-weight: 600; + color: var(--accent-primary-hover); +} + +/* ---- Pagination ---- */ +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md); + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + margin-top: var(--space-md); +} + +.pagination-info { + font-size: 0.8rem; + color: var(--text-tertiary); +} + +.pagination-controls { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.pagination-btn { + padding: 6px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-family: inherit; + font-size: 0.8rem; + cursor: pointer; + transition: all var(--transition-fast); +} + +.pagination-btn:hover:not(:disabled) { + background: var(--bg-card-hover); + color: var(--text-primary); + border-color: var(--border-accent); +} + +.pagination-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.pagination-btn.active { + background: var(--accent-primary); + color: white; + border-color: var(--accent-primary); +} + +/* ---- Ticket Detail ---- */ +.ticket-detail { + display: grid; + grid-template-columns: 1fr 320px; + gap: var(--space-lg); +} + +.ticket-detail-main { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.ticket-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-lg); +} + +.ticket-header-info { + flex: 1; +} + +.ticket-number { + font-size: 0.85rem; + color: var(--accent-primary-hover); + font-weight: 600; + font-family: 'Inter', monospace; + margin-bottom: var(--space-xs); +} + +.ticket-detail-title { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: var(--space-md); + line-height: 1.3; +} + +.ticket-meta-badges { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.ticket-header-actions { + display: flex; + gap: var(--space-sm); +} + +/* Quick Edit Fields */ +.quick-edit { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--space-md); +} + +.quick-edit-field { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.quick-edit-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.quick-edit-select { + padding: 8px 30px 8px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 0.85rem; + outline: none; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + transition: all var(--transition-fast); +} + +.quick-edit-select:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-glow); +} + +.quick-edit-select.changed { + border-color: var(--success); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); +} + +/* ---- Articles Timeline ---- */ +.articles-timeline { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.article-card { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: var(--space-lg); + transition: border-color var(--transition-fast); + position: relative; + animation: fadeIn var(--transition-base); +} + +.article-card::before { + content: ''; + position: absolute; + left: 0; + top: 16px; + bottom: 16px; + width: 3px; + border-radius: var(--radius-full); +} + +.article-card.sender-agent::before { + background: var(--accent-primary); +} + +.article-card.sender-customer::before { + background: var(--success); +} + +.article-card.sender-system::before { + background: var(--text-muted); +} + +.article-card:hover { + border-color: var(--border-light); +} + +.article-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-md); +} + +.article-sender { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.article-sender-badge { + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.article-sender-badge.agent { + background: rgba(99, 102, 241, 0.12); + color: var(--accent-primary-hover); +} + +.article-sender-badge.customer { + background: var(--success-bg); + color: var(--success); +} + +.article-sender-badge.system { + background: rgba(100, 116, 139, 0.12); + color: var(--text-tertiary); +} + +.article-from { + font-weight: 600; + color: var(--text-primary); + font-size: 0.85rem; +} + +.article-time { + font-size: 0.78rem; + color: var(--text-tertiary); +} + +.article-subject { + font-weight: 600; + margin-bottom: var(--space-sm); + font-size: 0.92rem; +} + +.article-body { + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; +} + +/* ---- Ticket Sidebar ---- */ +.ticket-sidebar { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.sidebar-panel { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: var(--space-lg); +} + +.sidebar-panel-title { + font-size: 0.75rem; + font-weight: 700; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: var(--space-md); +} + +.meta-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-sm) 0; + border-bottom: 1px solid var(--border-subtle); +} + +.meta-row:last-child { + border-bottom: none; +} + +.meta-label { + font-size: 0.78rem; + color: var(--text-tertiary); +} + +.meta-value { + font-size: 0.82rem; + font-weight: 500; + color: var(--text-primary); + text-align: right; +} + +/* ---- Add Note Form ---- */ +.add-note-form { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: var(--space-lg); +} + +.add-note-form .card-title { + margin-bottom: var(--space-md); +} + +.note-textarea { + width: 100%; + min-height: 100px; + padding: var(--space-md); + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 0.85rem; + outline: none; + resize: vertical; + transition: border-color var(--transition-fast); + line-height: 1.6; + margin-bottom: var(--space-md); +} + +.note-textarea:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-glow); +} + +.note-textarea::placeholder { + color: var(--text-muted); +} + +/* ---- Create Ticket Form ---- */ +.create-form { + max-width: 720px; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-md); + margin-bottom: var(--space-lg); +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.02em; +} + +.form-label .required { + color: var(--error); +} + +.form-input, +.form-select, +.form-textarea { + padding: 10px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 0.85rem; + outline: none; + transition: all var(--transition-fast); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-glow); +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: var(--text-muted); +} + +.form-select { + appearance: none; + cursor: pointer; + padding-right: 32px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; +} + +.form-select option { + background: var(--bg-secondary); +} + +.form-textarea { + resize: vertical; + min-height: 140px; + line-height: 1.6; +} + +.form-actions { + display: flex; + gap: var(--space-md); + justify-content: flex-end; +} + +/* ---- Toast Notifications ---- */ +.toast-container { + position: fixed; + bottom: var(--space-xl); + right: var(--space-xl); + z-index: 9999; + display: flex; + flex-direction: column-reverse; + gap: var(--space-sm); + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: var(--space-md); + padding: 12px 20px; + border-radius: var(--radius-md); + font-size: 0.85rem; + font-weight: 500; + backdrop-filter: blur(16px); + box-shadow: var(--shadow-lg); + pointer-events: auto; + animation: toastIn 0.3s ease-out; + max-width: 400px; + border: 1px solid; +} + +.toast.toast-exit { + animation: toastOut 0.25s ease-in forwards; +} + +.toast-success { + background: var(--success-bg); + color: var(--success); + border-color: rgba(34, 197, 94, 0.2); +} + +.toast-error { + background: var(--error-bg); + color: var(--error); + border-color: rgba(239, 68, 68, 0.2); +} + +.toast-info { + background: var(--info-bg); + color: var(--info); + border-color: rgba(56, 189, 248, 0.2); +} + +.toast-warning { + background: var(--warning-bg); + color: var(--warning); + border-color: rgba(245, 158, 11, 0.2); +} + +@keyframes toastIn { + from { opacity: 0; transform: translateX(40px) scale(0.95); } + to { opacity: 1; transform: translateX(0) scale(1); } +} + +@keyframes toastOut { + from { opacity: 1; transform: translateX(0) scale(1); } + to { opacity: 0; transform: translateX(40px) scale(0.95); } +} + +/* ---- Recent Tickets Table (Dashboard) ---- */ +.recent-tickets-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.recent-tickets-table th { + padding: 8px var(--space-md); + text-align: left; + font-size: 0.72rem; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--border-subtle); +} + +.recent-tickets-table td { + padding: 8px var(--space-md); + border-bottom: 1px solid var(--border-subtle); +} + +.recent-tickets-table tbody tr { + cursor: pointer; + transition: background var(--transition-fast); +} + +.recent-tickets-table tbody tr:hover { + background: var(--bg-card-hover); +} + +/* ---- Empty State ---- */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-2xl); + color: var(--text-tertiary); + text-align: center; +} + +.empty-state-icon { + font-size: 3rem; + margin-bottom: var(--space-md); + opacity: 0.4; +} + +.empty-state-text { + font-size: 1rem; + font-weight: 500; + margin-bottom: var(--space-sm); + color: var(--text-secondary); +} + +.empty-state-sub { + font-size: 0.85rem; +} + +/* ---- Back Button ---- */ +.back-link { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + color: var(--text-tertiary); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: var(--space-lg); + transition: color var(--transition-fast); + cursor: pointer; +} + +.back-link:hover { + color: var(--text-primary); +} + +/* ---- Note subject ---- */ +.note-subject-input { + width: 100%; + padding: 8px var(--space-md); + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 0.85rem; + outline: none; + transition: border-color var(--transition-fast); + margin-bottom: var(--space-md); +} + +.note-subject-input:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-glow); +} + +.note-subject-input::placeholder { + color: var(--text-muted); +} + +/* ---- Responsive ---- */ +@media (max-width: 1100px) { + .ticket-detail { + grid-template-columns: 1fr; + } + .ticket-sidebar { + order: -1; + } +} + +@media (max-width: 900px) { + :root { + --sidebar-width: 0px; + } + .sidebar { + transform: translateX(-100%); + width: 260px; + } + .sidebar.open { + transform: translateX(0); + } + .search-bar { + width: 200px; + } + .form-grid { + grid-template-columns: 1fr; + } +} + +/* ---- Autocomplete Suggestions ---- */ +.autocomplete-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #1f2937; + border: 1px solid #4b5563; + border-radius: var(--radius-md); + max-height: 220px; + overflow-y: auto; + z-index: 1000; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3); +} + +.autocomplete-suggestion-item { + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid rgba(156, 163, 175, 0.15); + font-size: 0.85rem; + color: #f3f4f6; + transition: all var(--transition-fast); +} + +.autocomplete-suggestion-item:hover { + background: var(--accent-primary); + color: #ffffff !important; +} + +.autocomplete-suggestion-item:hover span { + color: #e5e7eb !important; +} + +.autocomplete-suggestion-item:last-child { + border-bottom: none; +} + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..529bbac --- /dev/null +++ b/public/index.html @@ -0,0 +1,110 @@ + + + + + + + OTRS Turbo — Gestione Ticket Veloce + + + + + + + + + + +
+ +
+
+

Dashboard

+ +
+
+ + +
+
+ + +
+ +
+
+

Caricamento...

+
+
+
+ + +
+ + + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..0edcdaf --- /dev/null +++ b/public/js/app.js @@ -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 => + `` + ).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()); diff --git a/public/js/components/filters.js b/public/js/components/filters.js new file mode 100644 index 0000000..b521931 --- /dev/null +++ b/public/js/components/filters.js @@ -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 ``; + }).join(''); + }; + + return ` +
+
+ Stato + +
+
+ Coda + +
+
+ Priorità + +
+
+ Owner + +
+
+ +
+
+ `; + }, + + /** 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(); diff --git a/public/js/components/toast.js b/public/js/components/toast.js new file mode 100644 index 0000000..281c30d --- /dev/null +++ b/public/js/components/toast.js @@ -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 = ` + ${icons[type] || ''} + ${message} + `; + + 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); }, +}; diff --git a/public/js/views/dashboard.js b/public/js/views/dashboard.js new file mode 100644 index 0000000..d29d4a9 --- /dev/null +++ b/public/js/views/dashboard.js @@ -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 = '

Caricamento dashboard...

'; + + 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 = ` + +
+
+
Ticket Aperti
+
${stats.total_open}
+
+
+
Creati Oggi
+
${stats.created_today}
+
+
+
Creati Settimana
+
${stats.created_this_week}
+
+
+
Escalated
+
${stats.escalated}
+
+
+ + +
+
+
Per Stato
+ ${(stats.by_state || []).map(s => ` +
+
+ ${s.state} + ${s.count} +
+
+
+
+
+ `).join('')} + ${(stats.by_state || []).length === 0 ? '

Nessun dato

' : ''} +
+ +
+
Per Priorità
+ ${(stats.by_priority || []).map((p, idx) => ` +
+
+ ${p.priority} + ${p.count} +
+
+
+
+
+ `).join('')} + ${(stats.by_priority || []).length === 0 ? '

Nessun dato

' : ''} +
+ +
+
Per Coda (Top 10)
+ ${(stats.by_queue || []).map(q => ` +
+
+ ${q.queue} + ${q.count} +
+
+
+
+
+ `).join('')} + ${(stats.by_queue || []).length === 0 ? '

Nessun dato

' : ''} +
+
+ + +
+
Ticket Recenti
+ ${(stats.recent_tickets || []).length > 0 ? ` + + + + + + + + + + + + + ${stats.recent_tickets.map(t => ` + + + + + + + + + `).join('')} + +
NumeroTitoloStatoPrioritàCodaData
${t.tn}${App.escapeHtml(t.title || '')}${t.state_name}${t.priority_name}${t.queue_name}${App.formatDate(t.create_time)}
+ ` : ` +
+
📭
+
Nessun ticket recente
+
+ `} +
+ `; + + // 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 = ` +
+
⚠️
+
Errore caricamento dashboard
+
${App.escapeHtml(err.message)}
+
+ `; + } + }, +}; diff --git a/public/js/views/ticketCreate.js b/public/js/views/ticketCreate.js new file mode 100644 index 0000000..b9bd9e1 --- /dev/null +++ b/public/js/views/ticketCreate.js @@ -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 = '

Caricamento form...

'; + + try { + await App.ensureLookups(); + + container.innerHTML = ` + + + Torna indietro + + +
+
+ Crea Nuovo Ticket + +
+ +
+
+ + +
+ +
+ + + + +
+ +
+ + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ + + + +
+ + +
+ +
+ + +
+
+ +
+
+ +
+
+ + +
+
+
+ `; + + this.bindEvents(); + + } catch (err) { + container.innerHTML = ` +
+
⚠️
+
Errore caricamento form
+
${App.escapeHtml(err.message)}
+
+ `; + } + }, + + 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 = '
Nessun utente trovato
'; + userSuggestionsDiv.style.display = 'block'; + return; + } + + userSuggestionsDiv.innerHTML = users.map(u => ` +
+ ${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)} + (Login: ${App.escapeHtml(u.login)} | Azienda: ${App.escapeHtml(u.customer_id || '—')}) +
+ `).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 = '
Nessun agente trovato
'; + ownerSuggestionsDiv.style.display = 'block'; + return; + } + + ownerSuggestionsDiv.innerHTML = agents.map(u => ` +
+ ${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)} + (Login: ${App.escapeHtml(u.login)}) +
+ `).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 = '
Nessun agente trovato
'; + responsibleSuggestionsDiv.style.display = 'block'; + return; + } + + responsibleSuggestionsDiv.innerHTML = agents.map(u => ` +
+ ${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)} + (Login: ${App.escapeHtml(u.login)}) +
+ `).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 = '
Nessuna coda trovata
'; + queueSuggestionsDiv.style.display = 'block'; + return; + } + + queueSuggestionsDiv.innerHTML = queues.map(q => ` +
+ ${App.escapeHtml(q.name)} +
+ `).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 = '
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 = ` + + + + Crea Ticket + `; + } + }); + }, +}; diff --git a/public/js/views/ticketDetail.js b/public/js/views/ticketDetail.js new file mode 100644 index 0000000..e4f9cb1 --- /dev/null +++ b/public/js/views/ticketDetail.js @@ -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 = '

Caricamento ticket...

'; + + 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 = ` + + + Torna alla lista + + +
+
+ +
+
+
+
#${ticket.tn}
+

${App.escapeHtml(ticket.title || '(senza titolo)')}

+
+ ${ticket.state_name} + ${ticket.priority_name} + ${ticket.queue_name} + ${ticket.lock_name === 'lock' ? '🔒 Bloccato' : ''} +
+
+
+
+ + +
+
Modifica Rapida
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ${(App.lookups.types || []).length > 0 ? ` +
+ + +
+ ` : ''} +
+
+ + +
+
+ + +
+
Aggiungi Nota
+
+ + +
+ +
+ +
+
+ + +
+
Articoli & Note (${articles.length})
+
+ ${articles.length > 0 ? articles.map(a => ` +
+
+
+ ${a.sender_type || 'System'} + ${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')} + ${a.channel_name ? `via ${a.channel_name}` : ''} +
+
+ ${a.time_unit ? `⏱ ${parseFloat(a.time_unit)} min` : ''} + ${App.formatDateTime(a.create_time)} +
+
+ ${a.a_subject ? `
${App.escapeHtml(a.a_subject)}
` : ''} +
${App.escapeHtml(a.a_body || '')}
+
+ `).join('') : ` +
+
💬
+
Nessun articolo
+
+ `} +
+
+
+ + +
+ + + ${ticket.customer_user_id || ticket.customer_id ? ` + + ` : ''} + + ${ticket.escalation_time > 0 ? ` + + ` : ''} +
+
+ `; + + this.bindEvents(); + + } catch (err) { + container.innerHTML = ` +
+
⚠️
+
Errore caricamento ticket
+
${App.escapeHtml(err.message)}
+ +
+ `; + } + }, + + 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 = '
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'; + } + }); + }, +}; diff --git a/public/js/views/ticketList.js b/public/js/views/ticketList.js new file mode 100644 index 0000000..cfad6b1 --- /dev/null +++ b/public/js/views/ticketList.js @@ -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 = '

Caricamento ticket...

'; + + 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 = ` +
+
⚠️
+
Errore caricamento ticket
+
${App.escapeHtml(err.message)}
+
+ `; + } + }, + + renderContent(container, data) { + const tickets = data.tickets || []; + const { total, page, per_page, total_pages } = data; + + container.innerHTML = ` + ${Filters.renderBar(App.lookups)} + + +
+ 0 selezionati +
+ Stato + +
+
+ Coda + +
+
+ Owner + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + ${tickets.length > 0 ? tickets.map(t => ` + + + + + + + + + + + + `).join('') : ` + + + + `} + +
+ + TitoloStatoPrioritàCodaOwnerCreatoModificato
+ + ${t.tn}${App.escapeHtml(t.title || '(senza titolo)')}${t.state_name}${t.priority_name}${t.queue_name}${t.owner_first || ''} ${t.owner_last || ''}${App.formatDate(t.create_time)}${App.formatDate(t.change_time)}
+
+
📭
+
Nessun ticket trovato
+
Prova a cambiare i filtri o crea un nuovo ticket.
+
+
+
+ + + ${total_pages > 1 ? ` + + ` : ` + + `} + `; + }, + + 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(``); + } + 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); + } + }, +}; diff --git a/routes/dashboard.js b/routes/dashboard.js new file mode 100644 index 0000000..8a402ad --- /dev/null +++ b/routes/dashboard.js @@ -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; diff --git a/routes/lookups.js b/routes/lookups.js new file mode 100644 index 0000000..063ef4d --- /dev/null +++ b/routes/lookups.js @@ -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; diff --git a/routes/tickets.js b/routes/tickets.js new file mode 100644 index 0000000..cbe7f0e --- /dev/null +++ b/routes/tickets.js @@ -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 = `${body}`; + 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; diff --git a/server.js b/server.js new file mode 100644 index 0000000..b17b952 --- /dev/null +++ b/server.js @@ -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`); +});