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