From 62fb2c91a1a257cd66f6c9546f69595b4ee60641 Mon Sep 17 00:00:00 2001 From: Mads Iversen Date: Mon, 9 Feb 2026 12:09:56 +0000 Subject: [PATCH 01/19] feat: Add API endpoint to retrieve time worked by technicians on tickets with filtering by date and technician. --- api/v1/technicians/time.php | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 api/v1/technicians/time.php diff --git a/api/v1/technicians/time.php b/api/v1/technicians/time.php new file mode 100644 index 00000000..5d17be98 --- /dev/null +++ b/api/v1/technicians/time.php @@ -0,0 +1,73 @@ + 12)) { + $return_arr['success'] = "False"; + $return_arr['message'] = "Invalid month parameter. Must be between 1 and 12."; + echo json_encode($return_arr); + exit(); +} + +// Optional technician filter +$technician_id = isset($_GET['technician_id']) ? intval($_GET['technician_id']) : null; + +// Build WHERE conditions for date filtering +$date_conditions = "YEAR(tr.ticket_reply_created_at) = $year"; +if ($month !== null) { + $date_conditions .= " AND MONTH(tr.ticket_reply_created_at) = $month"; +} + +// Build technician filter +$technician_condition = ""; +if ($technician_id !== null) { + $technician_condition = "AND tr.ticket_reply_by = $technician_id"; +} + +// Query to get time worked per ticket reply, grouped by technician +$sql = mysqli_query( + $mysqli, + "SELECT + t.ticket_id, + CONCAT(t.ticket_prefix, t.ticket_number) AS ticket_number, + t.ticket_subject, + c.client_id, + c.client_name AS company, + u.user_id AS technician_id, + u.user_name AS technician, + SEC_TO_TIME(SUM(TIME_TO_SEC(tr.ticket_reply_time_worked))) AS time_worked + FROM ticket_replies tr + INNER JOIN tickets t ON t.ticket_id = tr.ticket_reply_ticket_id + INNER JOIN clients c ON c.client_id = t.ticket_client_id + INNER JOIN users u ON u.user_id = tr.ticket_reply_by + WHERE tr.ticket_reply_time_worked IS NOT NULL + AND tr.ticket_reply_time_worked != '00:00:00' + AND $date_conditions + AND t.ticket_client_id LIKE '$client_id' + $technician_condition + GROUP BY t.ticket_id, u.user_id + ORDER BY c.client_name ASC, t.ticket_number ASC, u.user_name ASC + LIMIT $limit OFFSET $offset" +); + +// Output +require_once "../read_output.php"; From 1d06e6d9c8294523fe101d74ab4304bd20d0bff8 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Wed, 11 Feb 2026 13:33:28 -0500 Subject: [PATCH 02/19] Revert to old ajax-modal js code for now, Fix Assets not lising in create ticket. --- agent/ajax.php | 2 +- js/ajax_modal.js | 163 ++++++++++++++++------------------------------- 2 files changed, 56 insertions(+), 109 deletions(-) diff --git a/agent/ajax.php b/agent/ajax.php index 8079da84..beb25738 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -319,7 +319,7 @@ if (isset($_GET['get_client_assets'])) { LEFT JOIN contacts ON contact_id = asset_contact_id WHERE assets.asset_archived_at IS NULL AND asset_client_id = $client_id $access_permission_query - ORDER BY asset_important DESC, asset_name" + ORDER BY asset_favorite DESC, asset_name" ); while ($row = mysqli_fetch_assoc($asset_sql)) { diff --git a/js/ajax_modal.js b/js/ajax_modal.js index af12db20..81a9824f 100644 --- a/js/ajax_modal.js +++ b/js/ajax_modal.js @@ -1,114 +1,61 @@ -// Ajax Modal Load Script (deduped + locked) -function hashKey(str) { - let h = 0; - for (let i = 0; i < str.length; i++) { - h = ((h << 5) - h + str.charCodeAt(i)) | 0; - } - return Math.abs(h).toString(36); -} - +// Ajax Modal Load Script $(document).on('click', '.ajax-modal', function (e) { - e.preventDefault(); + e.preventDefault(); - const $trigger = $(this); + const $trigger = $(this); - // prevent spam clicks on same trigger - if ($trigger.data('ajaxModalLoading')) { + // Prefer data-modal-url, fallback to href + let modalUrl = $trigger.data('modal-url') || $trigger.attr('href') || '#'; + const modalSize = $trigger.data('modal-size') || 'md'; + const modalId = 'ajaxModal_' + Date.now(); + + // If no usable URL, bail + if (!modalUrl || modalUrl === '#') { + console.warn('ajax-modal: No modal URL found on trigger:', this); + return; + } + + // Show loading spinner while fetching content + const loadingSpinner = ` + `; + $('.content-wrapper').append(loadingSpinner); + + // Make AJAX request + $.ajax({ + url: modalUrl, + method: 'GET', + dataType: 'json', + success: function (response) { + $('#modal-loading-spinner').remove(); + + if (response.error) { + alert(response.error); return; + } + + const modalHtml = ` + `; + + $('.content-wrapper').append(modalHtml); + const $modal = $('#' + modalId); + $modal.modal('show'); + + $modal.on('hidden.bs.modal', function () { + $(this).remove(); + }); + }, + error: function (xhr, status, error) { + $('#modal-loading-spinner').remove(); + alert('Error loading modal content. Please try again.'); + console.error('Modal AJAX Error:', status, error); } - - $trigger - .data('ajaxModalLoading', true) - .prop('disabled', true) - .addClass('disabled'); - - // Prefer data-modal-url, fallback to href - const modalUrl = $trigger.data('modal-url') || $trigger.attr('href') || '#'; - const modalSize = $trigger.data('modal-size') || 'md'; - - if (!modalUrl || modalUrl === '#') { - console.warn('ajax-modal: No modal URL found on trigger:', this); - - $trigger - .data('ajaxModalLoading', false) - .prop('disabled', false) - .removeClass('disabled'); - - return; - } - - // stable IDs based on URL (prevents duplicates) - const key = hashKey(String(modalUrl)); - const modalId = 'ajaxModal_' + key; - const spinnerId = 'modal-loading-spinner-' + key; - - // if modal already exists, just show it - const $existing = $('#' + modalId); - if ($existing.length) { - $existing.modal('show'); - - $trigger - .data('ajaxModalLoading', false) - .prop('disabled', false) - .removeClass('disabled'); - - return; - } - - // Show loading spinner while fetching content (deduped) - $('#' + spinnerId).remove(); - $('.content-wrapper').append(` -
- -
- `); - - $.ajax({ - url: modalUrl, - method: 'GET', - dataType: 'json' - }) - .done(function (response) { - $('#' + spinnerId).remove(); - - if (response && response.error) { - alert(response.error); - return; - } - - // guard against race: if another request already created it - if ($('#' + modalId).length) { - $('#' + modalId).modal('show'); - return; - } - - const modalHtml = ` - `; - - $('.content-wrapper').append(modalHtml); - - const $modal = $('#' + modalId); - $modal.modal('show'); - - $modal.on('hidden.bs.modal', function () { - $(this).remove(); - }); - }) - .fail(function (xhr, status, error) { - $('#' + spinnerId).remove(); - alert('Error loading modal content. Please try again.'); - console.error('Modal AJAX Error:', status, error); - }) - .always(function () { - $trigger - .data('ajaxModalLoading', false) - .prop('disabled', false) - .removeClass('disabled'); - }); + }); }); From 1bee085b33392627375777542d3e271fe6218c53 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Fri, 13 Feb 2026 13:35:02 -0500 Subject: [PATCH 03/19] Add Report for Client Detail Time Auditing --- agent/reports/client_ticket_time_detail.php | 383 ++++++++++++++++++++ agent/reports/includes/reports_side_nav.php | 6 + agent/reports/ticket_by_client_v2.php | 279 -------------- 3 files changed, 389 insertions(+), 279 deletions(-) create mode 100644 agent/reports/client_ticket_time_detail.php delete mode 100644 agent/reports/ticket_by_client_v2.php diff --git a/agent/reports/client_ticket_time_detail.php b/agent/reports/client_ticket_time_detail.php new file mode 100644 index 00000000..b84f3402 --- /dev/null +++ b/agent/reports/client_ticket_time_detail.php @@ -0,0 +1,383 @@ + 24h by using hours > 24) + */ +function secondsToHmsString($seconds) { + $seconds = (int) max(0, $seconds); + $hours = intdiv($seconds, 3600); + $minutes = intdiv($seconds % 3600, 60); + $secs = $seconds % 60; + return sprintf('%02d:%02d:%02d', $hours, $minutes, $secs); +} + +/** + * 15-minute round up, return decimal hours in 0.25 increments + * NOTE: In this report, billed hours are calculated per TICKET total + * (sum of reply time within range, then rounded up to nearest 15 minutes). + */ +function secondsToQuarterHourDecimal($seconds) { + $seconds = (int) max(0, $seconds); + if ($seconds === 0) return 0.00; + + $quarters = (int) ceil($seconds / 900); // 900 seconds = 15 minutes + return $quarters * 0.25; +} + +/** + * Validate YYYY-MM-DD + */ +function isValidDateYmd($s) { + return is_string($s) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $s); +} + +// Default range: current month +$from = isset($_GET['from']) ? $_GET['from'] : date('Y-m-01'); +$to = isset($_GET['to']) ? $_GET['to'] : date('Y-m-t'); + +if (!isValidDateYmd($from)) $from = date('Y-m-01'); +if (!isValidDateYmd($to)) $to = date('Y-m-t'); + +// Inclusive datetime bounds +$from_dt = $from . " 00:00:00"; +$to_dt = $to . " 23:59:59"; + +$billable_only = (isset($_GET['billable_only']) && (int)$_GET['billable_only'] === 1) ? 1 : 0; + +// Ticket-level billable flag (same as your original report) +$billable_sql = $billable_only ? " AND t.ticket_billable = 1 " : ""; + +/** + * Query returns ONLY replies that have time_worked and are within date range. + * Reply content column = tr.ticket_reply + */ +$stmt = $mysqli->prepare(" + SELECT + c.client_id, + c.client_name, + t.ticket_id, + t.ticket_prefix, + t.ticket_number, + t.ticket_subject, + + tr.ticket_reply_id, + tr.ticket_reply_created_at, + tr.ticket_reply_time_worked, + TIME_TO_SEC(tr.ticket_reply_time_worked) AS reply_time_seconds, + tr.ticket_reply AS reply_content + + FROM tickets t + INNER JOIN clients c + ON c.client_id = t.ticket_client_id + + INNER JOIN ticket_replies tr + ON tr.ticket_reply_ticket_id = t.ticket_id + AND tr.ticket_reply_time_worked IS NOT NULL + AND TIME_TO_SEC(tr.ticket_reply_time_worked) > 0 + AND tr.ticket_reply_created_at BETWEEN ? AND ? + + WHERE c.client_archived_at IS NULL + $billable_sql + + ORDER BY c.client_name ASC, + t.ticket_number ASC, + t.ticket_id ASC, + tr.ticket_reply_created_at ASC +"); +$stmt->bind_param("ss", $from_dt, $to_dt); +$stmt->execute(); +$result = $stmt->get_result(); + +?> +
+
+

+ + Client Time Detail Audit Report ( to ) + + Billable Only + +

+
+ +
+
+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+
+ + > + +
+
+ +
+ +
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + '; + } + + // Client subtotal + ?> + + + + + + + + + + + '; + + // Reset ticket accumulator + $ticket_time_seconds = 0; + $current_ticket_id = null; + $current_ticket_label_html = null; + } + + // Ticket header (first row for this ticket) + if ($ticket_id !== $current_ticket_id) { + $current_ticket_id = $ticket_id; + $current_ticket_label_html = $ticket_label_html; + + $client_ticket_count++; + $grand_ticket_count++; + + ?> + + + + + + + + + + + + + + + + '; + } + + // Close last client subtotal + ?> + + + + + + + + + + + + + + + + +
Ticket / Replies with TimeTime WorkedBillable (hrs)
Ticket Total for
+ Total for ( tickets) +
+ + +
+ +
+
+ No ticket replies with time worked found for this date range. +
+ Total for ( tickets) +
+ Grand Total ( tickets) +
+ + + This report shows only ticket replies with time worked within the selected date range. + Ticket “Billable (hrs)” totals are calculated by summing reply time per ticket within the range, + then rounding that ticket total up to the nearest 15 minutes (0.25 hours). +
+ Reply content is displayed under each reply timestamp. +
+
+
+ +Unbilled Tickets

+ diff --git a/agent/reports/ticket_by_client_v2.php b/agent/reports/ticket_by_client_v2.php deleted file mode 100644 index 25e5c864..00000000 --- a/agent/reports/ticket_by_client_v2.php +++ /dev/null @@ -1,279 +0,0 @@ - 24h by using hours > 24) - */ -function secondsToHmsString($seconds) { - $seconds = (int) max(0, $seconds); - $hours = intdiv($seconds, 3600); - $minutes = intdiv($seconds % 3600, 60); - $secs = $seconds % 60; - return sprintf('%02d:%02d:%02d', $hours, $minutes, $secs); -} - -/** - * 15-minute round up, return decimal hours in 0.25 increments - * Examples: - * 1 min => 0.25 - * 16 min => 0.50 - * 61 min => 1.25 - */ -function secondsToQuarterHourDecimal($seconds) { - $seconds = (int) max(0, $seconds); - if ($seconds === 0) return 0.00; - - $quarters = (int) ceil($seconds / 900); // 900 seconds = 15 minutes - return $quarters * 0.25; -} - -$year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y'); -$month = isset($_GET['month']) ? (int) $_GET['month'] : (int) date('m'); -if ($month < 1 || $month > 12) $month = (int) date('m'); - -$billable_only = (isset($_GET['billable_only']) && (int) $_GET['billable_only'] === 1) ? 1 : 0; - -// Used for Year dropdown -$sql_ticket_years = mysqli_query($mysqli, "SELECT DISTINCT YEAR(ticket_created_at) AS ticket_year FROM tickets ORDER BY ticket_year DESC"); - -// Billable filter (adjust field name if yours differs) -$billable_sql = $billable_only ? " AND t.ticket_billable = 1 " : ""; - -/** - * IMPORTANT: - * This sums time worked ONLY for replies within the selected month/year - * by filtering on tr.ticket_reply_created_at. - * If your column name differs, replace ticket_reply_created_at accordingly. - */ -$stmt = $mysqli->prepare(" - SELECT - c.client_id, - c.client_name, - t.ticket_id, - t.ticket_prefix, - t.ticket_number, - t.ticket_subject, - SEC_TO_TIME(COALESCE(SUM(TIME_TO_SEC(tr.ticket_reply_time_worked)), 0)) AS ticket_time_hms, - COALESCE(SUM(TIME_TO_SEC(tr.ticket_reply_time_worked)), 0) AS ticket_time_seconds - FROM tickets t - INNER JOIN clients c - ON c.client_id = t.ticket_client_id - LEFT JOIN ticket_replies tr - ON tr.ticket_reply_ticket_id = t.ticket_id - AND tr.ticket_reply_time_worked IS NOT NULL - AND YEAR(tr.ticket_reply_created_at) = ? - AND MONTH(tr.ticket_reply_created_at) = ? - WHERE c.client_archived_at IS NULL - $billable_sql - GROUP BY t.ticket_id - HAVING ticket_time_seconds > 0 - ORDER BY c.client_name ASC, t.ticket_number ASC, t.ticket_id ASC -"); -$stmt->bind_param("ii", $year, $month); -$stmt->execute(); -$result = $stmt->get_result(); - -?> - -
-
-

- - Ticket By Client () - - Billable Only - -

-
- -
-
- -
- - -
-
-
- - -
- -
- - -
- -
-
- - > - -
-
- -
- -
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TicketTime WorkedBillable (hrs)
- Total for ( tickets) -
No tickets with time worked found for this month.
- Total for ( tickets) -
- Grand Total ( tickets) -
- - - Billed hours are calculated per ticket by rounding that ticket’s worked time up to the nearest 15 minutes (0.25 hours). - -
-
- - Date: Fri, 13 Feb 2026 13:39:41 -0500 Subject: [PATCH 04/19] Fix Transfer Asset to client due to field change from asset_important to asset_favorite --- agent/post/asset.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agent/post/asset.php b/agent/post/asset.php index 61e5f400..06566915 100644 --- a/agent/post/asset.php +++ b/agent/post/asset.php @@ -376,8 +376,8 @@ if (isset($_POST['bulk_transfer_client_asset'])) { // Create new asset mysqli_query($mysqli, " - INSERT INTO assets (asset_type, asset_name, asset_description, asset_make, asset_model, asset_serial, asset_os, asset_status, asset_purchase_date, asset_warranty_expire, asset_install_date, asset_notes, asset_important) - SELECT asset_type, asset_name, asset_description, asset_make, asset_model, asset_serial, asset_os, asset_status, asset_purchase_date, asset_warranty_expire, asset_install_date, asset_notes, asset_important + INSERT INTO assets (asset_type, asset_name, asset_description, asset_make, asset_model, asset_serial, asset_os, asset_status, asset_purchase_date, asset_warranty_expire, asset_install_date, asset_notes, asset_favorite) + SELECT asset_type, asset_name, asset_description, asset_make, asset_model, asset_serial, asset_os, asset_status, asset_purchase_date, asset_warranty_expire, asset_install_date, asset_notes, asset_favorite FROM assets WHERE asset_id = $current_asset_id "); From c0fe9813dcc2ca801695dba229c35aa03da7ee14 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Fri, 13 Feb 2026 14:09:05 -0500 Subject: [PATCH 05/19] Fix Ticket Merging regressed from ticket select now use ticket_id instead of ticket_number --- agent/modals/ticket/ticket_bulk_merge.php | 2 +- agent/modals/ticket/ticket_merge.php | 4 ++-- agent/post/ticket.php | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/agent/modals/ticket/ticket_bulk_merge.php b/agent/modals/ticket/ticket_bulk_merge.php index 1d0738fd..b2eb3eb3 100644 --- a/agent/modals/ticket/ticket_bulk_merge.php +++ b/agent/modals/ticket/ticket_bulk_merge.php @@ -47,7 +47,7 @@ ob_start();
-
- +