From 1bee085b33392627375777542d3e271fe6218c53 Mon Sep 17 00:00:00 2001
From: johnnyq
Date: Fri, 13 Feb 2026 13:35:02 -0500
Subject: [PATCH] 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).
-
-
-
-
-