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'; + } + } ?> - - + + + + +
-
-
-
- -
-
Ticket - - - -
-
-
-
+
- = 2) { ?> -
-
+
- = 2) { ?> - - Quote - - - if ($config_module_enable_accounting && $ticket_billable == 1 && empty($invoice_id) && lookupUserPermission("module_sales") >= 2) { ?> - - Invoice + + +

+ + + + - +

- if (empty($ticket_closed_at)) { ?> + +
+ - - - Reopen + + + + No client + + + + + + + + · + ·Opened + + ·via + + ·Updated + + +
+ +
+ + + = 2; + $show_invoice_button = $config_module_enable_accounting && $ticket_billable == 1 && empty($invoice_id) && lookupUserPermission("module_sales") >= 2; + $has_action_buttons = $show_quote_button || $show_invoice_button || !$ticket_is_closed; + ?> + - - -
-
- -
- -
- - -
- Updated: ($ticket_updated_at_ago)" ?> -
- - - -
- Agent: - - - -
- -
- Agent: -
- - -
- -
-
- -
- - - - - - -
- Scheduled: -
- - - - - = 1) { ?> - - -
- Quoted: -
- -
- Invoiced: +
+ + +
+ + - -
- -
Tasks
-
-
+
+
Assigned to
+
+ + + + + +
- -
+
+
Scheduled
+
+ + + Not scheduled + + + Not scheduled' ?> + +
+
+ + = 1) { ?> +
+
Billing
+
+ + + + + + + Billable" : "Not billable" ?> + + + Not billable' ?> + +
+
+ + + +
+
Time logged
+
+
+ + + +
+
Tasks
+
+
+
+ +
+
+
+ + +
+ +
-
+
+
-
-
- +
Description
- +
-
- - - - - - - - - - -
- [View][Download]= 3) { ?>'>[Delete] -
+ +
+ +
+ + View + Download + = 3) { ?> + Delete + +
+ +
+
-
- - = 2 && empty($ticket_resolved_at) && empty($ticket_closed_at)) { ?> + + -
- - + + + -
+
+
-
-
- - + +
+ + + - - -
+
-
+
+ +
-
- -
+
+ +
-
- -
- -
-
-
-
+
+
+
+
-
- -
-
-
-
+ +
+
+ +
-
- -
-
- -
-
- -
- - +
+ + +
-
-
-
- +
+
+
+ - - - - + + +
+
Conversation
+
+ + + +
+
+ + purify($row['ticket_reply']); - $ticket_reply_type = escapeHtml($row['ticket_reply_type']); - $ticket_reply_created_at = escapeHtml($row['ticket_reply_created_at']); - $ticket_reply_created_at_ago = timeAgo($row['ticket_reply_created_at']); - $ticket_reply_updated_at = escapeHtml($row['ticket_reply_updated_at']); - $ticket_reply_updated_at_ago = timeAgo($row['ticket_reply_updated_at']); - $ticket_reply_by = intval($row['ticket_reply_by']); + while ($reply_row = mysqli_fetch_assoc($sql_ticket_replies)) { + $ticket_reply_id = intval($reply_row['ticket_reply_id']); + $ticket_reply = $purifier->purify($reply_row['ticket_reply']); + $ticket_reply_type = escapeHtml($reply_row['ticket_reply_type']); + $ticket_reply_created_at = escapeHtml($reply_row['ticket_reply_created_at']); + $ticket_reply_created_at_ago = timeAgo($reply_row['ticket_reply_created_at']); + $ticket_reply_updated_at = escapeHtml($reply_row['ticket_reply_updated_at']); + $ticket_reply_time_worked = ''; if ($ticket_reply_type == "Client") { - $ticket_reply_by_display = escapeHtml($row['contact_name']); - $user_initials = initials($row['contact_name']); - $user_avatar = escapeHtml($row['contact_photo']); + $ticket_reply_by_display = escapeHtml($reply_row['contact_name']); + $user_initials = initials($reply_row['contact_name']); + $user_avatar = escapeHtml($reply_row['contact_photo']); $avatar_link = "../uploads/clients/$client_id/$user_avatar"; } else { - $ticket_reply_by_display = escapeHtml($row['user_name']); - $user_id = intval($row['user_id']); - $user_avatar = escapeHtml($row['user_avatar']); - $user_initials = initials($row['user_name']); + $ticket_reply_by_display = escapeHtml($reply_row['user_name']); + $user_id = intval($reply_row['user_id']); + $user_avatar = escapeHtml($reply_row['user_avatar']); + $user_initials = initials($reply_row['user_name']); $avatar_link = "../uploads/users/$user_id/$user_avatar"; - $ticket_reply_time_worked = $row['ticket_reply_time_worked']; + $ticket_reply_time_worked = $reply_row['ticket_reply_time_worked']; } - $sql_ticket_reply_attachments = mysqli_query( - $mysqli, - "SELECT * FROM ticket_attachments - WHERE ticket_attachment_reply_id = $ticket_reply_id - AND ticket_attachment_ticket_id = $ticket_id" - ); + // Internal notes are the one thing that must never be mistaken for + // something the client saw, so they get a label, not just a colour + if ($ticket_reply_type == 'Internal') { + $reply_border = 'dark'; + $reply_badge = "Internal note"; + $reply_group = 'internal'; + } elseif ($ticket_reply_type == 'Client') { + $reply_border = 'warning'; + $reply_badge = "From client"; + $reply_group = 'public'; + } else { + $reply_border = 'info'; + $reply_badge = "Public reply"; + $reply_group = 'public'; + } ?> -
mb-3" style="border-left-width: 8px !important;"> -
-
- +
+
+
+
- User Avatar + - + -
-

-
- -
-
- - - Time worked: - - - - -
+
+ + +
+ (edited)'; } ?> + + ·
- -
-
+ +
+ - -
- -
-
- -
-
+
- - - - - - - - - - -
- [View][Download]= 3) { ?>'>[Delete] -
+ +
+ +
+ + View + Download + = 3) { ?> + Delete + +
+ +
+
@@ -854,171 +933,51 @@ if (isset($_GET['ticket_id'])) {
-
+
- -
-
-
Activity Summary
- -
- -
-
-
- - -
- Created: - () -
- - - - -
- Created by: -
- - - - -
- Source: -
- - - - -
- Category: -
- - - - -
- 1st resp: -
- - - - -
- Respond by: - " : ""; } ?> -
- - -
- Resolve by: - Paused - - - - " : ""; } ?> -
- - - - -
- Total time: -
- - - - - - - - - - - - -
-
- Resolved: -
- - - - -
- Closed by: -
- -
- Closed: -
- - -
- Feedback: -
- - - - - -
-
- - - - 0)) { ?> + +
-
Tasks
- = 2) { ?> +
+ Tasks + + + +
- = 2) { ?> +
-
+
- +
@@ -1026,66 +985,60 @@ if (isset($_GET['ticket_id'])) { - + +
No tasks on this ticket.
+ + +
+ 0; + while ($task_row = mysqli_fetch_assoc($sql_tasks)) { + $task_id = intval($task_row['task_id']); + $task_name = escapeHtml($task_row['task_name']); + $task_completion_estimate = intval($task_row['task_completion_estimate']); + $task_completed_at = escapeHtml($task_row['task_completed_at']); + // Approvals came from the single batched query above + $task_needs_approval = isset($task_approvals[$task_id]); $approval_id = 0; $user_can_approve = false; - $approval_rows = mysqli_query($mysqli, " - SELECT approval_id, approval_scope, approval_type, approval_required_user_id, approval_created_by - FROM task_approvals WHERE approval_task_id = $task_id AND approval_status = 'pending' - "); - while ($approval = mysqli_fetch_assoc($approval_rows)) { + if ($task_needs_approval) { + foreach ($task_approvals[$task_id] as $approval) { + if ($approval['approval_status'] !== 'pending') { + continue; + } - $scope = escapeHtml($approval['approval_scope']); - $type = escapeHtml($approval['approval_type']); - $required_user = intval($approval['approval_required_user_id']); - $created_by = intval($approval['approval_created_by']); + $scope = escapeHtml($approval['approval_scope']); + $type = escapeHtml($approval['approval_type']); + $required_user = intval($approval['approval_required_user_id']); + $created_by = intval($approval['approval_created_by']); - // Named, specific user? - if ($scope == 'internal' && $type == 'specific' && $required_user == $session_user_id) { - $user_can_approve = true; - $approval_id = intval($approval['approval_id']); - continue; + // Named, specific user? + if ($scope == 'internal' && $type == 'specific' && $required_user == $session_user_id) { + $user_can_approve = true; + $approval_id = intval($approval['approval_id']); + continue; + } + + // Any internal user, but the one who created the task + if ($scope == 'internal' && $type == 'any' && $created_by !== $session_user_id) { + $user_can_approve = true; + $approval_id = intval($approval['approval_id']); + continue; + } } - - // Any internal user, but the one who created the task - if ($scope == 'internal' && $type == 'any' && $created_by !== $session_user_id) { - $user_can_approve = true; - $approval_id = intval($approval['approval_id']); - continue; - } - } ?> - +
- - = 2) { ?> + + - + @@ -1094,287 +1047,339 @@ if (isset($_GET['ticket_id'])) { - + - + -
+
+
- - - + +
Contact
- = 2) { ?> -
- - - -
+ +
+ + + +
+ +
No contact set on this ticket.
+ +
- - + + +
- +
- - if (!empty($contact_email)) { ?> -
+ +
- - if (!empty($contact_phone)) { ?> +
+ ext.
- - if (!empty($contact_mobile)) { ?> +
-
-
- - - - - 0) { ?> - -
-
-
Watchers
- = 2) { ?> -
- - - -
- -
-
- - -
- - - - - - -
- - -
-
- - - - - -
-
-
Assets
- = 2) { ?> -
- - - -
- -
- - - - - - -
-
-
Vendor
- = 2) { ?> -
- - - -
-
-
-
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- - + - - + +
-
Project
- = 2) { ?> +
Linked
- + +
+ +
-
-
- - -
- -
- -
+ +
Nothing linked yet. Use to attach an asset, vendor, project or watcher.
+ + +
Assets
+
+ + + +
+ +
+ + + + + + + + +
+ + + + +
Vendor
+
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + + + +
Project
+
+ +
+ +
+ + + + +
Watchers
+ +
+ + + + + + +
+ + +
- -
+ +
+
+
History
+
+ +
+
+ +
+ +
@@ -1405,22 +1410,49 @@ require_once "../includes/footer.php"; diff --git a/agent/ticket_kanban.php b/agent/ticket_kanban.php index de5632ca..3a5e08bb 100644 --- a/agent/ticket_kanban.php +++ b/agent/ticket_kanban.php @@ -1,24 +1,28 @@ - - $status_id, - 'name' => $status_name, - 'order' => $kanban_order, - 'tickets' => array() + 'id' => $status_id, + 'name' => escapeHtml($row['ticket_status_name']), + 'color' => escapeHtml($row['ticket_status_color']), + 'pauses_sla' => intval($row['ticket_status_pauses_sla']), + 'tickets' => array() ); } @@ -36,74 +40,88 @@ if ($config_ticket_ordering == 1) { $ordering_snippet = "ORDER BY ticket_order ASC"; } -// Fetch tickets -$sql = mysqli_query( - $mysqli, - "SELECT SQL_CALC_FOUND_ROWS * FROM tickets - LEFT JOIN clients ON ticket_client_id = client_id - LEFT JOIN contacts ON ticket_contact_id = contact_id - LEFT JOIN users ON ticket_assigned_to = user_id - LEFT JOIN assets ON ticket_asset_id = asset_id - 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_query - $client_query - 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_project_snippet - $access_permission_query_overide +/* + * Same WHERE clause the list view runs, so the filter bar means the same + * thing in both views. The old kanban query built its own subset and quietly + * ignored the SLA, priority and billing filters. + */ +$sql = mysqli_query($mysqli, + "SELECT $ticket_select_columns + FROM tickets + $ticket_joins + $ticket_where $ordering_snippet" ); +$kanban_ticket_count = 0; +$kanban_offboard_count = 0; while ($row = mysqli_fetch_assoc($sql)) { - - $status_id = $row['ticket_status_id']; - - foreach ($row as $key => $value) { - if (is_string($value)) { - $row[$key] = escapeHtml($value); - } - } - + $status_id = intval($row['ticket_status_id']); if (isset($statuses[$status_id])) { $statuses[$status_id]['tickets'][] = $row; + $kanban_ticket_count++; + } else { + // Matched the filters but sits in a status with no column - Closed, or + // a status that has since been deactivated + $kanban_offboard_count++; } } -// Convert associative array to indexed array $kanban = array_values($statuses); + ?> -
+ + + +
+
+ + +

The board has no column for these tickets - closed tickets are not shown on the kanban.

+ Show them in the list + +

No tickets match these filters.

+ + Clear the filters + + +
+
+ +
+ + matching ticket not shown - the board has no column for closed tickets. + Show them in the list +
+ + +
-
-
+
+ +
+ + + + + +
-
+
- - - +
+ + + - - - - - + - - - + + + + + - -
+ + + - +
· ' . escapeHtml($item['contact_name']) . ''; } } else { - echo $item['contact_name']; + echo escapeHtml($item['contact_name']); } ?> - +
-
- - -
+ +
-
+ -
- - - - + +
@@ -188,12 +211,10 @@ $kanban = array_values($statuses);
-"; -echo "const CONFIG_TICKET_MOVING_COLUMNS = " . json_encode($config_ticket_moving_columns) . ";"; -echo "const CONFIG_TICKET_ORDERING = " . json_encode($config_ticket_ordering) . ";"; -echo ""; -?> + diff --git a/agent/ticket_list.php b/agent/ticket_list.php index c24c1a27..6fdef9f0 100644 --- a/agent/ticket_list.php +++ b/agent/ticket_list.php @@ -1,18 +1,154 @@ + +
-
+ + + +
+ +

No tickets match these filters.

+ + Clear the filters + +
+ + + + + = 2) { ?> + + + +
- text-nowrap"> + - = 2) { ?> + + @@ -54,6 +191,9 @@ Status + + + $row) { $ticket_prefix = escapeHtml($row['ticket_prefix']); $ticket_number = intval($row['ticket_number']); $ticket_subject = escapeHtml($row['ticket_subject']); $ticket_priority = escapeHtml($row['ticket_priority']); - $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_billable = intval($row['ticket_billable']); $ticket_scheduled_for = escapeHtml($row['ticket_schedule']); $ticket_created_at = escapeHtml($row['ticket_created_at']); $ticket_created_at_time_ago = timeAgo($row['ticket_created_at']); - $ticket_updated_at = escapeHtml($row['ticket_updated_at']); - $ticket_updated_at_time_ago = timeAgo($row['ticket_updated_at']); $ticket_closed_at = escapeHtml($row['ticket_closed_at']); + $ticket_resolved_at = escapeHtml($row['ticket_resolved_at']); + $ticket_is_open = empty($ticket_resolved_at) && empty($ticket_closed_at); + // SLA alert stages are maintained by cron/ticket_sla.php (1 = warned, 2 = breached) $ticket_sla_alert_stage = max(intval($row['ticket_response_sla_alert_stage']), intval($row['ticket_resolution_sla_alert_stage'])); + $ticket_sla_paused = intval($row['ticket_status_pauses_sla']); // A paused ticket isn't running down its clock, so drop the // at-risk warning - a breach already recorded still stands - if (intval($row['ticket_status_pauses_sla']) && $ticket_sla_alert_stage < 2) { + if ($ticket_sla_paused && $ticket_sla_alert_stage < 2) { $ticket_sla_alert_stage = 0; } - if (empty($ticket_updated_at)) { - if (!empty($ticket_closed_at)) { - $ticket_updated_at_display = "

Never

"; - } else { - $ticket_updated_at_display = "

Never

"; - } - } else { - $ticket_updated_at_display = "$ticket_updated_at_time_ago
$ticket_updated_at"; - } $project_id = intval($row['ticket_project_id']); - $client_id = intval($row['ticket_client_id']); $client_name = escapeHtml($row['client_name']); $contact_id = intval($row['contact_id']); $contact_name = escapeHtml($row['contact_name']); - $contact_email = escapeHtml($row['contact_email']); - if ($client_id) { - $has_client = "&client_id=$client_id"; - } else { - $has_client = ""; - } + $has_client = $client_id ? "&client_id=$client_id" : ""; if ($ticket_priority == "Urgent") { $ticket_priority_color = "dark"; @@ -132,76 +257,68 @@ $ticket_assigned_to = intval($row['ticket_assigned_to']); if (empty($ticket_assigned_to)) { - if (!empty($ticket_closed_at)) { - $ticket_assigned_to_display = "

Unassigned

"; - } else { - $ticket_assigned_to_display = "

Unassigned

"; - } + $ticket_assigned_to_display = $ticket_is_open ? "Unassigned" : "Unassigned"; } else { $ticket_assigned_to_display = escapeHtml($row['user_name']); } if (empty($contact_name)) { - $contact_display = "-"; + $contact_display = "-"; } else { - $contact_display = ""; + $contact_display = "$contact_name"; } $ticket_invoice_id = intval($row['ticket_invoice_id']); $ticket_quote_id = intval($row['ticket_quote_id']); - // Get who last updated the ticket - to be shown in the last Response column - - // Defaults to prevent undefined errors + // Last reply, from the batched lookup above $ticket_reply_created_at = ""; $ticket_reply_created_at_time_ago = "Never"; $ticket_reply_by_display = ""; - $ticket_reply_type = "Client"; // Default to client for un-replied tickets + $ticket_reply_type = "Client"; // Un-replied tickets are waiting on us - $sql_ticket_reply = mysqli_query($mysqli, - "SELECT ticket_reply_type, ticket_reply_created_at, contact_name, user_name FROM ticket_replies - LEFT JOIN users ON ticket_reply_by = user_id - LEFT JOIN contacts ON ticket_reply_by = contact_id - WHERE ticket_reply_ticket_id = $ticket_id - AND ticket_reply_archived_at IS NULL - ORDER BY ticket_reply_id DESC LIMIT 1" - ); - $row = mysqli_fetch_assoc($sql_ticket_reply); - - if ($row) { - $ticket_reply_type = escapeHtml($row['ticket_reply_type']); + if (isset($ticket_replies[$ticket_id])) { + $reply = $ticket_replies[$ticket_id]; + $ticket_reply_type = escapeHtml($reply['ticket_reply_type']); if ($ticket_reply_type == "Client") { - $ticket_reply_by_display = escapeHtml($row['contact_name']); + $ticket_reply_by_display = escapeHtml($reply['contact_name']); } else { - $ticket_reply_by_display = escapeHtml($row['user_name']); + $ticket_reply_by_display = escapeHtml($reply['user_name']); } - $ticket_reply_created_at = escapeHtml($row['ticket_reply_created_at']); - $ticket_reply_created_at_time_ago = timeAgo($ticket_reply_created_at); + $ticket_reply_created_at = escapeHtml($reply['ticket_reply_created_at']); + $ticket_reply_created_at_time_ago = timeAgo($reply['ticket_reply_created_at']); } + // Task progress, from the batched lookup above + $task_count = isset($ticket_tasks[$ticket_id]) ? intval($ticket_tasks[$ticket_id]['task_count']) : 0; + $completed_task_count = isset($ticket_tasks[$ticket_id]) ? intval($ticket_tasks[$ticket_id]['completed_count']) : 0; + $tasks_completed_percent = $task_count ? round(($completed_task_count / $task_count) * 100) : 0; - // Get Tasks - // Get Tasks - $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('task_id') AS count FROM tickets, tasks WHERE ticket_id = task_ticket_id AND ticket_project_id = $project_id")); - $task_count = $row['count']; - $sql_tasks = mysqli_query( $mysqli, "SELECT * FROM tasks WHERE task_ticket_id = $ticket_id ORDER BY task_created_at 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); - - // Tasks Completed Percent - if($task_count) { - $tasks_completed_percent = round(($completed_task_count / $task_count) * 100); + /* + * Row highlighting, in priority order. The title explains why the + * row is coloured - previously you just got a red row with no + * indication of which of several rules had fired. + */ + $row_class = ''; + $row_title = ''; + if ($ticket_is_open && $ticket_sla_alert_stage == 2) { + $row_class = 'table-danger'; + $row_title = 'SLA breached'; + } elseif ($ticket_is_open && $ticket_sla_alert_stage == 1) { + $row_class = 'table-warning'; + $row_title = 'SLA at risk'; + } elseif ($ticket_is_open && $ticket_reply_type == "Client") { + $row_class = 'table-warning'; + $row_title = 'Waiting on a reply from us'; + } + if ($ticket_is_open && $ticket_reply_created_at_time_ago == "Never") { + $row_class .= ' font-weight-bold'; + $row_title = $row_title ? $row_title . ', not yet replied to' : 'Not yet replied to'; } ?> - "> + title=""> - + - - = 2) { ?> + + @@ -271,21 +386,50 @@ = 2 && empty($ticket_closed_at)) { ?> class="ajax-modal" - data-modal-url = "modals/ticket/ticket_priority.php?id=" + data-modal-url="modals/ticket/ticket_priority.php?id=" > - - - + + + + + + @@ -325,5 +469,8 @@ - + + + + diff --git a/agent/tickets.php b/agent/tickets.php index 413a7466..6fc1fe92 100644 --- a/agent/tickets.php +++ b/agent/tickets.php @@ -1,5 +1,13 @@ state=open|closed (status[] stays as-is) + * billable=1&unbilled -> billing=unbilled + * + * The old page carried the coarse Open/Closed state in a scalar `status` + * while the status multi-select posted `status[]`. Both landed in the same + * key, so whichever came last in the query string won and the two controls + * silently fought each other. They are separate params now. + */ +if (isset($_GET['status']) && !is_array($_GET['status'])) { + if (!isset($_GET['state'])) { + $_GET['state'] = strtolower($_GET['status']) === 'closed' ? 'closed' : 'open'; } + unset($_GET['status']); +} +if (isset($_GET['billable']) && $_GET['billable'] == '1' && isset($_GET['unbilled'])) { + $_GET['billing'] = 'unbilled'; +} +unset($_GET['billable'], $_GET['unbilled']); - // Convert the sanitized statuses into a comma-separated string - $sanitizedStatusesString = implode(",", $sanitizedStatuses); - $ticket_status_snippet = "ticket_status IN ($sanitizedStatusesString)"; +// filter_header.php built this from the raw query string - rebuild it so the +// sort links carry the normalised params too +$get_copy = $_GET; +unset($get_copy['sort'], $get_copy['order']); +$url_query_strings_sort = http_build_query($get_copy); -} else { - - // TODO: Convert this to use the status IDs - if (isset($_GET['status']) && ($_GET['status']) == 'Closed') { - $status = 'Closed'; - $ticket_status_snippet = "ticket_resolved_at IS NOT NULL"; - } else { - // Default - Show open tickets - $status = 'Open'; - $ticket_status_snippet = "ticket_resolved_at IS NULL"; - } +/* + * filter_header.php strips a sort value down to [a-z_], which blocks injection + * but happily leaves an invalid column name behind - ?sort=ticket_id;DROP... + * becomes ticket_idtickets, and the ORDER BY then takes the page down with an + * uncaught mysqli exception. Only these columns are sortable here, so anything + * else falls back to the default. + */ +$sortable_columns = array( + 'ticket_number', 'ticket_subject', 'client_name', 'contact_name', + 'ticket_billable', 'ticket_priority', 'ticket_status', 'user_name', + 'ticket_updated_at', 'ticket_created_at' +); +if (!in_array($sort, $sortable_columns, true)) { + $sort = 'ticket_number'; } -if (isset($_GET['billable']) && ($_GET['billable']) == '1') { - if (isset($_GET['unbilled'])) { - $billable = 1; - $ticket_billable_snippet = "AND ticket_billable = 1 AND ticket_invoice_id = 0"; - $ticket_status_snippet = '1 = 1'; +/* + * Builds a link to this page with filter overrides applied. Pass null to drop + * a filter. Paging always resets - a page number from the previous filter set + * means nothing once the result set changes. + */ +function ticketsFilterUrl($overrides = []) +{ + $params = $_GET; + foreach ($overrides as $name => $value) { + if ($value === null) { + unset($params[$name]); + } else { + $params[$name] = $value; + } } + unset($params['page']); + + return escapeHtml('?' . http_build_query($params)); +} + +// View - list or kanban +$view = ($_GET['view'] ?? '') === 'kanban' ? 'kanban' : (($_GET['view'] ?? '') === 'list' ? 'list' : ''); +if (!$view) { + // Admin default (0 = list, 2 = kanban) + $view = $config_ticket_default_view === 2 ? 'kanban' : 'list'; +} + +// Chips describing what is currently filtering the list, built as each +// filter is parsed. Each one renders with an x that drops just that filter. +$active_filters = array(); + +// Specific ticket statuses - takes precedence over the open/closed state +$status_filter = array(); +if (isset($_GET['status']) && is_array($_GET['status']) && !empty($_GET['status'])) { + $status_filter = array_filter(array_map('intval', $_GET['status'])); +} + +/* + * Coarse state. Anything with a resolved date is "closed" as far as this page + * is concerned, matching what the counts in the header report. + */ +$state = $_GET['state'] ?? ''; +if (!in_array($state, array('open', 'closed', 'all'), true)) { + $state = 'open'; +} + +// Billing filter +$billing_filter = $_GET['billing'] ?? ''; +if (!in_array($billing_filter, array('unbilled', 'invoiced', 'nonbillable'), true)) { + $billing_filter = ''; +} +$ticket_billable_snippet = ''; +if ($billing_filter == 'unbilled') { + $ticket_billable_snippet = 'AND ticket_billable = 1 AND ticket_invoice_id = 0'; + $active_filters[] = array('label' => 'Billing', 'value' => 'Billable, not invoiced', 'drop' => 'billing'); +} elseif ($billing_filter == 'invoiced') { + $ticket_billable_snippet = 'AND ticket_invoice_id > 0'; + $active_filters[] = array('label' => 'Billing', 'value' => 'Invoiced', 'drop' => 'billing'); +} elseif ($billing_filter == 'nonbillable') { + $ticket_billable_snippet = 'AND ticket_billable = 0'; + $active_filters[] = array('label' => 'Billing', 'value' => 'Not billable', 'drop' => 'billing'); +} + +/* + * Unbilled work is nearly always on tickets that are already done, so a + * billing filter with no state asked for widens to every ticket rather than + * returning the empty list the open default would give. The old page did the + * same thing invisibly - now it shows up as a chip. + */ +if ($billing_filter && !isset($_GET['state'])) { + $state = 'all'; +} + +// Status snippet - specific statuses win, otherwise the coarse state +if ($status_filter) { + $status_ids = implode(',', $status_filter); + $ticket_status_snippet = "ticket_status IN ($status_ids)"; + + $status_names = array(); + $sql_status_names = mysqli_query($mysqli, "SELECT ticket_status_name FROM ticket_statuses WHERE ticket_status_id IN ($status_ids) ORDER BY ticket_status_order"); + while ($row = mysqli_fetch_assoc($sql_status_names)) { + $status_names[] = escapeHtml($row['ticket_status_name']); + } + $active_filters[] = array('label' => 'Status', 'value' => implode(', ', $status_names), 'drop' => 'status'); +} elseif ($state == 'closed') { + $ticket_status_snippet = 'ticket_resolved_at IS NOT NULL'; +} elseif ($state == 'all') { + $ticket_status_snippet = '1 = 1'; } else { - $billable = 0; - $ticket_billable_snippet = ''; + $ticket_status_snippet = 'ticket_resolved_at IS NULL'; } // Category Filter -if (isset($_GET['category']) & !empty($_GET['category'])) { - $category_query = 'AND (ticket_category = ' . intval($_GET['category']) . ')'; +$category_query = ''; +$category_filter = ''; +if (!empty($_GET['category'])) { $category_filter = intval($_GET['category']); -} else { - // Default - any - $category_query = ''; - $category_filter = ''; + $category_query = "AND ticket_category = $category_filter"; + $active_filters[] = array('label' => 'Category', 'value' => getFieldById('categories', $category_filter, 'category_name', 'html'), 'drop' => 'category'); } -// Ticket assignment status filter -// Default - any -$ticket_assigned_query = ''; -$ticket_assigned_filter_id = ''; -// SLA state filter - breached / at risk / met / no SLA +// Priority Filter +$ticket_priority_query = ''; +$priority_filter = ''; +if (!empty($_GET['priority']) && in_array($_GET['priority'], array('Low', 'Medium', 'High', 'Urgent'), true)) { + $priority_filter = $_GET['priority']; + $ticket_priority_query = "AND ticket_priority = '$priority_filter'"; + $active_filters[] = array('label' => 'Priority', 'value' => $priority_filter, 'drop' => 'priority'); +} + +// SLA state filter - breached / at risk / paused / met / no SLA $ticket_sla_query = ''; $ticket_sla_filter = ''; -if (isset($_GET['sla']) && !empty($_GET['sla'])) { +$sla_filter_labels = array( + 'breached' => 'SLA breached', + 'at_risk' => 'SLA at risk', + 'paused' => 'SLA paused', + 'met' => 'SLA met', + 'none' => 'No SLA' +); +if (!empty($_GET['sla']) && isset($sla_filter_labels[$_GET['sla']])) { $ticket_sla_filter = $_GET['sla']; if ($ticket_sla_filter == 'breached') { $ticket_sla_query = 'AND ticket_sla_id > 0 AND (ticket_response_sla_alert_stage = 2 OR ticket_resolution_sla_alert_stage = 2 OR ticket_response_sla_met = 0 OR ticket_resolution_sla_met = 0)'; @@ -85,25 +205,46 @@ if (isset($_GET['sla']) && !empty($_GET['sla'])) { } elseif ($ticket_sla_filter == 'none') { $ticket_sla_query = 'AND ticket_sla_id = 0'; } + $active_filters[] = array('label' => 'SLA', 'value' => $sla_filter_labels[$ticket_sla_filter], 'drop' => 'sla'); } -if (isset($_GET['assigned']) & !empty($_GET['assigned'])) { +// Ticket assignment filter +$ticket_assigned_query = ''; +$ticket_assigned_filter_id = ''; +if (!empty($_GET['assigned'])) { if ($_GET['assigned'] == 'unassigned') { $ticket_assigned_query = 'AND ticket_assigned_to = 0'; $ticket_assigned_filter_id = 0; + $active_filters[] = array('label' => 'Assigned', 'value' => 'Unassigned', 'drop' => 'assigned'); } else { - $ticket_assigned_query = 'AND ticket_assigned_to = ' . intval($_GET['assigned']); $ticket_assigned_filter_id = intval($_GET['assigned']); + $ticket_assigned_query = "AND ticket_assigned_to = $ticket_assigned_filter_id"; + $assigned_name = $ticket_assigned_filter_id === intval($session_user_id) ? 'Me' : getFieldById('users', $ticket_assigned_filter_id, 'user_name', 'html'); + $active_filters[] = array('label' => 'Assigned', 'value' => $assigned_name, 'drop' => 'assigned'); } } // Project filter -// Default - any (including tickets without a project) $ticket_project_snippet = ''; $ticket_project_filter_id = ''; -if (isset($_GET['project']) & !empty($_GET['project']) && $_GET['project'] > '0') { - $ticket_project_snippet = 'AND ticket_project_id = ' . intval($_GET['project']); +if (!empty($_GET['project'])) { $ticket_project_filter_id = intval($_GET['project']); + $ticket_project_snippet = "AND ticket_project_id = $ticket_project_filter_id"; + $active_filters[] = array('label' => 'Project', 'value' => getFieldById('projects', $ticket_project_filter_id, 'project_name', 'html'), 'drop' => 'project'); +} + +// Date range - filter_header.php resolves the canned ranges into $dtf / $dtt +$date_filter_active = isset($_GET['canned_date']) && $_GET['canned_date'] !== 'custom' && $_GET['canned_date'] !== 'alltime'; +if (!$date_filter_active && !empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01') { + $date_filter_active = true; +} +if ($date_filter_active) { + $active_filters[] = array('label' => 'Created', 'value' => "$dtf to $dtt", 'drop' => array('canned_date', 'dtf', 'dtt')); +} + +// Search +if ($q !== '') { + $active_filters[] = array('label' => 'Search', 'value' => stripslashes(escapeHtml($q)), 'drop' => 'q'); } // Ticket client access overide - This is the only way to show tickets without a client to agents with restricted client access @@ -112,358 +253,313 @@ if ($client_access_string) { $access_permission_query_overide = "AND ticket_client_id IN (0,$client_access_string)"; } -// Main ticket query: -$query = - "SELECT SQL_CALC_FOUND_ROWS * FROM tickets - LEFT JOIN clients ON ticket_client_id = client_id +/* + * Columns the two views need. Explicit rather than SELECT * - the tickets + * table carries ticket_details (longtext), and pulling a page of ticket + * bodies to render a list that never shows them is pure overhead. + */ +$ticket_select_columns = + "ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_priority, + ticket_status, ticket_billable, ticket_schedule, ticket_order, + ticket_created_at, ticket_updated_at, ticket_resolved_at, ticket_closed_at, + ticket_client_id, ticket_contact_id, ticket_assigned_to, ticket_project_id, + ticket_invoice_id, ticket_quote_id, ticket_sla_id, + ticket_first_response_at, ticket_response_due_at, ticket_resolution_due_at, + ticket_response_sla_met, ticket_resolution_sla_met, + ticket_response_sla_alert_stage, ticket_resolution_sla_alert_stage, + ticket_status_id, ticket_status_name, ticket_status_color, ticket_status_pauses_sla, + client_name, contact_id, contact_name, contact_email, + user_name, asset_name, category_name"; + +$ticket_joins = + "LEFT JOIN clients ON ticket_client_id = client_id LEFT JOIN contacts ON ticket_contact_id = contact_id LEFT JOIN users ON ticket_assigned_to = user_id LEFT JOIN assets ON ticket_asset_id = asset_id 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 . " + LEFT JOIN categories ON ticket_category = category_id"; + +$ticket_where = + "WHERE $ticket_status_snippet + $ticket_assigned_query $category_query - 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_priority_query $ticket_sla_query $ticket_billable_snippet $ticket_project_snippet $access_permission_query_overide $client_query - ORDER BY - CASE - WHEN '$sort' = 'ticket_priority' THEN - CASE ticket_priority - WHEN 'Urgent' THEN 0 - WHEN 'High' THEN 1 - WHEN 'Medium' THEN 2 - WHEN 'Low' THEN 3 - ELSE 4 -- Optional: for unexpected priority values - END - ELSE NULL - END $order, - $sort $order -- Apply normal sorting by $sort and $order - LIMIT $record_from, $record_to"; + 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%')"; -$sql = mysqli_query($mysqli,$query); +// Counts for the quick views in the header - scope-wide, not filter-aware +$count_where = "$access_permission_query_overide $client_query"; +$total_tickets_open = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_resolved_at IS NULL $count_where"))[0]); +$total_tickets_closed = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_resolved_at IS NOT NULL $count_where"))[0]); +$total_tickets_unassigned = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_assigned_to = 0 AND ticket_resolved_at IS NULL $count_where"))[0]); +$user_active_assigned_tickets = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_assigned_to = $session_user_id AND ticket_resolved_at IS NULL $count_where"))[0]); -$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +// Only offer the SLA filter once SLAs are actually in use +$sla_filter_in_use = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(sla_id) FROM slas WHERE sla_archived_at IS NULL"))[0] > 0; -//Get Total tickets open -$sql_total_tickets_open = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_open FROM tickets WHERE ticket_resolved_at IS NULL $client_query $access_permission_query_overide"); -$row = mysqli_fetch_assoc($sql_total_tickets_open); -$total_tickets_open = intval($row['total_tickets_open']); +// Gates both the Billing column in the table and the Billing filter select +$show_billing_column = $config_module_enable_accounting && lookupUserPermission("module_sales") >= 2; -//Get Total tickets closed -$sql_total_tickets_closed = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_closed FROM tickets WHERE ticket_resolved_at IS NOT NULL $client_query $access_permission_query_overide"); -$row = mysqli_fetch_assoc($sql_total_tickets_closed); -$total_tickets_closed = intval($row['total_tickets_closed']); - -//Get Unassigned tickets -$sql_total_tickets_unassigned = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_unassigned FROM tickets WHERE ticket_assigned_to = '0' AND ticket_resolved_at IS NULL $client_query $access_permission_query_overide"); -$row = mysqli_fetch_assoc($sql_total_tickets_unassigned); -$total_tickets_unassigned = intval($row['total_tickets_unassigned']); - -//Get Total tickets assigned to me -$sql_total_tickets_assigned = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_assigned FROM tickets WHERE ticket_assigned_to = $session_user_id AND ticket_resolved_at IS NULL $client_query $access_permission_query_overide"); -$row = mysqli_fetch_assoc($sql_total_tickets_assigned); -$user_active_assigned_tickets = intval($row['total_tickets_assigned']); - -$sql_categories_filter = mysqli_query( - $mysqli, - "SELECT * FROM categories - WHERE category_type = 'Ticket' - AND category_archived_at IS NULL - ORDER BY category_name" -); +/* + * How many of the collapsed filters are set. The panel stays closed even when + * filters are active - the count on the filter button and the chips below the + * bar are what make them visible, without costing the vertical space an + * auto-expanded panel would. + */ +$hidden_filter_count = 0; +foreach (array('status', 'assigned', 'priority', 'category', 'sla', 'project', 'billing') as $panel_filter) { + if (!empty($_GET[$panel_filter])) { + $hidden_filter_count++; + } +} +if ($date_filter_active) { + $hidden_filter_count++; +} ?> - -
-
-

Tickets - - "> Open | - "> Closed - -

- = 2) { ?> -
-
- - 0) { ?> - - - + +
+
+

Tickets + + Open | + Closed | + All + +

+ = 2) { ?> +
+
+ + +
+
+ +
+ +
+
+ + -
-
- - - - - - -
-
-
+ + + + + + + + + +
+
+
+
- - + +
+
- 0; - if ($sla_filter_in_use) { ?> -
-
- + + + + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+ +
-
-
- -
-
-
-
-
- - +
+ + + + +
+ null); + ?> + + : + + + + Clear all +
+ +
+
diff --git a/agent/user/mfa_enforcement.php b/agent/user/mfa_enforcement.php index f3574d6e..95f178d5 100644 --- a/agent/user/mfa_enforcement.php +++ b/agent/user/mfa_enforcement.php @@ -114,22 +114,21 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token"; // ClipboardJS - // Tooltip + // Tooltip - manual trigger only, so a copy flash can never stay stranded + // on screen. This page is standalone and does not load js/app.js, so it + // carries its own copy of the helper. + function flashTooltip(button, message) { + $(button) + .tooltip('dispose') + .tooltip({ + trigger: 'manual', + placement: 'bottom', + title: message + }) + .tooltip('show'); - $('button').tooltip({ - trigger: 'click', - placement: 'bottom' - }); - - function setTooltip(btn, message) { - $(btn).tooltip('hide') - .attr('data-original-title', message) - .tooltip('show'); - } - - function hideTooltip(btn) { setTimeout(function() { - $(btn).tooltip('hide'); + $(button).tooltip('dispose'); }, 1000); } @@ -138,13 +137,11 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token"; var clipboard = new ClipboardJS('.clipboardjs'); clipboard.on('success', function(e) { - setTooltip(e.trigger, 'Copied!'); - hideTooltip(e.trigger); + flashTooltip(e.trigger, 'Copied!'); }); clipboard.on('error', function(e) { - setTooltip(e.trigger, 'Failed!'); - hideTooltip(e.trigger); + flashTooltip(e.trigger, 'Failed!'); }); // Enable Popovers diff --git a/js/app.js b/js/app.js index be9b2a4b..0b988a57 100644 --- a/js/app.js +++ b/js/app.js @@ -371,35 +371,15 @@ $(document).ready(function() { // ClipboardJS fix for Bootstrap modals $.fn.modal.Constructor.prototype._enforceFocus = function() {}; - // Tooltip - $('button').tooltip({ - trigger: 'click', - placement: 'bottom' - }); - - function setTooltip(btn, message) { - $(btn).tooltip('hide') - .attr('data-original-title', message) - .tooltip('show'); - } - - function hideTooltip(btn) { - setTimeout(function() { - $(btn).tooltip('hide'); - }, 1000); - } - // Clipboard var clipboard = new ClipboardJS('.clipboardjs'); clipboard.on('success', function(e) { - setTooltip(e.trigger, 'Copied!'); - hideTooltip(e.trigger); + flashTooltip(e.trigger, 'Copied!'); }); clipboard.on('error', function(e) { - setTooltip(e.trigger, 'Failed!'); - hideTooltip(e.trigger); + flashTooltip(e.trigger, 'Failed!'); }); // Enable Popovers @@ -502,3 +482,29 @@ $(document).off('change.itflowEventTime').on('change.itflowEventTime', '.event-s } } }); + +/* + * Bootstrap tooltips are only used for the brief "Copied!" flash on a copy + * button. Everything else relies on the browser's own title attribute. + * + * This used to be `$('button').tooltip({trigger: 'click'})`, which attached a + * click-toggled tooltip to EVERY button in the app. A click-triggered tooltip + * is only dismissed by clicking the same button again - clicking anywhere else + * leaves it on screen - and if the button was then removed from the DOM by a + * modal closing or an ajax refresh, its tooltip was orphaned and stayed up + * until the next page load. + */ +function flashTooltip(button, message) { + $(button) + .tooltip('dispose') + .tooltip({ + trigger: 'manual', + placement: 'bottom', + title: message + }) + .tooltip('show'); + + setTimeout(function() { + $(button).tooltip('dispose'); + }, 1000); +}
-
- +
-
@@ -36,10 +172,11 @@ Contact - Billable + Billing SLA Assigned @@ -74,51 +214,36 @@
@@ -223,45 +340,43 @@ "> - 0) { ?> -
-
-
- - -
-

-
+ +
+
+ / tasks +
- + +
+ + +
No client
+
- Invoiced - - Quoted + Invoiced + + Quoted - - "; - } else { - echo ""; - } - ?> - + + + + + + + - - $ticket_scheduled_for "; } ?> + + +
+
+ -"; + } elseif ($ticket_sla_paused) { + echo " Paused"; + } elseif ($ticket_sla_alert_stage == 2) { + echo " Breached"; + } elseif ($ticket_sla_alert_stage == 1) { + echo " At risk"; + } elseif (!$ticket_is_open) { + $sla_met = intval($row['ticket_response_sla_met'] ?? 0) && (is_null($row['ticket_resolution_due_at']) || intval($row['ticket_resolution_sla_met'] ?? 0)); + echo $sla_met ? "Met" : "-"; + } else { + // Whichever clock is still running is the one worth showing + $sla_due_at = empty($row['ticket_first_response_at']) ? $row['ticket_response_due_at'] : $row['ticket_resolution_due_at']; + $sla_due_label = empty($row['ticket_first_response_at']) ? 'First response due' : 'Resolution due'; + if (!empty($sla_due_at)) { + echo "" . timeAgo($sla_due_at) . ""; + } else { + echo "-"; + } + } + ?> + -
- -
-
+
+ + "; } ?> +