diff --git a/ajax.php b/ajax.php index 29fe43a0..e8d81564 100644 --- a/ajax.php +++ b/ajax.php @@ -541,3 +541,168 @@ if (isset($_GET['get_totp_token_via_id'])) { if (isset($_GET['get_readable_pass'])) { echo json_encode(GenerateReadablePassword(4)); } + +/* + * ITFlow - POST request handler for client tickets + */ +if (isset($_POST['update_kanban_status_position'])) { + // Update multiple ticket status kanban orders + enforceUserPermission('module_support', 2); + + $positions = $_POST['positions']; + + foreach ($positions as $position) { + $status_id = intval($position['status_id']); + $kanban = intval($position['status_kanban']); + + mysqli_query($mysqli, "UPDATE ticket_statuses SET ticket_status_order = $kanban WHERE ticket_status_id = $status_id"); + } + + // return a response + echo json_encode(['status' => 'success']); + exit; +} + +if (isset($_POST['update_kanban_ticket'])) { + // Update ticket kanban order and status + enforceUserPermission('module_support', 2); + + // all tickets on the column + $positions = $_POST['positions']; + + foreach ($positions as $position) { + $ticket_id = intval($position['ticket_id']); + $kanban = intval($position['ticket_order']); // ticket kanban position + $status = intval($position['ticket_status']); // ticket statuses + $oldStatus = intval($position['ticket_oldStatus']); // ticket old status if moved + + $statuses['Closed'] = 5; + $statuses['Resolved'] = 4; + + // Continue if status is null / Closed + if ($status === null || $status === $statuses['Closed']) { + continue; + } + + + if ($oldStatus === false) { + // if ticket was not moved, just uptdate the order on kanban + mysqli_query($mysqli, "UPDATE tickets SET ticket_order = $kanban WHERE ticket_id = $ticket_id"); + customAction('ticket_update', $ticket_id); + } else { + // If the ticket was moved from a resolved status to another status, we need to update ticket_resolved_at + if ($oldStatus === $statuses['Resolved']) { + mysqli_query($mysqli, "UPDATE tickets SET ticket_order = $kanban, ticket_status = $status, ticket_resolved_at = NULL WHERE ticket_id = $ticket_id"); + customAction('ticket_update', $ticket_id); + } elseif ($status === $statuses['Resolved']) { + // If the ticket was moved to a resolved status, we need to update ticket_resolved_at + mysqli_query($mysqli, "UPDATE tickets SET ticket_order = $kanban, ticket_status = $status, ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id"); + customAction('ticket_update', $ticket_id); + + // Client notification email + if (!empty($config_smtp_host) && $config_ticket_client_general_notifications == 1) { + + // Get details + $ticket_sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, ticket_prefix, ticket_number, ticket_subject, ticket_status_name, ticket_assigned_to, ticket_url_key, ticket_client_id FROM tickets + LEFT JOIN clients ON ticket_client_id = client_id + LEFT JOIN contacts ON ticket_contact_id = contact_id + LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id + WHERE ticket_id = $ticket_id + "); + $row = mysqli_fetch_array($ticket_sql); + + $contact_name = sanitizeInput($row['contact_name']); + $contact_email = sanitizeInput($row['contact_email']); + $ticket_prefix = sanitizeInput($row['ticket_prefix']); + $ticket_number = intval($row['ticket_number']); + $ticket_subject = sanitizeInput($row['ticket_subject']); + $client_id = intval($row['ticket_client_id']); + $ticket_assigned_to = intval($row['ticket_assigned_to']); + $ticket_status = sanitizeInput($row['ticket_status_name']); + $url_key = sanitizeInput($row['ticket_url_key']); + + // Sanitize Config vars from get_settings.php + $config_ticket_from_name = sanitizeInput($config_ticket_from_name); + $config_ticket_from_email = sanitizeInput($config_ticket_from_email); + $config_base_url = sanitizeInput($config_base_url); + + // Get Company Info + $sql = mysqli_query($mysqli, "SELECT company_name, company_phone FROM companies WHERE company_id = 1"); + $row = mysqli_fetch_array($sql); + $company_name = sanitizeInput($row['company_name']); + $company_phone = sanitizeInput(formatPhoneNumber($row['company_phone'])); + + // EMAIL + $subject = "Ticket resolved - [$ticket_prefix$ticket_number] - $ticket_subject | (pending closure)"; + $body = "##- Please type your reply above this line -##

Hello $contact_name,

Your ticket regarding $ticket_subject has been marked as solved and is pending closure.

If your request/issue is resolved, you can simply ignore this email. If you need further assistance, please reply or re-open to let us know!

Ticket: $ticket_prefix$ticket_number
Subject: $ticket_subject
Status: $ticket_status
Portal: View ticket

--
$company_name - Support
$config_ticket_from_email
$company_phone"; + + // Check email valid + if (filter_var($contact_email, FILTER_VALIDATE_EMAIL)) { + + $data = []; + + // Email Ticket Contact + // Queue Mail + + $data[] = [ + 'from' => $config_ticket_from_email, + 'from_name' => $config_ticket_from_name, + 'recipient' => $contact_email, + 'recipient_name' => $contact_name, + 'subject' => $subject, + 'body' => $body + ]; + } + + // Also Email all the watchers + $sql_watchers = mysqli_query($mysqli, "SELECT watcher_email FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id"); + $body .= "

----------------------------------------
YOU ARE A COLLABORATOR ON THIS TICKET"; + while ($row = mysqli_fetch_array($sql_watchers)) { + $watcher_email = sanitizeInput($row['watcher_email']); + + // Queue Mail + $data[] = [ + 'from' => $config_ticket_from_email, + 'from_name' => $config_ticket_from_name, + 'recipient' => $watcher_email, + 'recipient_name' => $watcher_email, + 'subject' => $subject, + 'body' => $body + ]; + } + addToMailQueue($data); + } + //End Mail IF + + } else { + // If the ticket was moved from any status to another status + mysqli_query($mysqli, "UPDATE tickets SET ticket_order = $kanban, ticket_status = $status WHERE ticket_id = $ticket_id"); + customAction('ticket_update', $ticket_id); + } + } + + } + + // return a response + echo json_encode(['status' => 'success','payload' => $positions]); + exit; +} + +if (isset($_POST['update_ticket_tasks_order'])) { + // Update multiple ticket tasks order + enforceUserPermission('module_support', 2); + + $positions = $_POST['positions']; + $ticket_id = intval($_POST['ticket_id']); + + foreach ($positions as $position) { + $id = intval($position['id']); + $order = intval($position['order']); + + mysqli_query($mysqli, "UPDATE tasks SET task_order = $order WHERE task_ticket_id = $ticket_id AND task_id = $id"); + } + + // return a response + echo json_encode(['status' => 'success']); + exit; +} \ No newline at end of file diff --git a/css/tickets_kanban.css b/css/tickets_kanban.css new file mode 100644 index 00000000..d4a78cbe --- /dev/null +++ b/css/tickets_kanban.css @@ -0,0 +1,35 @@ +.popover { + max-width: 600px; +} +#kanban-board { + display: flex; + box-sizing: border-box; + overflow-x: auto; + min-width: 400px; + height: calc(100vh - 210px); +} + +.kanban-column { + flex: 1; /* Allows columns to grow equally */ + margin: 0 10px; /* Space between columns */ + min-width: 300px; + max-width: 300px; + background: #f4f4f4; + padding: 10px; + border: 1px solid #ccc; + min-height: calc(100vh - 230px); + max-height: calc(100vh - 230px); + box-sizing: border-box; +} + +.kanban-column div { + max-height: calc(100vh - 280px); /* Set your desired max height */ + overflow-y: auto; /* Adds a scrollbar when content exceeds max height */ +} + +.task { + background: #fff; + margin: 5px 0; + padding: 10px; + border: 1px solid #ddd; +} \ No newline at end of file diff --git a/database_updates.php b/database_updates.php index 8ee3abf5..b4cc80cb 100644 --- a/database_updates.php +++ b/database_updates.php @@ -2469,10 +2469,24 @@ if (LATEST_DATABASE_VERSION > CURRENT_DATABASE_VERSION) { mysqli_query($mysqli, "UPDATE `settings` SET `config_current_database_version` = '1.8.0'"); } - // if (CURRENT_DATABASE_VERSION == '1.8.0') { - // // Insert queries here required to update to DB version 1.8.1 + if (CURRENT_DATABASE_VERSION == '1.8.0') { + + + mysqli_query($mysqli, "ALTER TABLE `ticket_statuses` ADD `ticket_status_order` int(11) NOT NULL DEFAULT 0"); + + mysqli_query($mysqli, "ALTER TABLE `tickets` ADD `ticket_order` int(11) NOT NULL DEFAULT 0"); + + mysqli_query($mysqli, "ALTER TABLE `settings` ADD `config_ticket_default_view` tinyint(1) NOT NULL DEFAULT 0"); + mysqli_query($mysqli, "ALTER TABLE `settings` ADD `config_ticket_ordering` tinyint(1) NOT NULL DEFAULT 0"); + mysqli_query($mysqli, "ALTER TABLE `settings` ADD `config_ticket_moving_columns` tinyint(1) NOT NULL DEFAULT 1"); + + mysqli_query($mysqli, "UPDATE `settings` SET `config_current_database_version` = '1.8.1'"); + } + + // if (CURRENT_DATABASE_VERSION == '1.8.1') { + // // Insert queries here required to update to DB version 1.8.2 // // Then, update the database to the next sequential version - // mysqli_query($mysqli, "UPDATE `settings` SET `config_current_database_version` = '1.8.1'"); + // mysqli_query($mysqli, "UPDATE `settings` SET `config_current_database_version` = '1.8.2'"); // } } else { diff --git a/db.sql b/db.sql index 31f409e0..0399a3e4 100644 --- a/db.sql +++ b/db.sql @@ -1704,6 +1704,9 @@ CREATE TABLE `settings` ( `config_ticket_autoclose_hours` int(5) NOT NULL DEFAULT 72, `config_ticket_new_ticket_notification_email` varchar(200) DEFAULT NULL, `config_ticket_default_billable` tinyint(1) NOT NULL DEFAULT 0, + `config_ticket_default_view` tinyint(1) NOT NULL DEFAULT 0, + `config_ticket_moving_columns` tinyint(1) NOT NULL DEFAULT 1, + `config_ticket_ordering` tinyint(1) NOT NULL DEFAULT 0, `config_enable_cron` tinyint(1) NOT NULL DEFAULT 0, `config_recurring_auto_send_invoice` tinyint(1) NOT NULL DEFAULT 1, `config_enable_alert_domain_expire` tinyint(1) NOT NULL DEFAULT 1, @@ -2019,6 +2022,7 @@ CREATE TABLE `ticket_statuses` ( `ticket_status_name` varchar(200) NOT NULL, `ticket_status_color` varchar(200) NOT NULL, `ticket_status_active` tinyint(1) NOT NULL DEFAULT 1, + `ticket_status_order` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`ticket_status_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -2113,6 +2117,7 @@ CREATE TABLE `tickets` ( `ticket_asset_id` int(11) NOT NULL DEFAULT 0, `ticket_invoice_id` int(11) NOT NULL DEFAULT 0, `ticket_project_id` int(11) NOT NULL DEFAULT 0, + `ticket_order` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`ticket_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/get_settings.php b/get_settings.php index 0ccf34ef..e560078e 100644 --- a/get_settings.php +++ b/get_settings.php @@ -75,6 +75,9 @@ $config_ticket_client_general_notifications = intval($row['config_ticket_client_ $config_ticket_autoclose_hours = intval($row['config_ticket_autoclose_hours']); $config_ticket_new_ticket_notification_email = $row['config_ticket_new_ticket_notification_email']; $config_ticket_default_billable = intval($row['config_ticket_default_billable']); +$config_ticket_default_view = intval($row['config_ticket_default_view']); +$config_ticket_moving_columns = intval($row['config_ticket_moving_columns']); +$config_ticket_ordering = intval($row['config_ticket_ordering']); // Cron $config_enable_cron = intval($row['config_enable_cron']); diff --git a/includes/database_version.php b/includes/database_version.php index c1d450cd..292483e0 100644 --- a/includes/database_version.php +++ b/includes/database_version.php @@ -5,4 +5,4 @@ * It is used in conjunction with database_updates.php */ -DEFINE("LATEST_DATABASE_VERSION", "1.8.0"); +DEFINE("LATEST_DATABASE_VERSION", "1.8.1"); diff --git a/js/tickets_kanban.js b/js/tickets_kanban.js new file mode 100644 index 00000000..706ca9f9 --- /dev/null +++ b/js/tickets_kanban.js @@ -0,0 +1,118 @@ +$(document).ready(function() { + console.log('CONFIG_TICKET_MOVING_COLUMNS: ' + CONFIG_TICKET_MOVING_COLUMNS); + console.log('CONFIG_TICKET_ORDERING: ' + CONFIG_TICKET_ORDERING); + // Initialize Dragula for the Kanban board + let boardDrake = dragula([ + document.querySelector('#kanban-board') + ], { + moves: function(el, container, handle) { + return handle.classList.contains('panel-title'); + }, + accepts: function(el, target, source, sibling) { + return CONFIG_TICKET_MOVING_COLUMNS === 1; + } + }); + + // Log the event of moving the column panel-title + boardDrake.on('drag', function(el) { + //console.log('Dragging column:', el.querySelector('.panel-title').innerText); + }); + + boardDrake.on('drop', function(el, target, source, sibling) { + //console.log('Dropped column:', el.querySelector('.panel-title').innerText); + + // Get all columns and their positions + let columns = document.querySelectorAll('#kanban-board .kanban-column'); + let columnPositions = []; + + columns.forEach(function(column, index) { + let statusId = $(column).data('status-id'); // Assuming you have a data attribute for status ID + columnPositions.push({ + status_id: statusId, + status_kanban: index + }); + }); + + // Send AJAX request to update all column positions + $.ajax({ + url: 'ajax.php', + type: 'POST', + data: { + update_kanban_status_position: true, + positions: columnPositions + }, + success: function(response) { + console.log('Ticket status kanban orders updated successfully.'); + // Optionally, you can refresh the page or update the UI here + }, + error: function(xhr, status, error) { + console.error('Error updating ticket status kanban orders:', error); + } + }); + }); + + // Initialize Dragula for the Kanban Cards + let drake = dragula([ + ...document.querySelectorAll('#status') + ]); + + // Add event listener for the drop event + drake.on('drop', function (el, target, source, sibling) { + // Log the target ID to the console + //console.log('Dropped into:', target.getAttribute('data-column-name')); + + if (CONFIG_TICKET_ORDERING === 0 && source == target) { + drake.cancel(true); // Move the card back to its original position + return; + } + + // Get all cards in the target column and their positions + let cards = $(target).children('.task'); + let positions = []; + + //id of current status / column + let columnId = $(target).data('status-id'); + + let movedTicketId = $(el).data('ticket-id'); + let movedTicketStatusId = $(el).data('ticket-status-id'); + + cards.each(function(index, card) { + let ticketId = $(card).data('ticket-id'); + let statusId = $(card).data('ticket-status-id'); + + let oldStatus = false; + if (ticketId == movedTicketId) { + oldStatus = movedTicketStatusId; + } + + //update the status id of the card if needed + if (statusId != columnId) { + $(card).data('ticket-status-id', columnId); + statusId = columnId; + } + positions.push({ + ticket_id: ticketId, + ticket_order: index, + ticket_oldStatus: oldStatus, + ticket_status: statusId ?? null// Get the new status ID from the target column + }); + }); + + //console.log(positions); + // Send AJAX request to update all ticket kanban orders and statuses + $.ajax({ + url: 'ajax.php', + type: 'POST', + data: { + update_kanban_ticket: true, + positions: positions + }, + success: function(response) { + //console.log('Ticket kanban orders and statuses updated successfully.'); + }, + error: function(xhr, status, error) { + console.error('Error updating ticket kanban orders and statuses:', error); + } + }); + }); +}); \ No newline at end of file diff --git a/plugins/dragula/dragula.min.css b/plugins/dragula/dragula.min.css new file mode 100644 index 00000000..a080100e --- /dev/null +++ b/plugins/dragula/dragula.min.css @@ -0,0 +1 @@ +.gu-mirror{position:fixed!important;margin:0!important;z-index:9999!important;opacity:.8}.gu-hide{display:none!important}.gu-unselectable{-webkit-user-select:none!important;-moz-user-select:none!important;-ms-user-select:none!important;user-select:none!important}.gu-transit{opacity:.2} \ No newline at end of file diff --git a/plugins/dragula/dragula.min.js b/plugins/dragula/dragula.min.js new file mode 100644 index 00000000..dd61cd87 --- /dev/null +++ b/plugins/dragula/dragula.min.js @@ -0,0 +1 @@ +!function(e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).dragula=e()}(function(){return function o(r,i,u){function c(t,e){if(!i[t]){if(!r[t]){var n="function"==typeof require&&require;if(!e&&n)return n(t,!0);if(a)return a(t,!0);throw(n=new Error("Cannot find module '"+t+"'")).code="MODULE_NOT_FOUND",n}n=i[t]={exports:{}},r[t][0].call(n.exports,function(e){return c(r[t][1][e]||e)},n,n.exports,o,r,i,u)}return i[t].exports}for(var a="function"==typeof require&&require,e=0;ee.left+G(e)/2);return n(u>e.top+J(e)/2)}:function(){var e,t,n,o=r.children.length;for(e=0;ei)return t;if(!c&&n.top+n.height/2>u)return t}return null})();function n(e){return e?Z(t):t}}}}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./classes":1,"contra/emitter":5,crossvent:6}],3:[function(e,t,n){t.exports=function(e,t){return Array.prototype.slice.call(e,t)}},{}],4:[function(e,t,n){"use strict";var o=e("ticky");t.exports=function(e,t,n){e&&o(function(){e.apply(n||null,t||[])})}},{ticky:10}],5:[function(e,t,n){"use strict";var c=e("atoa"),a=e("./debounce");t.exports=function(r,e){var i=e||{},u={};return void 0===r&&(r={}),r.on=function(e,t){return u[e]?u[e].push(t):u[e]=[t],r},r.once=function(e,t){return t._once=!0,r.on(e,t),r},r.off=function(e,t){var n=arguments.length;if(1===n)delete u[e];else if(0===n)u={};else{e=u[e];if(!e)return r;e.splice(e.indexOf(t),1)}return r},r.emit=function(){var e=c(arguments);return r.emitterSnapshot(e.shift()).apply(this,e)},r.emitterSnapshot=function(o){var e=(u[o]||[]).slice(0);return function(){var t=c(arguments),n=this||r;if("error"===o&&!1!==i.throws&&!e.length)throw 1===t.length?t[0]:t;return e.forEach(function(e){i.async?a(e,t,n):e.apply(n,t),e._once&&r.off(o,e)}),r}},r}},{"./debounce":4,atoa:3}],6:[function(n,o,e){(function(r){"use strict";var i=n("custom-event"),u=n("./eventmap"),c=r.document,e=function(e,t,n,o){return e.addEventListener(t,n,o)},t=function(e,t,n,o){return e.removeEventListener(t,n,o)},a=[];function l(e,t,n){t=function(e,t,n){var o,r;for(o=0;o $sort, 'order' => $order, 'status' => $status, 'assigned' => $ticket_assigned_filter_id))); @@ -70,7 +84,9 @@ $sql = mysqli_query( LEFT JOIN locations ON ticket_location_id = location_id LEFT JOIN vendors ON ticket_vendor_id = vendor_id LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id + LEFT JOIN categories ON ticket_category = category_id WHERE $ticket_status_snippet " . $ticket_assigned_query . " + $category_snippet AND DATE(ticket_created_at) BETWEEN '$dtf' AND '$dtt' AND (CONCAT(ticket_prefix,ticket_number) LIKE '%$q%' OR client_name LIKE '%$q%' OR ticket_subject LIKE '%$q%' OR ticket_status_name LIKE '%$q%' OR ticket_priority LIKE '%$q%' OR user_name LIKE '%$q%' OR contact_name LIKE '%$q%' OR asset_name LIKE '%$q%' OR vendor_name LIKE '%$q%' OR ticket_vendor_ticket_number LIKE '%q%') $ticket_permission_snippet @@ -99,6 +115,15 @@ $sql_total_tickets_assigned = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS $row = mysqli_fetch_array($sql_total_tickets_assigned); $user_active_assigned_tickets = intval($row['total_tickets_assigned']); +$sql_categories = mysqli_query( + $mysqli, + "SELECT * FROM categories + WHERE category_type = 'Ticket' + ORDER BY category_name" +); + + + ?>