Merge pull request #1268 from itflow-org/develop

Develop to Master for 26.02.1 Maint Release
This commit is contained in:
Johnny 2026-02-14 15:54:13 -05:00 committed by GitHub
commit 20e2f22a27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 597 additions and 438 deletions

View File

@ -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.

View File

@ -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)) {

View File

@ -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']);

View File

@ -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']);

View File

@ -260,6 +260,8 @@ ob_start();
</div>
</form>
<script src="/agent/js/generate_password.js"></script>
<?php
require_once '../../../includes/modal_footer.php';

View File

@ -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)) {

View File

@ -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']);

View File

@ -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']);

View File

@ -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)) {

View File

@ -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
");

View File

@ -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();
}

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";

View File

@ -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>

View File

@ -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"
);

View File

@ -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 {

View File

@ -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";

View File

@ -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");

View File

@ -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');
});
});
});