diff --git a/agent/css/ticket.css b/agent/css/ticket.css
new file mode 100644
index 00000000..2b88abfe
--- /dev/null
+++ b/agent/css/ticket.css
@@ -0,0 +1,62 @@
+/* Ticket detail page */
+
+/*
+ * Header property fields.
+ *
+ * These used to be a run of inline label/value pairs separated only by a right
+ * margin, which read as one crunched line and collided outright when it wrapped.
+ * A fixed minimum width and a shared gutter give the row a rhythm, so each
+ * value reads as its own field and a wrapped row keeps its spacing.
+ */
+.ticket-fields {
+ display: flex;
+ flex-wrap: wrap;
+ margin: 0 -0.85rem;
+}
+
+.ticket-field {
+ flex: 0 0 auto;
+ min-width: 150px;
+ max-width: 100%;
+ padding: 0 0.85rem;
+ margin-top: 0.85rem;
+}
+
+.ticket-field-label {
+ font-size: 0.72rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #8a94a0;
+ margin-bottom: 0.2rem;
+ white-space: nowrap;
+}
+
+.ticket-field-value {
+ font-size: 0.95rem;
+ line-height: 1.4;
+}
+
+/* The subject is the page's real heading, so let it breathe */
+.ticket-subject {
+ font-weight: 600;
+ word-break: break-word;
+}
+
+/* The pencil next to the subject shouldn't drag the heading's line height around */
+.ticket-subject .btn-tool {
+ font-size: 0.9rem;
+ vertical-align: middle;
+}
+
+.ticket-meta {
+ font-size: 0.85rem;
+ color: #6c757d;
+ word-break: break-word;
+}
+
+@media (max-width: 575px) {
+ .ticket-field {
+ min-width: 45%;
+ }
+}
diff --git a/agent/css/ticket_kanban.css b/agent/css/ticket_kanban.css
index 140c0f9e..19324b45 100644
--- a/agent/css/ticket_kanban.css
+++ b/agent/css/ticket_kanban.css
@@ -1,83 +1,191 @@
-/* General Popover Styling */
-.popover {
- max-width: 600px;
-}
+/* Ticket kanban board */
-/* Kanban Board Container */
-#kanban-board {
+/* Board container - columns scroll sideways, each column scrolls on its own */
+.kanban-board {
display: flex;
+ align-items: flex-start;
+ gap: 12px;
overflow-x: auto;
+ padding-bottom: 12px;
box-sizing: border-box;
- min-width: 400px;
- height: calc(100vh - 210px);
}
-/* Kanban Column */
+/* Column */
.kanban-column {
- flex: 1;
- min-width: 300px;
+ flex: 0 0 300px;
max-width: 300px;
- margin: 0 10px;
- background: #f4f4f4;
- border: 1px solid #ccc;
- border-radius: 4px;
- padding: 10px;
- min-height: calc(100vh - 230px);
- max-height: calc(100vh - 230px);
+ background: #f1f3f5;
+ border: 1px solid #dee2e6;
+ border-radius: 6px;
box-sizing: border-box;
display: flex;
flex-direction: column;
+ max-height: calc(100vh - 300px);
+ min-height: 160px;
}
-/* Column Inner Scrollable Task Area */
-.kanban-status {
- flex: 1;
- overflow-y: auto;
- min-height: 60px;
- position: relative;
- padding: 5px;
- background-color: #f9f9f9;
- border-radius: 4px;
-}
-
-/* Individual Task Cards */
-.task {
+/* Column header - doubles as the drag handle for reordering columns */
+.kanban-column-header {
+ display: flex;
+ align-items: center;
+ padding: 10px 12px;
+ font-weight: 600;
+ font-size: 0.9rem;
+ color: #212529;
background: #fff;
- margin: 5px 0;
- padding: 10px;
- border: 1px solid #ddd;
- border-radius: 4px;
+ border-top: 3px solid #6c757d;
+ border-bottom: 1px solid #dee2e6;
+ border-radius: 5px 5px 0 0;
cursor: grab;
user-select: none;
}
-/* Grabbing Cursor State */
-.task:active {
+.kanban-column-header:active {
cursor: grabbing;
}
-/* Drag Handle (shown on mobile or with class targeting) */
-.drag-handle-class {
- float: right;
- touch-action: none;
- cursor: grab;
+.kanban-column-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-/* Placeholder shown in empty columns */
+/* Scrollable card area */
+.kanban-status {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ min-height: 60px;
+ padding: 8px;
+}
+
+/* Card */
+.kanban-card {
+ background: #fff;
+ margin-bottom: 8px;
+ padding: 10px;
+ border: 1px solid #dee2e6;
+ border-left: 3px solid #adb5bd;
+ border-radius: 4px;
+ cursor: grab;
+ user-select: none;
+ font-size: 0.85rem;
+ line-height: 1.35;
+}
+
+.kanban-card:last-child {
+ margin-bottom: 0;
+}
+
+.kanban-card:hover {
+ border-color: #adb5bd;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
+}
+
+.kanban-card:active {
+ cursor: grabbing;
+}
+
+/* SLA state - the left edge carries it so it reads at a glance down a column */
+.kanban-card-at-risk {
+ border-left-color: #ffc107;
+}
+
+.kanban-card-breached {
+ border-left-color: #dc3545;
+}
+
+/* Card top row - number, priority, SLA flag, drag handle */
+.kanban-card-top {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 6px;
+}
+
+.kanban-card-number {
+ font-weight: 700;
+ color: #343a40;
+ margin-right: auto;
+}
+
+.kanban-card-number:hover {
+ color: #007bff;
+}
+
+.kanban-card-subject {
+ display: block;
+ color: #212529;
+ font-weight: 500;
+ margin-bottom: 4px;
+ word-break: break-word;
+}
+
+.kanban-card-client {
+ color: #495057;
+ margin-bottom: 4px;
+ word-break: break-word;
+}
+
+.kanban-card-meta {
+ color: #6c757d;
+ font-size: 0.8rem;
+ margin-top: 4px;
+ word-break: break-word;
+}
+
+.kanban-card-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px dashed #e9ecef;
+ color: #6c757d;
+ font-size: 0.8rem;
+}
+
+/* Drag handle - hidden until hover, and forced visible on touch by the JS */
+.drag-handle-class {
+ color: #adb5bd;
+ cursor: grab;
+ touch-action: none;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+}
+
+.kanban-card:hover .drag-handle-class {
+ opacity: 1;
+}
+
+/* Empty column placeholder */
.empty-placeholder {
- border: 2px dashed #ccc;
+ border: 2px dashed #ced4da;
background-color: #fcfcfc;
- color: #999;
- font-style: italic;
- padding: 12px;
- margin: 10px 0;
+ color: #adb5bd;
+ padding: 14px;
text-align: center;
border-radius: 4px;
+ font-size: 0.8rem;
pointer-events: none;
}
-/* Sortable drop zone feedback (optional visual cue) */
+/* Drop zone feedback */
.kanban-status.sortable-over {
- background-color: #eaf6ff;
+ background-color: #e7f1ff;
transition: background-color 0.2s ease;
}
+
+/* Popovers on this page can carry a fair amount of ticket detail */
+.popover {
+ max-width: 600px;
+}
+
+@media (max-width: 767px) {
+ .kanban-column {
+ flex-basis: 260px;
+ max-width: 260px;
+ max-height: none;
+ }
+}
diff --git a/agent/js/credential_show_password_via_id.js b/agent/js/credential_show_password_via_id.js
index 5d74f407..846f4f90 100644
--- a/agent/js/credential_show_password_via_id.js
+++ b/agent/js/credential_show_password_via_id.js
@@ -29,14 +29,8 @@ function copyPasswordViaCredentialID(button, credential_id) {
const credential = JSON.parse(data);
navigator.clipboard.writeText(credential.password).then(function() {
- // Flash "Copied!" tooltip, matching the ClipboardJS success handler in app.js
- jQuery(button).tooltip('hide')
- .attr('data-original-title', 'Copied!')
- .tooltip('show');
-
- setTimeout(function() {
- jQuery(button).tooltip('hide');
- }, 1000);
+ // Same "Copied!" flash the ClipboardJS handler in app.js uses
+ flashTooltip(button, 'Copied!');
});
}
);
diff --git a/agent/js/tickets_kanban.js b/agent/js/tickets_kanban.js
index c614d057..86186301 100644
--- a/agent/js/tickets_kanban.js
+++ b/agent/js/tickets_kanban.js
@@ -1,13 +1,10 @@
$(document).ready(function () {
- console.log('CONFIG_TICKET_MOVING_COLUMNS:', CONFIG_TICKET_MOVING_COLUMNS);
- console.log('CONFIG_TICKET_ORDERING:', CONFIG_TICKET_ORDERING);
-
// -------------------------------
// Drag: Kanban Columns (Statuses)
// -------------------------------
new Sortable(document.querySelector('#kanban-board'), {
animation: 150,
- handle: '.panel-title',
+ handle: '.kanban-column-header',
draggable: '.kanban-column',
onEnd: function () {
const columnPositions = Array.from(document.querySelectorAll('#kanban-board .kanban-column')).map((col, index) => ({
@@ -19,8 +16,6 @@ $(document).ready(function () {
$.post('ajax.php', {
update_kanban_status_position: true,
positions: columnPositions
- }).done(() => {
- console.log('Ticket status kanban orders updated.');
}).fail((xhr) => {
console.error('Error updating status order:', xhr.responseText);
});
@@ -35,6 +30,9 @@ $(document).ready(function () {
new Sortable(statusCol, {
group: 'tickets',
animation: 150,
+ // Let the ticket number / subject links work without starting a drag
+ filter: 'a',
+ preventOnFilter: false,
handle: isTouchDevice() ? '.drag-handle-class' : undefined,
onStart: () => hidePlaceholders(),
onEnd: function (evt) {
@@ -69,8 +67,6 @@ $(document).ready(function () {
$.post('ajax.php', {
update_kanban_ticket: true,
positions: positions
- }).done(() => {
- console.log('Updated kanban ticket positions.');
}).fail((xhr) => {
console.error('Error updating ticket positions:', xhr.responseText);
});
diff --git a/agent/modals/ticket/ticket_export.php b/agent/modals/ticket/ticket_export.php
index 2493028c..9c18a8f7 100644
--- a/agent/modals/ticket/ticket_export.php
+++ b/agent/modals/ticket/ticket_export.php
@@ -16,7 +16,11 @@ $q_filter = $_GET['q'] ?? '';
// Status Filter - the page uses an ID set, or the Open / Closed shorthand
$status_filter = (isset($_GET['status']) && is_array($_GET['status'])) ? array_map('intval', $_GET['status']) : [];
-$status_shorthand = (isset($_GET['status']) && !is_array($_GET['status'])) ? $_GET['status'] : '';
+// Coarse open / closed / all state. `status` scalar is the pre-rework name.
+$state_filter = $_GET['state'] ?? '';
+if (!in_array($state_filter, array('open', 'closed', 'all'), true)) {
+ $state_filter = (isset($_GET['status']) && !is_array($_GET['status']) && strtolower($_GET['status']) === 'closed') ? 'closed' : 'open';
+}
// Client Filter
$client_filter = intval($_GET['client'] ?? 0);
@@ -30,6 +34,9 @@ $assigned_filter = intval($_GET['assigned'] ?? 0);
// SLA Filter
$sla_filter = $_GET['sla'] ?? '';
+// Billing Filter
+$billing_filter = $_GET['billing'] ?? '';
+
// Date Filter - the all-time sentinels from filter_header.php leave the fields blank
$date_from_filter = (!empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01') ? escapeHtml($_GET['dtf']) : '';
$date_to_filter = (!empty($_GET['dtt']) && $_GET['dtt'] !== '2099-12-31') ? escapeHtml($_GET['dtt']) : '';
@@ -94,12 +101,30 @@ ob_start();
+ = 2) { ?>
+
+
+
diff --git a/agent/post/ticket.php b/agent/post/ticket.php
index d77c194b..976303a6 100644
--- a/agent/post/ticket.php
+++ b/agent/post/ticket.php
@@ -2742,19 +2742,28 @@ if (isset($_POST['export_tickets'])) {
} elseif (!empty($_POST['resolution']) && $_POST['resolution'] == 'Closed') {
$ticket_status_snippet = "ticket_resolved_at IS NOT NULL";
$filter_summary['Status'] = 'Closed';
+ } elseif (!empty($_POST['resolution']) && $_POST['resolution'] == 'All') {
+ $ticket_status_snippet = "1 = 1";
+ $filter_summary['Status'] = 'Open and closed';
} else {
// Default - open tickets
$ticket_status_snippet = "ticket_resolved_at IS NULL";
$filter_summary['Status'] = 'Open';
}
- // Billable / unbilled Filter - overrides the status snippet, same as the page
- if (isset($_POST['billable']) && $_POST['billable'] == 1 && isset($_POST['unbilled'])) {
- $ticket_billable_snippet = "AND ticket_billable = 1 AND ticket_invoice_id = 0";
- $ticket_status_snippet = '1 = 1';
- $filter_summary['Billable'] = 'Billable, not yet invoiced';
- } else {
- $ticket_billable_snippet = '';
+ // Billing Filter - same values the tickets page offers
+ $ticket_billable_snippet = '';
+ if (!empty($_POST['billing'])) {
+ if ($_POST['billing'] == 'unbilled') {
+ $ticket_billable_snippet = "AND ticket_billable = 1 AND ticket_invoice_id = 0";
+ $filter_summary['Billing'] = 'Billable, not invoiced';
+ } elseif ($_POST['billing'] == 'invoiced') {
+ $ticket_billable_snippet = "AND ticket_invoice_id > 0";
+ $filter_summary['Billing'] = 'Invoiced';
+ } elseif ($_POST['billing'] == 'nonbillable') {
+ $ticket_billable_snippet = "AND ticket_billable = 0";
+ $filter_summary['Billing'] = 'Not billable';
+ }
}
// Category Filter
diff --git a/agent/ticket.php b/agent/ticket.php
index 10e3b63c..5ebe78f7 100644
--- a/agent/ticket.php
+++ b/agent/ticket.php
@@ -55,69 +55,65 @@ if (isset($_GET['ticket_id'])) {
require_once "../includes/footer.php";
} else {
- $row = mysqli_fetch_assoc($sql);
- $client_id = intval($row['client_id']);
- $client_name = escapeHtml($row['client_name']);
- $client_type = escapeHtml($row['client_type']);
- $client_website = escapeHtml($row['client_website']);
+ $ticket = mysqli_fetch_assoc($sql);
- $client_net_terms = intval($row['client_net_terms']);
+ $client_id = intval($ticket['client_id']);
+ $client_name = escapeHtml($ticket['client_name']);
+
+ $client_net_terms = intval($ticket['client_net_terms']);
if ($client_net_terms == 0) {
$client_net_terms = $config_default_net_terms;
}
- $client_rate = floatval($row['client_rate']);
-
- $ticket_prefix = escapeHtml($row['ticket_prefix']);
- $ticket_number = intval($row['ticket_number']);
- $ticket_source = escapeHtml($row['ticket_source']);
- $ticket_category = intval($row['ticket_category']);
- $ticket_category_display = escapeHtml($row['category_name']);
- $ticket_subject = escapeHtml($row['ticket_subject']);
- $ticket_details = $purifier->purify($row['ticket_details']);
- $ticket_priority = escapeHtml($row['ticket_priority']);
- $ticket_billable = intval($row['ticket_billable']);
- $ticket_scheduled_for = escapeHtml($row['ticket_schedule']);
- $ticket_onsite = intval($row['ticket_onsite']);
+ $ticket_prefix = escapeHtml($ticket['ticket_prefix']);
+ $ticket_number = intval($ticket['ticket_number']);
+ $ticket_reference = "$ticket_prefix$ticket_number";
+ $ticket_source = escapeHtml($ticket['ticket_source']);
+ $ticket_category = intval($ticket['ticket_category']);
+ $ticket_category_display = escapeHtml($ticket['category_name']);
+ $ticket_subject = escapeHtml($ticket['ticket_subject']);
+ $ticket_details = $purifier->purify($ticket['ticket_details']);
+ $ticket_priority = escapeHtml($ticket['ticket_priority']);
+ $ticket_billable = intval($ticket['ticket_billable']);
+ $ticket_scheduled_for = escapeHtml($ticket['ticket_schedule']);
+ $ticket_onsite = intval($ticket['ticket_onsite']);
if ($ticket_scheduled_for) {
- $ticket_scheduled_wording = "$ticket_scheduled_for";
+ $ticket_scheduled_wording = date('M d, Y • g:i A', strtotime($ticket_scheduled_for));
} else {
- $ticket_scheduled_wording = "Add";
+ $ticket_scheduled_wording = "Not scheduled";
}
- //Set Ticket Badge Color based of priority
+ // Set Ticket Badge Color based of priority
if ($ticket_priority == "Urgent") {
- $ticket_priority_display = "
$ticket_priority";
+ $ticket_priority_color = "dark";
} elseif ($ticket_priority == "High") {
- $ticket_priority_display = "
$ticket_priority";
+ $ticket_priority_color = "danger";
} elseif ($ticket_priority == "Medium") {
- $ticket_priority_display = "
$ticket_priority";
- } elseif ($ticket_priority == "Low") {
- $ticket_priority_display = "
$ticket_priority";
+ $ticket_priority_color = "warning";
} else {
- $ticket_priority_display = "";
+ $ticket_priority_color = "info";
}
- $ticket_feedback = escapeHtml($row['ticket_feedback']);
+ $ticket_feedback = escapeHtml($ticket['ticket_feedback']);
- $ticket_status = intval($row['ticket_status_id']);
- $ticket_status_id = intval($row['ticket_status_id']);
- $ticket_status_name = escapeHtml($row['ticket_status_name']);
- $ticket_status_color = escapeHtml($row['ticket_status_color']);
+ $ticket_status = intval($ticket['ticket_status_id']);
+ $ticket_status_id = intval($ticket['ticket_status_id']);
+ $ticket_status_name = escapeHtml($ticket['ticket_status_name']);
+ $ticket_status_color = escapeHtml($ticket['ticket_status_color']);
- $ticket_vendor_ticket_number = escapeHtml($row['ticket_vendor_ticket_number']);
- $ticket_created_at = escapeHtml($row['ticket_created_at']);
- $ticket_created_at_ago = timeAgo($row['ticket_created_at']);
- $ticket_created_by = intval($row['ticket_created_by']);
+ $ticket_vendor_ticket_number = escapeHtml($ticket['ticket_vendor_ticket_number']);
+ $ticket_created_at = escapeHtml($ticket['ticket_created_at']);
+ $ticket_created_at_ago = timeAgo($ticket['ticket_created_at']);
+ $ticket_created_by = intval($ticket['ticket_created_by']);
$ticket_date = date('Y-m-d', strtotime($ticket_created_at));
- $ticket_updated_at = escapeHtml($row['ticket_updated_at']);
- $ticket_updated_at_ago = timeAgo($row['ticket_updated_at']);
- $ticket_first_response_at = escapeHtml($row['ticket_first_response_at']);
- $ticket_sla_id = intval($row['ticket_sla_id']);
- $ticket_response_due_at = escapeHtml($row['ticket_response_due_at']);
- $ticket_resolution_due_at = escapeHtml($row['ticket_resolution_due_at']);
- $ticket_response_sla_met = $row['ticket_response_sla_met'];
- $ticket_resolution_sla_met = $row['ticket_resolution_sla_met'];
- $ticket_sla_paused = intval($row['ticket_status_pauses_sla']);
+ $ticket_updated_at = escapeHtml($ticket['ticket_updated_at']);
+ $ticket_updated_at_ago = timeAgo($ticket['ticket_updated_at']);
+ $ticket_first_response_at = escapeHtml($ticket['ticket_first_response_at']);
+ $ticket_sla_id = intval($ticket['ticket_sla_id']);
+ $ticket_response_due_at = escapeHtml($ticket['ticket_response_due_at']);
+ $ticket_resolution_due_at = escapeHtml($ticket['ticket_resolution_due_at']);
+ $ticket_response_sla_met = $ticket['ticket_response_sla_met'];
+ $ticket_resolution_sla_met = $ticket['ticket_resolution_sla_met'];
+ $ticket_sla_paused = intval($ticket['ticket_status_pauses_sla']);
$ticket_sla_name = "None";
if ($ticket_sla_id) {
$sla_name_sql = mysqli_query($mysqli, "SELECT sla_name FROM slas WHERE sla_id = $ticket_sla_id");
@@ -125,152 +121,150 @@ if (isset($_GET['ticket_id'])) {
$ticket_sla_name = escapeHtml(mysqli_fetch_assoc($sla_name_sql)['sla_name']);
}
}
- $ticket_resolved_at = escapeHtml($row['ticket_resolved_at']);
- $ticket_resolved_at_ago = timeAgo($row['ticket_resolved_at']);
- $ticket_resolved_date = date('Y-m-d', strtotime($ticket_resolved_at));
- $ticket_closed_at = escapeHtml($row['ticket_closed_at']);
- $ticket_closed_at_ago = timeAgo($row['ticket_closed_at']);
- $ticket_closed_date = date('Y-m-d', strtotime($ticket_closed_at));
- $ticket_closed_by = intval($row['ticket_closed_by']);
+ $ticket_resolved_at = escapeHtml($ticket['ticket_resolved_at']);
+ $ticket_resolved_at_ago = timeAgo($ticket['ticket_resolved_at']);
+ $ticket_closed_at = escapeHtml($ticket['ticket_closed_at']);
+ $ticket_closed_at_ago = timeAgo($ticket['ticket_closed_at']);
+ $ticket_closed_by = intval($ticket['ticket_closed_by']);
- $ticket_assigned_to = intval($row['ticket_assigned_to']);
+ // The three states the page keys its controls off
+ $ticket_is_closed = !empty($ticket_closed_at);
+ $ticket_is_resolved = !empty($ticket_resolved_at);
+ $ticket_is_open = !$ticket_is_resolved && !$ticket_is_closed;
+ $can_edit_ticket = lookupUserPermission("module_support") >= 2;
+
+ $ticket_assigned_to = intval($ticket['ticket_assigned_to']);
if (empty($ticket_assigned_to)) {
- $ticket_assigned_to_display = "
Unassigned";
+ $ticket_assigned_to_display = "
Unassigned";
} else {
- $ticket_assigned_to_display = escapeHtml($row['user_name']);
+ $ticket_assigned_to_display = escapeHtml($ticket['user_name']);
}
// Tab Title // No Sanitizing needed
- $page_title = $row['ticket_subject'];
- $tab_title = "{$row['ticket_prefix']}{$row['ticket_number']}";
+ $page_title = $ticket['ticket_subject'];
+ $tab_title = "{$ticket['ticket_prefix']}{$ticket['ticket_number']}";
- $contact_id = intval($row['contact_id']);
- $contact_name = escapeHtml($row['contact_name']);
- $contact_title = escapeHtml($row['contact_title']);
- $contact_email = escapeHtml($row['contact_email']);
- $contact_phone_country_code = escapeHtml($row['contact_phone_country_code']);
- $contact_phone = escapeHtml(formatPhoneNumber($row['contact_phone'], $contact_phone_country_code));
- $contact_extension = escapeHtml($row['contact_extension']);
- $contact_mobile_country_code = escapeHtml($row['contact_mobile_country_code']);
- $contact_mobile = escapeHtml(formatPhoneNumber($row['contact_mobile'], $contact_mobile_country_code));
+ $contact_id = intval($ticket['contact_id']);
+ $contact_name = escapeHtml($ticket['contact_name']);
+ $contact_title = escapeHtml($ticket['contact_title']);
+ $contact_email = escapeHtml($ticket['contact_email']);
+ $contact_phone_country_code = escapeHtml($ticket['contact_phone_country_code']);
+ $contact_phone = escapeHtml(formatPhoneNumber($ticket['contact_phone'], $contact_phone_country_code));
+ $contact_extension = escapeHtml($ticket['contact_extension']);
+ $contact_mobile_country_code = escapeHtml($ticket['contact_mobile_country_code']);
+ $contact_mobile = escapeHtml(formatPhoneNumber($ticket['contact_mobile'], $contact_mobile_country_code));
- $asset_id = intval($row['asset_id']);
- $asset_ip = escapeHtml($row['interface_ip']);
- $asset_name = escapeHtml($row['asset_name']);
- $asset_type = escapeHtml($row['asset_type']);
- $asset_uri = escapeHtml($row['asset_uri']);
- $asset_make = escapeHtml($row['asset_make']);
- $asset_model = escapeHtml($row['asset_model']);
- $asset_serial = escapeHtml($row['asset_serial']);
- $asset_os = escapeHtml($row['asset_os']);
- $asset_warranty_expire = escapeHtml($row['asset_warranty_expire']);
+ $asset_id = intval($ticket['asset_id']);
+ $asset_name = escapeHtml($ticket['asset_name']);
+ $asset_type = escapeHtml($ticket['asset_type']);
$asset_icon = getAssetIcon($asset_type);
- $vendor_id = intval($row['ticket_vendor_id']);
- $vendor_name = escapeHtml($row['vendor_name']);
- $vendor_description = escapeHtml($row['vendor_description']);
- $vendor_account_number = escapeHtml($row['vendor_account_number']);
- $vendor_contact_name = escapeHtml($row['vendor_contact_name']);
- $vendor_phone_country_code = escapeHtml($row['vendor_phone_country_code']);
- $vendor_phone = escapeHtml(formatPhoneNumber($row['vendor_phone'], $vendor_phone_country_code));
- $vendor_extension = escapeHtml($row['vendor_extension']);
- $vendor_email = escapeHtml($row['vendor_email']);
- $vendor_website = escapeHtml($row['vendor_website']);
- $vendor_hours = escapeHtml($row['vendor_hours']);
- $vendor_sla = escapeHtml($row['vendor_sla']);
- $vendor_code = escapeHtml($row['vendor_code']);
- $vendor_notes = escapeHtml($row['vendor_notes']);
+ $vendor_id = intval($ticket['ticket_vendor_id']);
+ $vendor_name = escapeHtml($ticket['vendor_name']);
+ $vendor_contact_name = escapeHtml($ticket['vendor_contact_name']);
+ $vendor_phone_country_code = escapeHtml($ticket['vendor_phone_country_code']);
+ $vendor_phone = escapeHtml(formatPhoneNumber($ticket['vendor_phone'], $vendor_phone_country_code));
+ $vendor_email = escapeHtml($ticket['vendor_email']);
+ $vendor_website = escapeHtml($ticket['vendor_website']);
- $location_id = intval($row['location_id']);
- $location_name = escapeHtml($row['location_name']);
- $location_address = escapeHtml($row['location_address']);
- $location_city = escapeHtml($row['location_city']);
- $location_state = escapeHtml($row['location_state']);
- $location_zip = escapeHtml($row['location_zip']);
- $location_phone = formatPhoneNumber($row['location_phone']);
+ $location_id = intval($ticket['location_id']);
+ $location_name = escapeHtml($ticket['location_name']);
+ $location_phone = formatPhoneNumber($ticket['location_phone']);
- $quote_id = intval($row['ticket_quote_id']);
- $quote_prefix = escapeHtml($row['quote_prefix']);
- $quote_number = intval($row['quote_number']);
- $quote_created_at = escapeHtml($row['quote_created_at']);
+ $quote_id = intval($ticket['ticket_quote_id']);
+ $quote_prefix = escapeHtml($ticket['quote_prefix']);
+ $quote_number = intval($ticket['quote_number']);
+ // The quote row may have been deleted out from under the ticket
+ $quote_reference = $quote_number ? "$quote_prefix$quote_number" : "#$quote_id";
- $invoice_id = intval($row['ticket_invoice_id']);
- $invoice_prefix = escapeHtml($row['invoice_prefix']);
- $invoice_number = intval($row['invoice_number']);
- $invoice_created_at = escapeHtml($row['invoice_created_at']);
+ $invoice_id = intval($ticket['ticket_invoice_id']);
+ $invoice_prefix = escapeHtml($ticket['invoice_prefix']);
+ $invoice_number = intval($ticket['invoice_number']);
+ // Same for the invoice - fall back to the id rather than rendering a bare 0
+ $invoice_reference = $invoice_number ? "$invoice_prefix$invoice_number" : "#$invoice_id";
- $project_id = intval($row['project_id']);
- $project_prefix = escapeHtml($row['project_prefix']);
- $project_number = intval($row['project_number']);
- $project_name = escapeHtml($row['project_name']);
- $project_description = escapeHtml($row['project_description']);
- $project_due = escapeHtml($row['project_due']);
- $project_manager = escapeHtml($row['project_manager']);
-
- if($project_manager) {
- $sql_project_manager = mysqli_query($mysqli,"SELECT * FROM users WHERE user_id = $project_manager");
- $row = mysqli_fetch_assoc($sql_project_manager);
- $project_manager_name = escapeHtml($row['user_name']);
+ $project_id = intval($ticket['project_id']);
+ $project_name = escapeHtml($ticket['project_name']);
+ $project_manager = intval($ticket['project_manager']);
+ $project_manager_name = '';
+ if ($project_manager) {
+ $project_manager_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name FROM users WHERE user_id = $project_manager"));
+ $project_manager_name = escapeHtml($project_manager_row['user_name']);
}
+ $ticket_created_by_display = '';
+ if ($ticket_created_by) {
+ $created_by_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name FROM users WHERE user_id = $ticket_created_by"));
+ $ticket_created_by_display = escapeHtml($created_by_row['user_name']);
+ }
+
+ $ticket_closed_by_display = '';
+ if ($ticket_is_closed) {
+ $ticket_closed_by_display = 'User';
+ if (!empty($ticket_closed_by)) {
+ $closed_by_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name FROM users WHERE user_id = $ticket_closed_by"));
+ $ticket_closed_by_display = escapeHtml($closed_by_row['user_name']);
+ }
+ }
+
+ /*
+ * How much else this contact has open. The old queries compared the
+ * integer ticket_status column against the string 'Closed', which MySQL
+ * casts to 0 - so "open" matched every ticket and "closed" matched none.
+ * Resolution state lives in ticket_resolved_at, same as the ticket list.
+ */
+ $contact_open_tickets = 0;
if ($contact_id) {
- //Get Contact Ticket Stats
- $ticket_related_open = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS ticket_related_open FROM tickets WHERE ticket_status != 'Closed' AND ticket_contact_id = $contact_id ");
- $row = mysqli_fetch_assoc($ticket_related_open);
- $ticket_related_open = intval($row['ticket_related_open']);
-
- $ticket_related_closed = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS ticket_related_closed FROM tickets WHERE ticket_status = 'Closed' AND ticket_contact_id = $contact_id ");
- $row = mysqli_fetch_assoc($ticket_related_closed);
- $ticket_related_closed = intval($row['ticket_related_closed']);
-
- $ticket_related_total = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS ticket_related_total FROM tickets WHERE ticket_contact_id = $contact_id ");
- $row = mysqli_fetch_assoc($ticket_related_total);
- $ticket_related_total = intval($row['ticket_related_total']);
+ $contact_open_tickets = intval(mysqli_fetch_row(mysqli_query(
+ $mysqli,
+ "SELECT COUNT(ticket_id) FROM tickets
+ WHERE ticket_contact_id = $contact_id
+ AND ticket_id != $ticket_id
+ AND ticket_resolved_at IS NULL"
+ ))[0]);
}
- //Get Total Ticket Time
- $ticket_total_reply_time = mysqli_query($mysqli, "SELECT SEC_TO_TIME(SUM(TIME_TO_SEC(ticket_reply_time_worked))) AS ticket_total_reply_time FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND ticket_reply_ticket_id = $ticket_id");
- $row = mysqli_fetch_assoc($ticket_total_reply_time);
- $ticket_total_reply_time = escapeHtml($row['ticket_total_reply_time']);
-
- // Get the number of ticket Responses
- $ticket_responses_sql = mysqli_query($mysqli, "SELECT COUNT(ticket_reply_id) AS ticket_responses FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND ticket_reply_ticket_id = $ticket_id");
- $row = mysqli_fetch_assoc($ticket_responses_sql);
- $ticket_responses = intval($row['ticket_responses']);
-
- $ticket_all_comments_sql = mysqli_query($mysqli, "SELECT COUNT(ticket_reply_id) AS ticket_all_comments_count FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND ticket_reply_ticket_id = $ticket_id");
- $row = mysqli_fetch_assoc($ticket_all_comments_sql);
- $ticket_all_comments_count = intval($row['ticket_all_comments_count']);
-
- $ticket_internal_notes_sql = mysqli_query($mysqli, "SELECT COUNT(ticket_reply_id) AS ticket_internal_notes_count FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND ticket_reply_type = 'Internal' AND ticket_reply_ticket_id = $ticket_id");
- $row = mysqli_fetch_assoc($ticket_internal_notes_sql);
- $ticket_internal_notes_count = intval($row['ticket_internal_notes_count']);
-
- $ticket_public_comments_sql = mysqli_query($mysqli, "SELECT COUNT(ticket_reply_id) AS ticket_public_comments_count FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND (ticket_reply_type = 'Public' OR ticket_reply_type = 'Client') AND ticket_reply_ticket_id = $ticket_id");
- $row = mysqli_fetch_assoc($ticket_public_comments_sql);
- $ticket_public_comments_count = intval($row['ticket_public_comments_count']);
-
- $ticket_events_sql = mysqli_query($mysqli, "SELECT COUNT(log_id) AS ticket_events_count FROM logs WHERE log_type = 'Ticket' AND log_entity_id = $ticket_id");
- $row = mysqli_fetch_assoc($ticket_events_sql);
- $ticket_events_count = intval($row['ticket_events_count']);
-
-
- // Get & format asset warranty expiry
- $date = date('Y-m-d H:i:s');
- $dt_value = $asset_warranty_expire; //sample date
- $warranty_check = date('m/d/Y', strtotime('-8 hours'));
- if ($dt_value <= $date) {
- $dt_value = "Expired on $asset_warranty_expire";
- $warranty_status_color = 'red';
- } else {
- $warranty_status_color = 'green';
+ // Reply counts, one pass - these drive the conversation filter
+ $reply_counts = array('total' => 0, 'public' => 0, 'internal' => 0);
+ $sql_reply_counts = mysqli_query(
+ $mysqli,
+ "SELECT ticket_reply_type, COUNT(ticket_reply_id) AS reply_count
+ FROM ticket_replies
+ WHERE ticket_reply_archived_at IS NULL AND ticket_reply_ticket_id = $ticket_id
+ GROUP BY ticket_reply_type"
+ );
+ while ($reply_count_row = mysqli_fetch_assoc($sql_reply_counts)) {
+ $reply_count = intval($reply_count_row['reply_count']);
+ $reply_counts['total'] += $reply_count;
+ if ($reply_count_row['ticket_reply_type'] == 'Internal') {
+ $reply_counts['internal'] += $reply_count;
+ } else {
+ $reply_counts['public'] += $reply_count;
+ }
}
- if ($asset_warranty_expire == "NULL") {
- $dt_value = "None";
- $warranty_status_color = 'red';
- }
+ // Get Total Ticket Time
+ $ticket_total_reply_time = escapeHtml(mysqli_fetch_assoc(mysqli_query(
+ $mysqli,
+ "SELECT SEC_TO_TIME(SUM(TIME_TO_SEC(ticket_reply_time_worked))) AS ticket_total_reply_time
+ FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND ticket_reply_ticket_id = $ticket_id"
+ ))['ticket_total_reply_time']);
+ /*
+ * Agents who have worked this ticket. The old query joined every reply's
+ * ticket_reply_by against users, but a Client reply stores a CONTACT id
+ * there - so any contact whose id collided with a user id showed up as a
+ * collaborator. That is the bug the old commented-out block ran into.
+ */
+ $ticket_collaborators = escapeHtml(mysqli_fetch_assoc(mysqli_query(
+ $mysqli,
+ "SELECT GROUP_CONCAT(DISTINCT user_name ORDER BY user_name SEPARATOR ', ') AS user_names
+ FROM ticket_replies
+ INNER JOIN users ON ticket_reply_by = user_id
+ WHERE ticket_reply_archived_at IS NULL
+ AND ticket_reply_ticket_id = $ticket_id
+ AND ticket_reply_type != 'Client'"
+ ))['user_names']);
// Get ticket replies
$sql_ticket_replies = mysqli_query($mysqli, "SELECT * FROM ticket_replies
@@ -281,26 +275,29 @@ if (isset($_GET['ticket_id'])) {
ORDER BY ticket_reply_id DESC"
);
- // Get ticket Events
- $sql_ticket_events = mysqli_query($mysqli, "SELECT * FROM ticket_history
- WHERE ticket_history_ticket_id = $ticket_id
- ORDER BY ticket_history_id DESC"
- );
-
- // Get Technicians to assign the ticket to
- $sql_assign_to_select = mysqli_query(
+ /*
+ * Every attachment on the ticket in one query, split by reply. The page
+ * used to fire one attachment query per reply as it rendered.
+ */
+ $ticket_attachments = array(); // attachments on the ticket itself
+ $reply_attachments = array(); // attachments keyed by reply id
+ $sql_all_attachments = mysqli_query(
$mysqli,
- "SELECT user_id, user_name FROM users
- WHERE user_role_id > 1
- AND user_type = 1
- AND user_status = 1
- AND user_archived_at IS NULL
- ORDER BY user_name ASC"
+ "SELECT ticket_attachment_id, ticket_attachment_name, ticket_attachment_reply_id
+ FROM ticket_attachments WHERE ticket_attachment_ticket_id = $ticket_id"
);
-
+ while ($attachment_row = mysqli_fetch_assoc($sql_all_attachments)) {
+ $attachment_reply_id = intval($attachment_row['ticket_attachment_reply_id']);
+ if ($attachment_reply_id) {
+ $reply_attachments[$attachment_reply_id][] = $attachment_row;
+ } else {
+ $ticket_attachments[] = $attachment_row;
+ }
+ }
// Get Watchers
$sql_ticket_watchers = mysqli_query($mysqli, "SELECT * FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id ORDER BY watcher_email DESC");
+ $watcher_count = mysqli_num_rows($sql_ticket_watchers);
// Get Additional Assets
$sql_additional_assets = mysqli_query($mysqli, "SELECT * FROM assets, ticket_assets
@@ -309,115 +306,212 @@ if (isset($_GET['ticket_id'])) {
AND assets.asset_id != $asset_id"
);
- // Get Ticket Attachments
- $sql_ticket_attachments = mysqli_query(
- $mysqli,
- "SELECT * FROM ticket_attachments
- WHERE ticket_attachment_reply_id IS NULL
- AND ticket_attachment_ticket_id = $ticket_id"
- );
-
-
// Get Tasks
- $sql_tasks = mysqli_query( $mysqli, "SELECT * FROM tasks WHERE task_ticket_id = $ticket_id ORDER BY task_order ASC, task_id ASC");
+ $sql_tasks = mysqli_query($mysqli, "SELECT * FROM tasks WHERE task_ticket_id = $ticket_id ORDER BY task_order ASC, task_id ASC");
$task_count = mysqli_num_rows($sql_tasks);
- // Get Completed Task Count
- $sql_tasks_completed = mysqli_query($mysqli,
- "SELECT * FROM tasks
- WHERE task_ticket_id = $ticket_id
- AND task_completed_at IS NOT NULL"
- );
- $completed_task_count = mysqli_num_rows($sql_tasks_completed);
+ $completed_task_count = intval(mysqli_fetch_row(mysqli_query(
+ $mysqli,
+ "SELECT COUNT(task_id) FROM tasks WHERE task_ticket_id = $ticket_id AND task_completed_at IS NOT NULL"
+ ))[0]);
// Tasks Completed Percent
+ $tasks_completed_percent = 0;
if ($task_count) {
$tasks_completed_percent = round(($completed_task_count / $task_count) * 100);
}
- // Get all Assigned ticket Users as a comma-separated string
- $sql_ticket_collaborators = mysqli_query($mysqli, "
- SELECT GROUP_CONCAT(DISTINCT user_name SEPARATOR ', ') AS user_names
- FROM users
- LEFT JOIN ticket_replies ON user_id = ticket_reply_by
- WHERE ticket_reply_archived_at IS NULL AND ticket_reply_ticket_id = $ticket_id
- ");
+ // Tasks still open block resolving the ticket - the page says so rather than just hiding the button
+ $tasks_block_resolve = $task_count !== $completed_task_count;
- // Fetch the result
- $row = mysqli_fetch_assoc($sql_ticket_collaborators);
+ /*
+ * All approvals for this ticket's tasks in one query. The task loop used
+ * to run two approval queries per row.
+ */
+ $task_approvals = array();
+ if ($task_count) {
+ $sql_task_approvals = mysqli_query(
+ $mysqli,
+ "SELECT approval_id, approval_task_id, approval_scope, approval_type,
+ approval_required_user_id, approval_created_by, approval_status
+ FROM task_approvals
+ INNER JOIN tasks ON approval_task_id = task_id
+ WHERE task_ticket_id = $ticket_id
+ AND approval_status IN ('pending','declined')"
+ );
+ while ($approval_row = mysqli_fetch_assoc($sql_task_approvals)) {
+ $task_approvals[intval($approval_row['approval_task_id'])][] = $approval_row;
+ }
+ }
- // The user names in a comma-separated string
- $ticket_collaborators = escapeHtml($row['user_names']);
+ /*
+ * Ticket history. agent/post/ticket.php has been writing priority, SLA,
+ * contact, watcher and asset changes here all along - the page fetched
+ * the rows and then never rendered them.
+ */
+ $sql_ticket_history = mysqli_query($mysqli, "SELECT ticket_history_description, ticket_history_created_at
+ FROM ticket_history
+ WHERE ticket_history_ticket_id = $ticket_id
+ ORDER BY ticket_history_id DESC"
+ );
+ $ticket_history_count = mysqli_num_rows($sql_ticket_history);
+
+ /*
+ * The single most useful thing on the page: which clock is running and
+ * how long is left. Everything else about the SLA is detail.
+ */
+ $sla_state = ''; // breached | at_risk | paused | met | running
+ $sla_label = '';
+ $sla_class = '';
+ $sla_icon = 'fa-stopwatch';
+ if ($ticket_sla_id) {
+ $sla_next_due = empty($ticket_first_response_at) ? $ticket_response_due_at : $ticket_resolution_due_at;
+ $sla_next_label = empty($ticket_first_response_at) ? 'First response due' : 'Resolution due';
+ $ticket_sla_alert_stage = max(intval($ticket['ticket_response_sla_alert_stage']), intval($ticket['ticket_resolution_sla_alert_stage']));
+ $sla_breached = (!is_null($ticket_response_sla_met) && !$ticket_response_sla_met)
+ || (!is_null($ticket_resolution_sla_met) && !$ticket_resolution_sla_met)
+ || $ticket_sla_alert_stage == 2;
+
+ if ($sla_breached) {
+ $sla_state = 'breached';
+ $sla_label = 'SLA breached';
+ $sla_class = 'badge-danger';
+ $sla_icon = 'fa-exclamation-triangle';
+ } elseif (!$ticket_is_open) {
+ $sla_state = 'met';
+ $sla_label = 'SLA met';
+ $sla_class = 'badge-success';
+ $sla_icon = 'fa-check';
+ } elseif ($ticket_sla_paused) {
+ $sla_state = 'paused';
+ $sla_label = 'SLA paused';
+ $sla_class = 'badge-secondary';
+ $sla_icon = 'fa-pause';
+ } elseif (!empty($sla_next_due)) {
+ $sla_overdue = strtotime($sla_next_due) < time();
+ $sla_state = $sla_overdue ? 'breached' : 'running';
+ $sla_label = $sla_next_label . ' ' . timeAgo($sla_next_due);
+ $sla_class = $sla_overdue ? 'badge-danger' : 'badge-light border';
+ }
+ }
?>
-
-
- -
- All Tickets
-
-
- -
- = $client_name ?> Tickets
-
-
- - = "$ticket_prefix$ticket_number" ?>
-
+
+
+
+
+
-