diff --git a/api/bulk_operation.php b/api/bulk_operation.php index 57fa6ec..bc3a14f 100644 --- a/api/bulk_operation.php +++ b/api/bulk_operation.php @@ -131,12 +131,19 @@ if (isset($result['error'])) { if ($inaccessibleCount > 0) { $message .= " ($inaccessibleCount skipped - no access)"; } - echo json_encode([ + $response = [ 'success' => true, 'operation_id' => $operationId, 'processed' => $result['processed'], 'failed' => $result['failed'], 'skipped' => $inaccessibleCount, 'message' => $message - ]); + ]; + // Best-effort batches (the default; see processBulkOperation()'s docblock) + // can partially fail — surface the per-ticket reasons so the admin isn't + // just told a count. The dashboard's bulkResultMessage() already expects this. + if (!empty($result['errors'])) { + $response['errors'] = $result['errors']; + } + echo json_encode($response); } diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index fca33fd..f0369dc 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -140,25 +140,25 @@ document.addEventListener('DOMContentLoaded', function() { break; // Bulk operation perform actions case 'perform-bulk-assign': - performBulkAssign(); + performBulkAssign(target); break; case 'close-bulk-assign-modal': closeBulkAssignModal(); break; case 'perform-bulk-priority': - performBulkPriority(); + performBulkPriority(target); break; case 'close-bulk-priority-modal': closeBulkPriorityModal(); break; case 'perform-bulk-status': - performBulkStatusChange(); + performBulkStatusChange(target); break; case 'close-bulk-status-modal': closeBulkStatusModal(); break; case 'perform-bulk-close': - performBulkCloseAction(); + performBulkCloseAction(undefined, target); break; case 'close-bulk-close-modal': closeBulkCloseModal(); @@ -491,7 +491,10 @@ function closeBulkCloseModal() { if (modal) setTimeout(() => modal.remove(), 300); } -function performBulkCloseAction(ticketIds) { +function performBulkCloseAction(ticketIds, btn) { + if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests + if (btn) btn.disabled = true; + ticketIds = ticketIds || getSelectedTicketIds(); const commentEl = document.getElementById('bulkCloseComment'); const comment = commentEl ? commentEl.value.trim() : ''; @@ -524,7 +527,8 @@ function performBulkCloseAction(ticketIds) { } closeBulkCloseModal(); lt.toast.error('Bulk close failed: ' + error.message, 5000); - }); + }) + .finally(() => { if (btn) btn.disabled = false; }); } var _bulkAssignUserId = null; @@ -596,7 +600,8 @@ function closeBulkAssignModal() { if (modal) setTimeout(() => modal.remove(), 300); } -function performBulkAssign() { +function performBulkAssign(btn) { + if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests const userId = _bulkAssignUserId; const ticketIds = getSelectedTicketIds(); @@ -605,6 +610,8 @@ function performBulkAssign() { return; } + if (btn) btn.disabled = true; + lt.api.post('/api/bulk_operation.php', { operation_type: 'bulk_assign', ticket_ids: ticketIds, @@ -625,7 +632,8 @@ function performBulkAssign() { }) .catch(error => { lt.toast.error('Bulk assign failed: ' + error.message, 5000); - }); + }) + .finally(() => { if (btn) btn.disabled = false; }); } function showBulkPriorityModal() { @@ -672,7 +680,8 @@ function closeBulkPriorityModal() { if (modal) setTimeout(() => modal.remove(), 300); } -function performBulkPriority() { +function performBulkPriority(btn) { + if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests const priorityEl = document.getElementById('bulkPriority'); if (!priorityEl) return; const priority = priorityEl.value; @@ -683,6 +692,8 @@ function performBulkPriority() { return; } + if (btn) btn.disabled = true; + lt.api.post('/api/bulk_operation.php', { operation_type: 'bulk_priority', ticket_ids: ticketIds, @@ -703,7 +714,8 @@ function performBulkPriority() { }) .catch(error => { lt.toast.error('Bulk priority update failed: ' + error.message, 5000); - }); + }) + .finally(() => { if (btn) btn.disabled = false; }); } // Make table rows clickable @@ -786,7 +798,8 @@ function closeBulkStatusModal() { if (modal) setTimeout(() => modal.remove(), 300); } -function performBulkStatusChange() { +function performBulkStatusChange(btn) { + if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests const bulkStatusEl = document.getElementById('bulkStatus'); if (!bulkStatusEl) return; const status = bulkStatusEl.value; @@ -800,6 +813,8 @@ function performBulkStatusChange() { const commentEl = document.getElementById('bulkStatusComment'); const comment = commentEl ? commentEl.value.trim() : ''; + if (btn) btn.disabled = true; + lt.api.post('/api/bulk_operation.php', { operation_type: 'bulk_status', ticket_ids: ticketIds, @@ -829,7 +844,8 @@ function performBulkStatusChange() { } closeBulkStatusModal(); lt.toast.error('Bulk status change failed: ' + error.message, 5000); - }); + }) + .finally(() => { if (btn) btn.disabled = false; }); } /** diff --git a/models/BulkOperationsModel.php b/models/BulkOperationsModel.php index 47dee2c..afd64be 100644 --- a/models/BulkOperationsModel.php +++ b/models/BulkOperationsModel.php @@ -106,12 +106,18 @@ class BulkOperationsModel /** * Process a bulk operation * - * Uses database transaction to ensure atomicity - either all tickets - * are updated or none are (on failure, changes are rolled back). + * Runs the whole batch inside one database transaction, but by default + * ($atomic = false, which is what api/bulk_operation.php uses) that + * transaction is always committed: a per-ticket failure (e.g. a + * disallowed workflow transition) is recorded in $failed/$errors and + * skipped, while every other ticket in the batch still succeeds. This + * is a best-effort batch, not an all-or-nothing one — set $atomic to + * true to roll back the entire batch when any ticket fails. * * @param int $operationId Operation ID * @param bool $atomic If true, rollback all changes on any failure - * @return array Result with processed and failed counts + * @return array Result with processed/failed counts and an errors[] list of + * per-ticket failure reasons (surfaced to the admin by the caller) */ public function processBulkOperation($operationId, bool $atomic = false) {