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); } /** * Convert seconds to true decimal hours (rounded to 2 decimals). */ function secondsToDecimalHours($seconds) { $seconds = (int) max(0, $seconds); if ($seconds === 0) return 0.00; return round($seconds / 3600, 2); } /** * Round UP seconds to the nearest increment (in seconds). */ function secondsRoundUpToIncrement($seconds, $increment_seconds) { $seconds = (int) max(0, $seconds); $increment_seconds = (int) max(1, $increment_seconds); if ($seconds === 0) return 0; return (int) (ceil($seconds / $increment_seconds) * $increment_seconds); } /** * Validate YYYY-MM-DD */ function isValidDateYmd($s) { return is_string($s) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $s); } /** * Billing increment options * Key = hours (string), Value = increment seconds */ $billing_increment_options = [ '0.1' => 6 * 60, // 6 minutes '0.25' => 15 * 60, // 15 minutes [DEFAULT] '0.5' => 30 * 60, // 30 minutes ]; // 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; // Billing increment selection (default 0.25) $billing_increment_key = isset($_GET['billing_increment']) ? (string)$_GET['billing_increment'] : '0.25'; if (!array_key_exists($billing_increment_key, $billing_increment_options)) { $billing_increment_key = '0.25'; } $billing_increment_seconds = $billing_increment_options[$billing_increment_key]; $billing_increment_minutes = (int) round($billing_increment_seconds / 60); // 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 (billable based on sum of rounded replies across all tickets) ?> | |
| Total for ( tickets) | ||
| '; // Reset ticket accumulators $ticket_time_seconds = 0; $ticket_billable_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) | ||