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 = `
+
+
+
+ ${response.content}
+
+
+
`;
+
+ $('.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 = `
-
-
-
- ${response.content || ''}
-
-
-
`;
-
- $('.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();
+
+?>
+
+
+
+
+
+
+
+
+
+
+
+ Ticket / Replies with Time
+ Time Worked
+ Billable (hrs)
+
+
+
+
+
+
+ Ticket Total for
+
+
+
+ ';
+ }
+
+ // Client subtotal
+ ?>
+
+
+ Total for ( tickets)
+
+
+
+
+
+
+
+
+
+ ';
+
+ // 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++;
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No ticket replies with time worked found for this date range.
+
+
+ ';
+ }
+
+ // Close last client subtotal
+ ?>
+
+
+ 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
+
+ ">
+
+ Client Time Detail Audit
+
+
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
- Time Worked
- Billable (hrs)
-
-
-
-
-
-
- Total for ( tickets)
-
-
-
-
-
-
-
-
-
-
-
- = "$display_ticket - $ticket_subject" ?>
-
-
-
-
-
- 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();
-
+
- Select a Ticket -