Add Report for Client Detail Time Auditing

This commit is contained in:
johnnyq 2026-02-13 13:35:02 -05:00
parent 1d06e6d9c8
commit 1bee085b33
3 changed files with 389 additions and 279 deletions

View File

@ -0,0 +1,383 @@
<?php
require_once "includes/inc_all_reports.php";
enforceUserPermission('module_support');
/**
* Convert seconds to "HH:MM:SS" (supports totals > 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();
?>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2">
<i class="fas fa-fw fa-life-ring mr-2"></i>
Client Time Detail Audit Report (<?php echo nullable_htmlentities($from); ?> to <?php echo nullable_htmlentities($to); ?>)
<?php if ($billable_only) { ?>
<span class="badge badge-success ml-2">Billable Only</span>
<?php } ?>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary d-print-none" onclick="window.print();">
<i class="fas fa-fw fa-print mr-2"></i>Print
</button>
</div>
</div>
<div class="card-header d-print-none">
<!-- Filters -->
<form class="mb-3">
<div class="row">
<div class="col-md-3 mb-2">
<label class="mb-1">From</label>
<input type="date" class="form-control" name="from" value="<?php echo nullable_htmlentities($from); ?>">
</div>
<div class="col-md-3 mb-2">
<label class="mb-1">To</label>
<input type="date" class="form-control" name="to" value="<?php echo nullable_htmlentities($to); ?>">
</div>
<div class="col-md-4 mb-2 d-flex align-items-end">
<div class="custom-control custom-checkbox">
<input
type="checkbox"
class="custom-control-input"
id="billable_only"
name="billable_only"
value="1"
<?php if ($billable_only) echo 'checked'; ?>
>
<label class="custom-control-label" for="billable_only">Billable tickets only</label>
</div>
</div>
<div class="col-md-2 mb-2 d-flex align-items-end">
<button type="submit" class="btn btn-success btn-block">
<i class="fas fa-fw fa-filter mr-1"></i>Apply
</button>
</div>
</div>
</form>
</div>
</div>
<div class="card">
<div class="table-responsive-sm">
<table class="table table-striped table-sm">
<thead class="bg-dark">
<tr>
<th>Ticket / Replies with Time</th>
<th class="text-right" style="width: 150px;">Time Worked</th>
<th class="text-right" style="width: 120px;">Billable (hrs)</th>
</tr>
</thead>
<tbody>
<?php
// Helper: print ticket subtotal row
$printTicketSubtotalRow = function($ticket_label_html, $ticket_seconds) {
$ticket_billed = secondsToQuarterHourDecimal($ticket_seconds);
?>
<tr class="font-weight-bold">
<td class="text-right pr-3">Ticket Total for <?php echo $ticket_label_html; ?></td>
<td class="text-right"><?php echo formatDuration(secondsToHmsString($ticket_seconds)); ?></td>
<td class="text-right"><?php echo number_format($ticket_billed, 2); ?></td>
</tr>
<?php
return $ticket_billed;
};
$current_client_id = null;
$current_client_name = null;
$current_ticket_id = null;
$current_ticket_label_html = null;
$client_ticket_count = 0;
$client_time_seconds = 0;
$client_billed_hours = 0.0;
$ticket_time_seconds = 0;
$grand_ticket_count = 0;
$grand_time_seconds = 0;
$grand_billed_hours = 0.0;
$had_rows = false;
while ($r = mysqli_fetch_assoc($result)) {
$had_rows = true;
$client_id = (int)$r['client_id'];
$client_name_html = nullable_htmlentities($r['client_name']);
$ticket_id = (int)$r['ticket_id'];
$ticket_prefix = nullable_htmlentities($r['ticket_prefix']);
$ticket_number = (int)$r['ticket_number'];
$ticket_subject_html = nullable_htmlentities($r['ticket_subject']);
$reply_created_at = $r['ticket_reply_created_at'];
$reply_seconds = (int)$r['reply_time_seconds'];
$reply_hms = secondsToHmsString($reply_seconds);
// Reply content: escape for safety, keep line breaks readable
$reply_content_raw = $r['reply_content'] ?? '';
// Remove all HTML tags completely
$reply_content_clean = strip_tags($reply_content_raw);
// Normalize line breaks (convert CRLF/CR to LF)
$reply_content_clean = str_replace(["\r\n", "\r"], "\n", $reply_content_clean);
// Collapse excessive blank lines (more than 2 into 2)
$reply_content_clean = preg_replace("/\n{3,}/", "\n\n", $reply_content_clean);
// Escape safely for output
$reply_content_html = nl2br(nullable_htmlentities(trim($reply_content_clean)));
// Close out previous client if client changed
if ($current_client_id !== null && $client_id !== $current_client_id) {
// Close out previous ticket (if any)
if ($current_ticket_id !== null) {
$ticket_billed = $printTicketSubtotalRow($current_ticket_label_html, $ticket_time_seconds);
$client_billed_hours += $ticket_billed;
$grand_billed_hours += $ticket_billed;
$ticket_time_seconds = 0;
$current_ticket_id = null;
$current_ticket_label_html = null;
echo '<tr><td colspan="3"></td></tr>';
}
// Client subtotal
?>
<tr class="font-weight-bold">
<td class="text-right">
Total for <?php echo $current_client_name; ?> (<?php echo $client_ticket_count; ?> tickets)
</td>
<td class="text-right"><?php echo formatDuration(secondsToHmsString($client_time_seconds)); ?></td>
<td class="text-right"><?php echo number_format($client_billed_hours, 2); ?></td>
</tr>
<tr><td colspan="3"></td></tr>
<?php
// Reset client totals
$client_ticket_count = 0;
$client_time_seconds = 0;
$client_billed_hours = 0.0;
}
// Client header
if ($client_id !== $current_client_id) {
$current_client_id = $client_id;
$current_client_name = $client_name_html;
?>
<tr class="table-active">
<td colspan="3" class="font-weight-bold"><?php echo $client_name_html; ?></td>
</tr>
<?php
}
// Ticket label
$display_ticket = trim($ticket_prefix . $ticket_number);
if ($display_ticket === '') $display_ticket = (string)$ticket_number;
$ticket_label_html = nullable_htmlentities($display_ticket) . " - " . $ticket_subject_html;
// Ticket changed: close previous ticket subtotal
if ($current_ticket_id !== null && $ticket_id !== $current_ticket_id) {
$ticket_billed = $printTicketSubtotalRow($current_ticket_label_html, $ticket_time_seconds);
// Add billed totals once per ticket
$client_billed_hours += $ticket_billed;
$grand_billed_hours += $ticket_billed;
echo '<tr><td colspan="3"></td></tr>';
// 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++;
?>
<tr>
<td class="font-weight-bold"><?php echo $ticket_label_html; ?></td>
<td class="text-right text-muted"></td>
<td class="text-right text-muted"></td>
</tr>
<?php
}
// Reply row (indented) - date/time + reply content + time
?>
<tr>
<td class="pl-4 text-muted">
<i class="far fa-clock mr-1"></i>
<?php echo nullable_htmlentities(date('Y-m-d g:i A', strtotime($reply_created_at))); ?>
<div class="mt-1 text-body" style="white-space: normal;">
<?php echo $reply_content_html; ?>
</div>
</td>
<td class="text-right"><?php echo formatDuration($reply_hms); ?></td>
<td class="text-right"><?php echo number_format(secondsToQuarterHourDecimal($reply_seconds), 2); ?></td>
</tr>
<?php
// Totals
$ticket_time_seconds += $reply_seconds;
$client_time_seconds += $reply_seconds;
$grand_time_seconds += $reply_seconds;
}
if (!$had_rows) {
?>
<tr>
<td colspan="3" class="text-center text-muted">
No ticket replies with time worked found for this date range.
</td>
</tr>
<?php
} else {
// Close last ticket subtotal
if ($current_ticket_id !== null) {
$ticket_billed = $printTicketSubtotalRow($current_ticket_label_html, $ticket_time_seconds);
$client_billed_hours += $ticket_billed;
$grand_billed_hours += $ticket_billed;
echo '<tr><td colspan="3"></td></tr>';
}
// Close last client subtotal
?>
<tr class="font-weight-bold">
<td class="text-right">
Total for <?php echo $current_client_name; ?> (<?php echo $client_ticket_count; ?> tickets)
</td>
<td class="text-right"><?php echo formatDuration(secondsToHmsString($client_time_seconds)); ?></td>
<td class="text-right"><?php echo number_format($client_billed_hours, 2); ?></td>
</tr>
<tr><td colspan="3"></td></tr>
<!-- Grand totals -->
<tr class="font-weight-bold">
<td class="text-right">
Grand Total (<?php echo $grand_ticket_count; ?> tickets)
</td>
<td class="text-right"><?php echo formatDuration(secondsToHmsString($grand_time_seconds)); ?></td>
<td class="text-right"><?php echo number_format($grand_billed_hours, 2); ?></td>
</tr>
<?php
}
?>
</tbody>
</table>
<small class="text-muted p-2">
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).
<br>
Reply content is displayed under each reply timestamp.
</small>
</div>
</div>
<?php
require_once "../../includes/footer.php";

View File

@ -71,6 +71,12 @@
<p>Unbilled Tickets</p>
</a>
</li>
<li class="nav-item">
<a href="/agent/reports/client_ticket_time_detail.php" class="nav-link <?php if (basename($_SERVER["PHP_SELF"]) == "client_ticket_time_detail.php") { echo "active"; } ?>">
<i class="nav-icon fas fa-life-ring"></i>
<p>Client Time Detail Audit</p>
</a>
</li>
<?php } // End financial reports IF statement ?>

View File

@ -1,279 +0,0 @@
<?php
require_once "includes/inc_all_reports.php";
enforceUserPermission('module_support');
/**
* Convert seconds to "HH:MM:SS" (supports totals > 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();
?>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2">
<i class="fas fa-fw fa-life-ring mr-2"></i>
Ticket By Client (<?php echo date("F", mktime(1, 1, 1, $month, 1)) . " " . $year; ?>)
<?php if ($billable_only) { ?>
<span class="badge badge-success ml-2">Billable Only</span>
<?php } ?>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary d-print-none" onclick="window.print();">
<i class="fas fa-fw fa-print mr-2"></i>Print
</button>
</div>
</div>
<div class="card-header d-print-none">
<!-- Filters -->
<form class="mb-3">
<div class="row">
<div class="col-md-3 mb-2">
<label class="mb-1">Year</label>
<select class="form-control" name="year">
<?php while ($row = mysqli_fetch_assoc($sql_ticket_years)) {
$ticket_year = (int) $row['ticket_year']; ?>
<option <?php if ($year === $ticket_year) echo 'selected'; ?> value="<?php echo $ticket_year; ?>">
<?php echo $ticket_year; ?>
</option>
<?php } ?>
</select>
</div>
<div class="col-md-4 mb-2">
<label class="mb-1">Month</label>
<select class="form-control" name="month">
<?php for ($m = 1; $m <= 12; $m++) { ?>
<option <?php if ($month === $m) echo 'selected'; ?> value="<?php echo $m; ?>">
<?php echo date("F", mktime(1, 1, 1, $m, 1)); ?>
</option>
<?php } ?>
</select>
</div>
<div class="col-md-3 mb-2 d-flex align-items-end">
<div class="custom-control custom-checkbox">
<input
type="checkbox"
class="custom-control-input"
id="billable_only"
name="billable_only"
value="1"
<?php if ($billable_only) echo 'checked'; ?>
>
<label class="custom-control-label" for="billable_only">Billable tickets only</label>
</div>
</div>
<div class="col-md-2 mb-2 d-flex align-items-end">
<button type="submit" class="btn btn-success btn-block">
<i class="fas fa-fw fa-filter mr-1"></i>Apply
</button>
</div>
</div>
</form>
</div>
</div>
<div class="card">
<div class="table-responsive-sm">
<table class="table table-striped">
<thead class="bg-dark">
<tr>
<th>Ticket</th>
<th class="text-right" style="width: 150px;">Time Worked</th>
<th class="text-right" style="width: 120px;">Billable (hrs)</th>
</tr>
</thead>
<tbody>
<?php
$current_client_id = null;
$current_client_name = null;
$client_ticket_count = 0;
$client_time_seconds = 0;
$client_billed_hours = 0.0;
$grand_ticket_count = 0;
$grand_time_seconds = 0;
$grand_billed_hours = 0.0;
$had_rows = false;
while ($r = mysqli_fetch_assoc($result)) {
$had_rows = true;
$client_id = (int) $r['client_id'];
$client_name = nullable_htmlentities($r['client_name']);
$ticket_prefix = nullable_htmlentities($r['ticket_prefix']);
$ticket_number = (int) $r['ticket_number'];
$ticket_subject = nullable_htmlentities($r['ticket_subject']);
$ticket_time_hms = $r['ticket_time_hms']; // "HH:MM:SS"
$ticket_time_seconds = (int) $r['ticket_time_seconds'];
$ticket_billed_hours = secondsToQuarterHourDecimal($ticket_time_seconds);
// Client break: print subtotal for previous client
if ($current_client_id !== null && $client_id !== $current_client_id) {
?>
<tr class="font-weight-bold">
<td class="text-right">
Total for <?php echo $current_client_name; ?> (<?php echo $client_ticket_count; ?> tickets)
</td>
<td class="text-right"><?php echo formatDuration(secondsToHmsString($client_time_seconds)); ?></td>
<td class="text-right"><?php echo number_format($client_billed_hours, 2); ?></td>
</tr>
<tr><td colspan="3"></td></tr>
<?php
$client_ticket_count = 0;
$client_time_seconds = 0;
$client_billed_hours = 0.0;
}
// Client header
if ($client_id !== $current_client_id) {
$current_client_id = $client_id;
$current_client_name = $client_name;
?>
<tr class="table-active">
<td colspan="3" class="font-weight-bold"><?php echo $client_name; ?></td>
</tr>
<?php
}
$display_ticket = trim($ticket_prefix . $ticket_number);
if ($display_ticket === '') $display_ticket = (string) $ticket_number;
?>
<tr>
<td><?= "$display_ticket - $ticket_subject" ?></td>
<td class="text-right"><?php echo formatDuration($ticket_time_hms); ?></td>
<td class="text-right"><?php echo number_format($ticket_billed_hours, 2); ?></td>
</tr>
<?php
// Totals
$client_ticket_count++;
$client_time_seconds += $ticket_time_seconds;
$client_billed_hours += $ticket_billed_hours;
$grand_ticket_count++;
$grand_time_seconds += $ticket_time_seconds;
$grand_billed_hours += $ticket_billed_hours;
}
if (!$had_rows) {
?>
<tr>
<td colspan="3" class="text-center text-muted">No tickets with time worked found for this month.</td>
</tr>
<?php
} else {
// Final client subtotal
?>
<tr class="font-weight-bold">
<td class="text-right">
Total for <?php echo $current_client_name; ?> (<?php echo $client_ticket_count; ?> tickets)
</td>
<td class="text-right"><?php echo formatDuration(secondsToHmsString($client_time_seconds)); ?></td>
<td class="text-right"><?php echo number_format($client_billed_hours, 2); ?></td>
</tr>
<tr><td colspan="3"></td></tr>
<!-- Grand totals -->
<tr class="font-weight-bold">
<td class="text-right">
Grand Total (<?php echo $grand_ticket_count; ?> tickets)
</td>
<td class="text-right"><?php echo formatDuration(secondsToHmsString($grand_time_seconds)); ?></td>
<td class="text-right"><?php echo number_format($grand_billed_hours, 2); ?></td>
</tr>
<?php
}
?>
</tbody>
</table>
<small class="text-muted p-2">
Billed hours are calculated per ticket by rounding that tickets worked time up to the nearest 15 minutes (0.25 hours).
</small>
</div>
</div>
<?php
require_once "../../includes/footer.php";