From ac094c8706fe6f499162714ce4d148a161d53de7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 1 Jan 2026 19:06:33 -0500 Subject: [PATCH] Feature 5: Implement Bulk Actions (Admin Only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive bulk operations system for admins: - Created BulkOperationsModel.php with operation tracking and processing - Added bulk_operation.php API endpoint for bulk operations - Created get_users.php API endpoint for user dropdown in bulk assign - Updated DashboardView.php with checkboxes and bulk actions toolbar - Added JavaScript functions for: - Select all/clear selection - Bulk close tickets - Bulk assign tickets - Bulk change priority - Added comprehensive CSS for bulk actions toolbar and modals - All bulk operations are admin-only (enforced server-side) - Operations tracked in bulk_operations table with audit logging - Supports bulk_close, bulk_assign, and bulk_priority operations Admins can now select multiple tickets and perform batch operations, significantly improving workflow efficiency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- api/bulk_operation.php | 85 ++++++++++++ api/get_users.php | 33 +++++ assets/css/dashboard.css | 170 ++++++++++++++++++++++- assets/js/dashboard.js | 241 +++++++++++++++++++++++++++++++++ models/BulkOperationsModel.php | 198 +++++++++++++++++++++++++++ views/DashboardView.php | 26 +++- 6 files changed, 751 insertions(+), 2 deletions(-) create mode 100644 api/bulk_operation.php create mode 100644 api/get_users.php create mode 100644 models/BulkOperationsModel.php diff --git a/api/bulk_operation.php b/api/bulk_operation.php new file mode 100644 index 0000000..fb1c34f --- /dev/null +++ b/api/bulk_operation.php @@ -0,0 +1,85 @@ + false, 'error' => 'Not authenticated']); + exit; +} + +// Check admin status - bulk operations are admin-only +$isAdmin = $_SESSION['user']['is_admin'] ?? false; +if (!$isAdmin) { + echo json_encode(['success' => false, 'error' => 'Admin access required']); + exit; +} + +// Get request data +$data = json_decode(file_get_contents('php://input'), true); +$operationType = $data['operation_type'] ?? null; +$ticketIds = $data['ticket_ids'] ?? []; +$parameters = $data['parameters'] ?? null; + +// Validate input +if (!$operationType || empty($ticketIds)) { + echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']); + exit; +} + +// Validate ticket IDs are integers +foreach ($ticketIds as $ticketId) { + if (!is_numeric($ticketId)) { + echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']); + exit; + } +} + +// Create database connection +$conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] +); + +if ($conn->connect_error) { + echo json_encode(['success' => false, 'error' => 'Database connection failed']); + exit; +} + +$bulkOpsModel = new BulkOperationsModel($conn); + +// Create bulk operation record +$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters); + +if (!$operationId) { + $conn->close(); + echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']); + exit; +} + +// Process the bulk operation +$result = $bulkOpsModel->processBulkOperation($operationId); + +$conn->close(); + +if (isset($result['error'])) { + echo json_encode([ + 'success' => false, + 'error' => $result['error'] + ]); +} else { + echo json_encode([ + 'success' => true, + 'operation_id' => $operationId, + 'processed' => $result['processed'], + 'failed' => $result['failed'], + 'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed" + ]); +} diff --git a/api/get_users.php b/api/get_users.php new file mode 100644 index 0000000..302f45f --- /dev/null +++ b/api/get_users.php @@ -0,0 +1,33 @@ + false, 'error' => 'Not authenticated']); + exit; +} + +// Create database connection +$conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] +); + +if ($conn->connect_error) { + echo json_encode(['success' => false, 'error' => 'Database connection failed']); + exit; +} + +// Get all users +$userModel = new UserModel($conn); +$users = $userModel->getAllUsers(); + +$conn->close(); + +echo json_encode(['success' => true, 'users' => $users]); diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css index 595e8bc..f921af3 100644 --- a/assets/css/dashboard.css +++ b/assets/css/dashboard.css @@ -671,4 +671,172 @@ th.sort-desc::after { .cancel-settings { background: var(--hover-bg); color: var(--text-primary); -} \ No newline at end of file +} +/* Bulk Actions Styles (Admin only) */ +.bulk-actions-toolbar { + display: none; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: var(--toolbar-bg, #fff3cd); + border: 1px solid var(--toolbar-border, #ffc107); + border-radius: 8px; + margin: 1rem 0; +} + +.bulk-actions-info { + font-weight: bold; + color: var(--text-primary, #333); +} + +.bulk-actions-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.btn-bulk { + padding: 0.5rem 1rem; + background: var(--btn-bulk-bg, #007bff); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.2s ease; +} + +.btn-bulk:hover { + background: var(--btn-bulk-hover, #0056b3); +} + +.btn-secondary { + padding: 0.5rem 1rem; + background: var(--btn-secondary-bg, #6c757d); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.2s ease; +} + +.btn-secondary:hover { + background: var(--btn-secondary-hover, #5a6268); +} + +/* Dark mode bulk actions */ +body.dark-mode .bulk-actions-toolbar { + --toolbar-bg: #3e3400; + --toolbar-border: #ffc107; + --text-primary: #f8f9fa; +} + +body.dark-mode .btn-bulk { + --btn-bulk-bg: #0d6efd; + --btn-bulk-hover: #0b5ed7; +} + +body.dark-mode .btn-secondary { + --btn-secondary-bg: #495057; + --btn-secondary-hover: #343a40; +} + +/* Modal styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; +} + +.modal-content { + background: var(--bg-primary, white); + padding: 2rem; + border-radius: 8px; + min-width: 300px; + max-width: 500px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.modal-content h3 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-primary, #333); +} + +.modal-body { + margin-bottom: 1.5rem; +} + +.modal-body label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-primary, #333); +} + +.modal-body select { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 4px; + background: var(--bg-secondary, #f8f9fa); + color: var(--text-primary, #333); +} + +.modal-footer { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +/* Dark mode modal */ +body.dark-mode .modal-content { + --bg-primary: #2d3748; + --bg-secondary: #1a202c; + --text-primary: #f7fafc; + --border-color: #4a5568; +} + +body.dark-mode .modal-body select { + background: #1a202c; + color: #f7fafc; + border-color: #4a5568; +} + +/* Checkbox styling in table */ +.ticket-checkbox { + cursor: pointer; + width: 18px; + height: 18px; +} + +#selectAllCheckbox { + cursor: pointer; + width: 18px; + height: 18px; +} + +/* Responsive bulk actions */ +@media (max-width: 768px) { + .bulk-actions-toolbar { + flex-direction: column; + align-items: stretch; + } + + .bulk-actions-info { + text-align: center; + margin-bottom: 0.5rem; + } + + .bulk-actions-buttons { + justify-content: center; + } +} diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index 33f1522..c33489b 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -913,3 +913,244 @@ function loadTemplate() { alert('Error loading template: ' + error.message); }); } + +/** + * Bulk Actions Functions (Admin only) + */ + +function toggleSelectAll() { + const selectAll = document.getElementById('selectAllCheckbox'); + const checkboxes = document.querySelectorAll('.ticket-checkbox'); + + checkboxes.forEach(checkbox => { + checkbox.checked = selectAll.checked; + }); + + updateSelectionCount(); +} + +function updateSelectionCount() { + const checkboxes = document.querySelectorAll('.ticket-checkbox:checked'); + const count = checkboxes.length; + const toolbar = document.querySelector('.bulk-actions-toolbar'); + const countDisplay = document.getElementById('selected-count'); + + if (count > 0) { + toolbar.style.display = 'flex'; + countDisplay.textContent = count; + } else { + toolbar.style.display = 'none'; + } +} + +function getSelectedTicketIds() { + const checkboxes = document.querySelectorAll('.ticket-checkbox:checked'); + return Array.from(checkboxes).map(cb => parseInt(cb.value)); +} + +function clearSelection() { + document.querySelectorAll('.ticket-checkbox').forEach(cb => cb.checked = false); + const selectAll = document.getElementById('selectAllCheckbox'); + if (selectAll) selectAll.checked = false; + updateSelectionCount(); +} + +function bulkClose() { + const ticketIds = getSelectedTicketIds(); + + if (ticketIds.length === 0) { + alert('No tickets selected'); + return; + } + + if (!confirm(`Close ${ticketIds.length} ticket(s)?`)) { + return; + } + + fetch('/api/bulk_operation.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operation_type: 'bulk_close', + ticket_ids: ticketIds + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(`Bulk Close Complete:\n${data.processed} succeeded\n${data.failed} failed`); + window.location.reload(); + } else { + alert('Error: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error performing bulk close:', error); + alert('Error performing bulk close: ' + error.message); + }); +} + +function showBulkAssignModal() { + const ticketIds = getSelectedTicketIds(); + + if (ticketIds.length === 0) { + alert('No tickets selected'); + return; + } + + // Create modal HTML + const modalHtml = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + + // Fetch users for the dropdown + fetch('/api/get_users.php') + .then(response => response.json()) + .then(data => { + if (data.success && data.users) { + const select = document.getElementById('bulkAssignUser'); + data.users.forEach(user => { + const option = document.createElement('option'); + option.value = user.user_id; + option.textContent = user.display_name || user.username; + select.appendChild(option); + }); + } + }) + .catch(error => { + console.error('Error loading users:', error); + }); +} + +function closeBulkAssignModal() { + const modal = document.getElementById('bulkAssignModal'); + if (modal) { + modal.remove(); + } +} + +function performBulkAssign() { + const userId = document.getElementById('bulkAssignUser').value; + const ticketIds = getSelectedTicketIds(); + + if (!userId) { + alert('Please select a user'); + return; + } + + fetch('/api/bulk_operation.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operation_type: 'bulk_assign', + ticket_ids: ticketIds, + parameters: { assigned_to: parseInt(userId) } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(`Bulk Assign Complete:\n${data.processed} succeeded\n${data.failed} failed`); + closeBulkAssignModal(); + window.location.reload(); + } else { + alert('Error: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error performing bulk assign:', error); + alert('Error performing bulk assign: ' + error.message); + }); +} + +function showBulkPriorityModal() { + const ticketIds = getSelectedTicketIds(); + + if (ticketIds.length === 0) { + alert('No tickets selected'); + return; + } + + const modalHtml = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); +} + +function closeBulkPriorityModal() { + const modal = document.getElementById('bulkPriorityModal'); + if (modal) { + modal.remove(); + } +} + +function performBulkPriority() { + const priority = document.getElementById('bulkPriority').value; + const ticketIds = getSelectedTicketIds(); + + if (!priority) { + alert('Please select a priority'); + return; + } + + fetch('/api/bulk_operation.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operation_type: 'bulk_priority', + ticket_ids: ticketIds, + parameters: { priority: parseInt(priority) } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(`Bulk Priority Update Complete:\n${data.processed} succeeded\n${data.failed} failed`); + closeBulkPriorityModal(); + window.location.reload(); + } else { + alert('Error: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error performing bulk priority update:', error); + alert('Error performing bulk priority update: ' + error.message); + }); +} diff --git a/models/BulkOperationsModel.php b/models/BulkOperationsModel.php new file mode 100644 index 0000000..ac35ffd --- /dev/null +++ b/models/BulkOperationsModel.php @@ -0,0 +1,198 @@ +conn = $conn; + } + + /** + * Create a new bulk operation record + * + * @param string $type Operation type (bulk_close, bulk_assign, bulk_priority) + * @param array $ticketIds Array of ticket IDs + * @param int $userId User performing the operation + * @param array|null $parameters Operation parameters + * @return int|false Operation ID or false on failure + */ + public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) { + $ticketIdsStr = implode(',', $ticketIds); + $totalTickets = count($ticketIds); + $parametersJson = $parameters ? json_encode($parameters) : null; + + $sql = "INSERT INTO bulk_operations (operation_type, ticket_ids, performed_by, parameters, total_tickets) + VALUES (?, ?, ?, ?, ?)"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("ssisi", $type, $ticketIdsStr, $userId, $parametersJson, $totalTickets); + + if ($stmt->execute()) { + $operationId = $this->conn->insert_id; + $stmt->close(); + return $operationId; + } + + $stmt->close(); + return false; + } + + /** + * Process a bulk operation + * + * @param int $operationId Operation ID + * @return array Result with processed and failed counts + */ + public function processBulkOperation($operationId) { + // Get operation details + $sql = "SELECT * FROM bulk_operations WHERE operation_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("i", $operationId); + $stmt->execute(); + $result = $stmt->get_result(); + $operation = $result->fetch_assoc(); + $stmt->close(); + + if (!$operation) { + return ['processed' => 0, 'failed' => 0, 'error' => 'Operation not found']; + } + + $ticketIds = explode(',', $operation['ticket_ids']); + $parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : []; + $processed = 0; + $failed = 0; + + // Load required models + require_once dirname(__DIR__) . '/models/TicketModel.php'; + require_once dirname(__DIR__) . '/models/AuditLogModel.php'; + + $ticketModel = new TicketModel($this->conn); + $auditLogModel = new AuditLogModel($this->conn); + + foreach ($ticketIds as $ticketId) { + $ticketId = trim($ticketId); + $success = false; + + try { + switch ($operation['operation_type']) { + case 'bulk_close': + // Get current ticket to preserve other fields + $currentTicket = $ticketModel->getTicketById($ticketId); + if ($currentTicket) { + $success = $ticketModel->updateTicket([ + 'ticket_id' => $ticketId, + 'title' => $currentTicket['title'], + 'description' => $currentTicket['description'], + 'category' => $currentTicket['category'], + 'type' => $currentTicket['type'], + 'status' => 'Closed', + 'priority' => $currentTicket['priority'] + ], $operation['performed_by']); + + if ($success) { + $auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, + ['status' => 'Closed', 'bulk_operation_id' => $operationId]); + } + } + break; + + case 'bulk_assign': + if (isset($parameters['assigned_to'])) { + $success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']); + if ($success) { + $auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId, + ['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]); + } + } + break; + + case 'bulk_priority': + if (isset($parameters['priority'])) { + $currentTicket = $ticketModel->getTicketById($ticketId); + if ($currentTicket) { + $success = $ticketModel->updateTicket([ + 'ticket_id' => $ticketId, + 'title' => $currentTicket['title'], + 'description' => $currentTicket['description'], + 'category' => $currentTicket['category'], + 'type' => $currentTicket['type'], + 'status' => $currentTicket['status'], + 'priority' => $parameters['priority'] + ], $operation['performed_by']); + + if ($success) { + $auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, + ['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]); + } + } + } + break; + } + + if ($success) { + $processed++; + } else { + $failed++; + } + } catch (Exception $e) { + $failed++; + error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage()); + } + } + + // Update operation status + $sql = "UPDATE bulk_operations SET status = 'completed', processed_tickets = ?, failed_tickets = ?, + completed_at = NOW() WHERE operation_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("iii", $processed, $failed, $operationId); + $stmt->execute(); + $stmt->close(); + + return ['processed' => $processed, 'failed' => $failed]; + } + + /** + * Get bulk operation by ID + * + * @param int $operationId Operation ID + * @return array|null Operation record or null + */ + public function getOperationById($operationId) { + $sql = "SELECT * FROM bulk_operations WHERE operation_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("i", $operationId); + $stmt->execute(); + $result = $stmt->get_result(); + $operation = $result->fetch_assoc(); + $stmt->close(); + return $operation; + } + + /** + * Get bulk operations performed by a user + * + * @param int $userId User ID + * @param int $limit Result limit + * @return array Array of operations + */ + public function getOperationsByUser($userId, $limit = 50) { + $sql = "SELECT * FROM bulk_operations WHERE performed_by = ? + ORDER BY created_at DESC LIMIT ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("ii", $userId, $limit); + $stmt->execute(); + $result = $stmt->get_result(); + + $operations = []; + while ($row = $result->fetch_assoc()) { + if ($row['parameters']) { + $row['parameters'] = json_decode($row['parameters'], true); + } + $operations[] = $row; + } + + $stmt->close(); + return $operations; + } +} diff --git a/views/DashboardView.php b/views/DashboardView.php index 1e186dd..a022878 100644 --- a/views/DashboardView.php +++ b/views/DashboardView.php @@ -173,9 +173,26 @@ + + + + + + + "; + + // Add checkbox column for admins + if ($GLOBALS['currentUser']['is_admin'] ?? false) { + echo ""; + } + echo ""; echo ""; echo ""; @@ -221,7 +244,8 @@ echo ""; } } else { - echo ""; + $colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '10' : '9'; + echo ""; } ?>
{$row['ticket_id']}{$row['priority']}" . htmlspecialchars($row['title']) . "
No tickets found
No tickets found