mirror of https://github.com/itflow-org/itflow
Merge pull request #1268 from itflow-org/develop
Develop to Master for 26.02.1 Maint Release
This commit is contained in:
commit
20e2f22a27
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -2,6 +2,24 @@
|
|||
|
||||
This file documents all notable changes made to ITFlow.
|
||||
|
||||
## [26.02.1] Maint Release
|
||||
### Bug Fixes
|
||||
- Credentials: Fix Password Generator.
|
||||
- Calendar: Restrict Events for client restricted agents.
|
||||
- Ticket Merge: Fix.
|
||||
- Asset Transfer: Fix.
|
||||
- Ticket Listing: Restrict Tickets presented in ticket list view from client restricted agents.
|
||||
- Ticket Details: Deny access to client restricted agents to view tickets without client_id in uri.
|
||||
- Tickets: Allow agents with restricted client access to view and edit tickets without a client.
|
||||
- Ticket Change client: Limit selection for agents with restricted client access.
|
||||
- Ticket Details: Don't display Updated at when null.
|
||||
|
||||
### New Features & Updates
|
||||
- Report: Added Client Detail Auditing.
|
||||
- API: Added Endpoint to retrieve time worked by agent.
|
||||
- ajax-modal: Revert to previous JS implementation before 26.02 release.
|
||||
- Ticket: Move Subject from Ticket main ticket header to ticket details card header.
|
||||
|
||||
## [26.02] Stable Release
|
||||
### Bug Fixes
|
||||
- Mail Parser - Do not automatically send new ticket notifications to noreply/donotreply addresses.
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -178,8 +178,8 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
|||
echo "{ id: $event_id, title: $event_title, start: $event_start, end: $event_end, color: $calendar_color },";
|
||||
}
|
||||
|
||||
//Invoices Created
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients LEFT JOIN invoices ON client_id = invoice_client_id $client_query");
|
||||
// Invoices Created
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients LEFT JOIN invoices ON client_id = invoice_client_id $client_query $access_permission_query");
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$event_id = intval($row['invoice_id']);
|
||||
$scope = strval($row['invoice_scope']);
|
||||
|
|
@ -193,8 +193,8 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
|||
echo "{ id: $event_id, title: $event_title, start: $event_start, display: 'list-item', color: 'blue', url: 'invoice.php?invoice_id=$event_id$client_url' },";
|
||||
}
|
||||
|
||||
//Quotes Created
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients LEFT JOIN quotes ON client_id = quote_client_id $client_query");
|
||||
// Quotes Created
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients LEFT JOIN quotes ON client_id = quote_client_id $client_query $access_permission_query");
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$event_id = intval($row['quote_id']);
|
||||
$event_title = json_encode($row['quote_prefix'] . $row['quote_number'] . " " . $row['quote_scope']);
|
||||
|
|
@ -203,12 +203,12 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
|||
echo "{ id: $event_id, title: $event_title, start: $event_start, display: 'list-item', color: 'purple', url: 'quote.php?quote_id=$event_id$client_url' },";
|
||||
}
|
||||
|
||||
//Tickets Created
|
||||
// Tickets Created
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients
|
||||
LEFT JOIN tickets ON client_id = ticket_client_id
|
||||
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
|
||||
LEFT JOIN users ON ticket_assigned_to = user_id
|
||||
$client_query"
|
||||
$client_query $access_permission_query"
|
||||
);
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$event_id = intval($row['ticket_id']);
|
||||
|
|
@ -242,7 +242,7 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
|||
$sql = mysqli_query($mysqli, "SELECT * FROM clients
|
||||
LEFT JOIN recurring_tickets ON client_id = recurring_ticket_client_id
|
||||
LEFT JOIN users ON recurring_ticket_assigned_to = user_id
|
||||
$client_query"
|
||||
$client_query $access_permission_query"
|
||||
);
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$event_id = intval($row['recurring_ticket_id']);
|
||||
|
|
@ -262,12 +262,12 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
|||
echo "{ id: $event_id, title: $event_title, start: $event_start, color: '$event_color', url: 'recurring_tickets.php?client_id=$client_id$client_url' },";
|
||||
}
|
||||
|
||||
//Tickets Scheduled
|
||||
// Tickets Scheduled
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients
|
||||
LEFT JOIN tickets ON client_id = ticket_client_id
|
||||
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
|
||||
LEFT JOIN users ON ticket_assigned_to = user_id
|
||||
$client_query AND ticket_schedule IS NOT NULL"
|
||||
$client_query $access_permission_query AND ticket_schedule IS NOT NULL"
|
||||
);
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$event_id = intval($row['ticket_id']);
|
||||
|
|
@ -297,8 +297,8 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
|||
echo "{ id: $event_id, title: $event_title, start: $event_start, color: '$event_color', url: 'ticket.php?ticket_id=$event_id$client_url' },";
|
||||
}
|
||||
|
||||
//Vendors Added Created
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients LEFT JOIN vendors ON client_id = vendor_client_id $client_query");
|
||||
// Vendors Added Created
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients LEFT JOIN vendors ON client_id = vendor_client_id $client_query $access_permission_query");
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$event_id = intval($row['vendor_id']);
|
||||
$client_id = intval($row['client_id']);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ require_once '../../../includes/modal_header.php';
|
|||
|
||||
$client_id = intval($_GET['id']);
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients WHERE client_id = $client_id LIMIT 1");
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM clients WHERE client_id = $client_id $access_permission_query LIMIT 1");
|
||||
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
$client_name = nullable_htmlentities($row['client_name']);
|
||||
|
|
|
|||
|
|
@ -260,6 +260,8 @@ ob_start();
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<script src="/agent/js/generate_password.js"></script>
|
||||
|
||||
<?php
|
||||
|
||||
require_once '../../../includes/modal_footer.php';
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ ob_start();
|
|||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" name="merge_into_ticket_number" required>
|
||||
<select class="form-control select2" name="merge_into_ticket_id" required>
|
||||
<option value=''>- Select a Ticket -</option>
|
||||
<?php
|
||||
while ($row = mysqli_fetch_assoc($sql_merge)) {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ ob_start();
|
|||
</div>
|
||||
<select class="form-control select2" name="new_client_id" id="client_select" required>
|
||||
<?php
|
||||
$sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_lead = 0 AND client_archived_at IS NULL ORDER BY client_name ASC");
|
||||
$sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_lead = 0 AND client_archived_at IS NULL $access_permission_query ORDER BY client_name ASC");
|
||||
while ($row = mysqli_fetch_assoc($sql_clients)) {
|
||||
$client_id_select = intval($row['client_id']);
|
||||
$client_name = nullable_htmlentities($row['client_name']);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@
|
|||
|
||||
require_once '../../../includes/modal_header.php';
|
||||
|
||||
// Ticket client access overide - This is the only way to show tickets without a client to agents with restricted client access
|
||||
$access_permission_query_overide = '';
|
||||
if ($client_access_string) {
|
||||
$access_permission_query_overide = "AND ticket_client_id IN (0,$client_access_string)";
|
||||
}
|
||||
|
||||
$ticket_id = intval($_GET['id']);
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM tickets LEFT JOIN clients ON client_id = ticket_client_id WHERE ticket_id = $ticket_id LIMIT 1");
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM tickets LEFT JOIN clients ON client_id = ticket_client_id WHERE ticket_id = $ticket_id $access_permission_query_overide LIMIT 1");
|
||||
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
$client_id = intval($row['client_id']);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ ob_start();
|
|||
</button>
|
||||
</div>
|
||||
<form action="post.php" method="post" autocomplete="off">
|
||||
<input type="hidden" id="current_ticket_id" name="ticket_id" value="<?php echo $ticket_id; ?>">
|
||||
<input type="hidden" id="current_ticket_id" name="ticket_id" value="<?= $ticket_id ?>">
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="alert alert-dark">
|
||||
|
|
@ -47,7 +47,7 @@ ob_start();
|
|||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" name="merge_into_ticket_number" required>
|
||||
<select class="form-control select2" name="merge_into_ticket_id" required>
|
||||
<option value=''>- Select a Ticket -</option>
|
||||
<?php
|
||||
while ($row = mysqli_fetch_assoc($sql_merge)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
|
|
|
|||
|
|
@ -1052,19 +1052,19 @@ if (isset($_POST['bulk_merge_tickets'])) {
|
|||
|
||||
enforceUserPermission('module_support', 2);
|
||||
|
||||
$merge_into_ticket_number = intval($_POST['merge_into_ticket_number']); // Parent ticket *number*
|
||||
$merge_into_ticket_id = intval($_POST['merge_into_ticket_id']); // Parent ticket id
|
||||
$merge_comment = sanitizeInput($_POST['merge_comment']); // Merge comment
|
||||
$ticket_reply_type = 'Internal'; // Default all replies to internal
|
||||
|
||||
// NEW PARENT ticket details
|
||||
// Get merge into ticket id (as it may differ from the number)
|
||||
$sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_number = $merge_into_ticket_number");
|
||||
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_number FROM tickets WHERE ticket_id = $merge_into_ticket_id");
|
||||
if (mysqli_num_rows($sql) == 0) {
|
||||
flash_alert("Cannot merge into that ticket.", 'error');
|
||||
redirect();
|
||||
}
|
||||
$merge_row = mysqli_fetch_assoc($sql);
|
||||
$merge_into_ticket_id = intval($merge_row['ticket_id']); // Parent ticket ID
|
||||
$merge_into_ticket_number = intval($merge_row['ticket_number']); // Parent ticket Number
|
||||
|
||||
// Update & Close the selected tickets
|
||||
if (isset($_POST['ticket_ids'])) {
|
||||
|
|
@ -1815,7 +1815,7 @@ if (isset($_POST['merge_ticket'])) {
|
|||
enforceUserPermission('module_support', 2);
|
||||
|
||||
$ticket_id = intval($_POST['ticket_id']); // Child ticket ID to be closed
|
||||
$merge_into_ticket_number = intval($_POST['merge_into_ticket_number']); // Parent ticket *number*
|
||||
$merge_into_ticket_id = intval($_POST['merge_into_ticket_id']); // Parent ticket id
|
||||
$merge_comment = sanitizeInput($_POST['merge_comment']); // Merge comment
|
||||
$move_replies = intval($_POST['merge_move_replies']); // Whether to move replies to the new parent ticket
|
||||
$ticket_reply_type = 'Internal'; // Default all replies to internal
|
||||
|
|
@ -1836,21 +1836,21 @@ if (isset($_POST['merge_ticket'])) {
|
|||
|
||||
// NEW PARENT ticket details
|
||||
// Get merge into ticket id (as it may differ from the number)
|
||||
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_client_id FROM tickets WHERE ticket_number = $merge_into_ticket_number");
|
||||
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_number, ticket_client_id FROM tickets WHERE ticket_id = $merge_into_ticket_id");
|
||||
if (mysqli_num_rows($sql) == 0) {
|
||||
flash_alert("Cannot merge into that ticket.", 'error');
|
||||
redirect();
|
||||
}
|
||||
$merge_row = mysqli_fetch_assoc($sql);
|
||||
$merge_into_ticket_id = intval($merge_row['ticket_id']);
|
||||
$client_id = intval($merge_row['ticket_client_id']);
|
||||
$merge_into_ticket_number = intval($merge_row['ticket_number']);
|
||||
if ($client_id) {
|
||||
$has_client = "&client_id=$client_id";
|
||||
} else {
|
||||
$has_client = "";
|
||||
}
|
||||
// Sanity check
|
||||
if ($ticket_number == $merge_into_ticket_number) {
|
||||
if ($ticket_id == $merge_into_ticket_id) {
|
||||
flash_alert("Cannot merge into the same ticket.", 'error');
|
||||
redirect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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 ?>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ticket’s worked time up to the nearest 15 minutes (0.25 hours).
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
require_once "../../includes/footer.php";
|
||||
|
|
@ -9,6 +9,12 @@ if (isset($_GET['client_id'])) {
|
|||
$client_url = '';
|
||||
}
|
||||
|
||||
// Ticket client access overide - This is the only way to show tickets without a client to agents with restricted client access
|
||||
$access_permission_query_overide = '';
|
||||
if ($client_access_string) {
|
||||
$access_permission_query_overide = "AND ticket_client_id IN (0,$client_access_string)";
|
||||
}
|
||||
|
||||
// Perms
|
||||
enforceUserPermission('module_support');
|
||||
|
||||
|
|
@ -39,6 +45,7 @@ if (isset($_GET['ticket_id'])) {
|
|||
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
|
||||
LEFT JOIN categories ON ticket_category = category_id
|
||||
WHERE ticket_id = $ticket_id
|
||||
$access_permission_query_overide
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
|
|
@ -349,11 +356,10 @@ if (isset($_GET['ticket_id'])) {
|
|||
<i class="fa fa-fw fa-2x fa-life-ring mr-2"></i>
|
||||
<div class="media-body">
|
||||
<div class="text-bold">Ticket <?= "$ticket_prefix$ticket_number" ?>
|
||||
<span class='badge badge-pill text-light ml-1' style="background-color: <?= $ticket_status_color ?>">
|
||||
<span class='badge badge-pill text-light ml-1 p-2' style="background-color: <?= $ticket_status_color ?>">
|
||||
<?= $ticket_status_name ?>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-secondary"><?= $ticket_subject ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -439,10 +445,11 @@ if (isset($_GET['ticket_id'])) {
|
|||
|
||||
<div class="card card-body">
|
||||
|
||||
<div title="<?php echo $ticket_updated_at; ?>">
|
||||
<?php if ($ticket_updated_at) { ?>
|
||||
<div title="<?= $ticket_updated_at ?>">
|
||||
<i class="fa fa-fw fa-history text-secondary mr-2"></i>Updated: <strong><?= date('M d, Y • g:i A', strtotime($ticket_updated_at)) . "</strong> <span class='text-muted small'>($ticket_updated_at_ago)</span>" ?>
|
||||
</div>
|
||||
|
||||
<?php } ?>
|
||||
<!-- Ticket assign (disable if closed -->
|
||||
<?php if (empty($ticket_closed_at)) { ?>
|
||||
<div class="mt-1">
|
||||
|
|
@ -535,9 +542,7 @@ if (isset($_GET['ticket_id'])) {
|
|||
<div class="card card-dark mb-3">
|
||||
|
||||
<div class="card-header px-3 py-2">
|
||||
<h5 class="card-title mt-1">
|
||||
Description / Comments
|
||||
</h5>
|
||||
<h5 class="card-title mt-1"><?= $ticket_subject ?></h5>
|
||||
<?php if (empty($ticket_closed_at)) { ?>
|
||||
<div class="card-tools">
|
||||
<button type="button" class="btn btn-tool ajax-modal" data-modal-url="modals/ticket/ticket_edit.php?id=<?= $ticket_id ?>" data-modal-size="lg"><i class="fas fa-edit"></i></button>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ $sql = mysqli_query(
|
|||
ticket_vendor_ticket_number LIKE '%$q%'
|
||||
)
|
||||
$ticket_project_snippet
|
||||
$ticket_permission_snippet
|
||||
$access_permission_query_overide
|
||||
$ordering_snippet"
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -88,15 +88,14 @@ if (isset($_GET['project']) & !empty($_GET['project']) && $_GET['project'] > '0'
|
|||
$ticket_project_filter_id = intval($_GET['project']);
|
||||
}
|
||||
|
||||
// Ticket client access snippet
|
||||
$ticket_permission_snippet = '';
|
||||
if (!empty($client_access_string)) {
|
||||
$ticket_permission_snippet = "AND ticket_client_id IN ($client_access_string)";
|
||||
// Ticket client access overide - This is the only way to show tickets without a client to agents with restricted client access
|
||||
$access_permission_query_overide = '';
|
||||
if ($client_access_string) {
|
||||
$access_permission_query_overide = "AND ticket_client_id IN (0,$client_access_string)";
|
||||
}
|
||||
|
||||
// Main ticket query:
|
||||
$sql = mysqli_query(
|
||||
$mysqli,
|
||||
$query =
|
||||
"SELECT SQL_CALC_FOUND_ROWS * FROM tickets
|
||||
LEFT JOIN clients ON ticket_client_id = client_id
|
||||
LEFT JOIN contacts ON ticket_contact_id = contact_id
|
||||
|
|
@ -112,7 +111,7 @@ $sql = mysqli_query(
|
|||
AND (CONCAT(ticket_prefix,ticket_number) LIKE '%$q%' OR client_name LIKE '%$q%' OR ticket_subject LIKE '%$q%' OR ticket_status_name LIKE '%$q%' OR ticket_priority LIKE '%$q%' OR user_name LIKE '%$q%' OR contact_name LIKE '%$q%' OR asset_name LIKE '%$q%' OR vendor_name LIKE '%$q%' OR ticket_vendor_ticket_number LIKE '%q%')
|
||||
$ticket_billable_snippet
|
||||
$ticket_project_snippet
|
||||
$ticket_permission_snippet
|
||||
$access_permission_query_overide
|
||||
$client_query
|
||||
ORDER BY
|
||||
CASE
|
||||
|
|
@ -126,28 +125,29 @@ $sql = mysqli_query(
|
|||
ELSE NULL
|
||||
END $order,
|
||||
$sort $order -- Apply normal sorting by $sort and $order
|
||||
LIMIT $record_from, $record_to"
|
||||
);
|
||||
LIMIT $record_from, $record_to";
|
||||
|
||||
$sql = mysqli_query($mysqli,$query);
|
||||
|
||||
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
|
||||
|
||||
//Get Total tickets open
|
||||
$sql_total_tickets_open = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_open FROM tickets WHERE ticket_resolved_at IS NULL $client_query $ticket_permission_snippet");
|
||||
$sql_total_tickets_open = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_open FROM tickets WHERE ticket_resolved_at IS NULL $client_query $access_permission_query_overide");
|
||||
$row = mysqli_fetch_assoc($sql_total_tickets_open);
|
||||
$total_tickets_open = intval($row['total_tickets_open']);
|
||||
|
||||
//Get Total tickets closed
|
||||
$sql_total_tickets_closed = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_closed FROM tickets WHERE ticket_resolved_at IS NOT NULL $client_query $ticket_permission_snippet");
|
||||
$sql_total_tickets_closed = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_closed FROM tickets WHERE ticket_resolved_at IS NOT NULL $client_query $access_permission_query_overide");
|
||||
$row = mysqli_fetch_assoc($sql_total_tickets_closed);
|
||||
$total_tickets_closed = intval($row['total_tickets_closed']);
|
||||
|
||||
//Get Unassigned tickets
|
||||
$sql_total_tickets_unassigned = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_unassigned FROM tickets WHERE ticket_assigned_to = '0' AND ticket_resolved_at IS NULL $client_query $ticket_permission_snippet");
|
||||
$sql_total_tickets_unassigned = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_unassigned FROM tickets WHERE ticket_assigned_to = '0' AND ticket_resolved_at IS NULL $client_query $access_permission_query_overide");
|
||||
$row = mysqli_fetch_assoc($sql_total_tickets_unassigned);
|
||||
$total_tickets_unassigned = intval($row['total_tickets_unassigned']);
|
||||
|
||||
//Get Total tickets assigned to me
|
||||
$sql_total_tickets_assigned = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_assigned FROM tickets WHERE ticket_assigned_to = $session_user_id AND ticket_resolved_at IS NULL $client_query $ticket_permission_snippet");
|
||||
$sql_total_tickets_assigned = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_assigned FROM tickets WHERE ticket_assigned_to = $session_user_id AND ticket_resolved_at IS NULL $client_query $access_permission_query_overide");
|
||||
$row = mysqli_fetch_assoc($sql_total_tickets_assigned);
|
||||
$user_active_assigned_tickets = intval($row['total_tickets_assigned']);
|
||||
|
||||
|
|
@ -159,8 +159,6 @@ $sql_categories_filter = mysqli_query(
|
|||
ORDER BY category_name"
|
||||
);
|
||||
|
||||
|
||||
|
||||
?>
|
||||
<style>
|
||||
.popover {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* API - technicians/time.php
|
||||
* Returns time worked by technicians on tickets
|
||||
*
|
||||
* GET Parameters:
|
||||
* api_key (required) - API key for authentication
|
||||
* year (optional) - Filter by year (default: current year)
|
||||
* month (optional) - Filter by month 1-12 (default: current month)
|
||||
* technician_id (optional) - Filter by specific technician user ID
|
||||
* limit (optional) - Number of results to return (default: 50)
|
||||
* offset (optional) - Offset for pagination (default: 0)
|
||||
*/
|
||||
|
||||
require_once '../validate_api_key.php';
|
||||
require_once '../require_get_method.php';
|
||||
|
||||
// Get filter parameters
|
||||
$year = isset($_GET['year']) ? intval($_GET['year']) : intval(date('Y'));
|
||||
$month = isset($_GET['month']) ? intval($_GET['month']) : null;
|
||||
|
||||
// Validate month if provided
|
||||
if ($month !== null && ($month < 1 || $month > 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";
|
||||
|
|
@ -5,4 +5,4 @@
|
|||
* Update this file each time we merge develop into master. Format is YY.MM (add a .v if there is more than one release a month.
|
||||
*/
|
||||
|
||||
DEFINE("APP_VERSION", "26.02");
|
||||
DEFINE("APP_VERSION", "26.02.1");
|
||||
|
|
|
|||
163
js/ajax_modal.js
163
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 = `
|
||||
<div id="modal-loading-spinner" class="text-center p-5">
|
||||
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
|
||||
</div>`;
|
||||
$('.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 = `
|
||||
<div class="modal fade" id="${modalId}" tabindex="-1">
|
||||
<div class="modal-dialog modal-${modalSize}">
|
||||
<div class="modal-content border-dark">
|
||||
${response.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
$('.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(`
|
||||
<div id="${spinnerId}" class="text-center p-5">
|
||||
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$.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 = `
|
||||
<div class="modal fade" id="${modalId}" tabindex="-1">
|
||||
<div class="modal-dialog modal-${modalSize}">
|
||||
<div class="modal-content border-dark">
|
||||
${response.content || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
$('.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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue