Files
tinker_tickets/assets/js/dashboard.js
Jared Vititoe a8738fdf57 Add comment threading and fix fetch authentication
- Add comment threading/reply functionality with nested display
  - Database migration for parent_comment_id and thread_depth columns
  - Recursive comment rendering with depth-based indentation
  - Reply form with inline UI and smooth animations
  - Thread collapse/expand capability
  - Max thread depth of 3 levels

- Fix 401 authentication errors on API calls
  - Add credentials: 'same-origin' to all fetch calls
  - Affects settings.js, ticket.js, dashboard.js, advanced-search.js
  - Ensures session cookies are sent with requests

- Enhanced comment styling
  - Thread connector lines for visual hierarchy
  - Reply button on comments (up to depth 3)
  - Quote block styling for replies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:43:36 -05:00

1982 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// XSS prevention helper
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
function getTicketIdFromUrl() {
const pathMatch = window.location.pathname.match(/\/ticket\/(\d+)/);
if (pathMatch) return pathMatch[1];
const params = new URLSearchParams(window.location.search);
return params.get('id');
}
/**
* Toggle sidebar visibility on desktop
*/
function toggleSidebar() {
const sidebar = document.getElementById('dashboardSidebar');
const layout = document.getElementById('dashboardLayout');
if (sidebar && layout) {
const isCollapsed = sidebar.classList.toggle('collapsed');
layout.classList.toggle('sidebar-collapsed', isCollapsed);
// Store state in localStorage
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
}
}
/**
* Mobile sidebar functions
*/
function openMobileSidebar() {
const sidebar = document.getElementById('dashboardSidebar');
const overlay = document.getElementById('mobileSidebarOverlay');
if (sidebar) {
sidebar.classList.add('mobile-open');
}
if (overlay) {
overlay.classList.add('active');
}
document.body.style.overflow = 'hidden';
}
function closeMobileSidebar() {
const sidebar = document.getElementById('dashboardSidebar');
const overlay = document.getElementById('mobileSidebarOverlay');
if (sidebar) {
sidebar.classList.remove('mobile-open');
}
if (overlay) {
overlay.classList.remove('active');
}
document.body.style.overflow = '';
}
// Initialize mobile elements
function initMobileSidebar() {
const sidebar = document.getElementById('dashboardSidebar');
const dashboardMain = document.querySelector('.dashboard-main');
// Create overlay if it doesn't exist
if (!document.getElementById('mobileSidebarOverlay')) {
const overlay = document.createElement('div');
overlay.id = 'mobileSidebarOverlay';
overlay.className = 'mobile-sidebar-overlay';
overlay.onclick = closeMobileSidebar;
document.body.appendChild(overlay);
}
// Create mobile filter toggle button
if (dashboardMain && !document.getElementById('mobileFilterToggle')) {
const toggleBtn = document.createElement('button');
toggleBtn.id = 'mobileFilterToggle';
toggleBtn.className = 'mobile-filter-toggle';
toggleBtn.innerHTML = '☰ Filters & Search Options';
toggleBtn.onclick = openMobileSidebar;
dashboardMain.insertBefore(toggleBtn, dashboardMain.firstChild);
}
// Create close button inside sidebar
if (sidebar && !sidebar.querySelector('.mobile-sidebar-close')) {
const closeBtn = document.createElement('button');
closeBtn.className = 'mobile-sidebar-close';
closeBtn.innerHTML = '×';
closeBtn.onclick = closeMobileSidebar;
sidebar.insertBefore(closeBtn, sidebar.firstChild);
}
// Create mobile bottom navigation
if (!document.getElementById('mobileBottomNav')) {
const nav = document.createElement('nav');
nav.id = 'mobileBottomNav';
nav.className = 'mobile-bottom-nav';
nav.innerHTML = `
<a href="/">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a>
<button type="button" data-action="open-mobile-sidebar">
<span class="nav-icon">🔍</span>
<span>Filter</span>
</button>
<a href="/ticket/create">
<span class="nav-icon"></span>
<span>New</span>
</a>
<button type="button" data-action="open-settings-modal">
<span class="nav-icon">⚙️</span>
<span>Settings</span>
</button>
`;
document.body.appendChild(nav);
}
}
// Restore sidebar state on page load
document.addEventListener('DOMContentLoaded', function() {
const savedState = localStorage.getItem('sidebarCollapsed');
const sidebar = document.getElementById('dashboardSidebar');
const layout = document.getElementById('dashboardLayout');
if (savedState === 'true' && sidebar && layout) {
sidebar.classList.add('collapsed');
layout.classList.add('sidebar-collapsed');
}
});
// Main initialization
document.addEventListener('DOMContentLoaded', function() {
// Initialize mobile sidebar for dashboard
initMobileSidebar();
// Check if we're on the dashboard page
const hasTable = document.querySelector('table');
const isTicketPage = window.location.pathname.includes('/ticket/') ||
window.location.href.includes('ticket.php') ||
document.querySelector('.ticket-details') !== null;
const isDashboard = hasTable && !isTicketPage;
if (isDashboard) {
// Dashboard-specific initialization
initStatusFilter();
initTableSorting();
initSidebarFilters();
}
// Initialize for all pages
initSettingsModal();
// Force dark mode only (terminal aesthetic - no theme switching)
document.documentElement.setAttribute('data-theme', 'dark');
document.body.classList.add('dark-mode');
// Event delegation for dynamically created modals
document.addEventListener('click', function(e) {
const target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
// Bulk operations
case 'perform-bulk-assign':
performBulkAssign();
break;
case 'close-bulk-assign-modal':
closeBulkAssignModal();
break;
case 'perform-bulk-priority':
performBulkPriority();
break;
case 'close-bulk-priority-modal':
closeBulkPriorityModal();
break;
case 'perform-bulk-status':
performBulkStatusChange();
break;
case 'close-bulk-status-modal':
closeBulkStatusModal();
break;
case 'perform-bulk-delete':
performBulkDelete();
break;
case 'close-bulk-delete-modal':
closeBulkDeleteModal();
break;
// Quick actions
case 'perform-quick-status':
performQuickStatusChange(target.dataset.ticketId);
break;
case 'close-quick-status-modal':
closeQuickStatusModal();
break;
case 'perform-quick-assign':
performQuickAssign(target.dataset.ticketId);
break;
case 'close-quick-assign-modal':
closeQuickAssignModal();
break;
// Mobile navigation
case 'open-mobile-sidebar':
if (typeof openMobileSidebar === 'function') openMobileSidebar();
break;
case 'open-settings-modal':
if (typeof openSettingsModal === 'function') openSettingsModal();
break;
// Filter badge actions
case 'remove-filter':
removeFilter(target.dataset.filterType, target.dataset.filterValue);
break;
case 'clear-all-filters':
clearAllFilters();
break;
}
});
});
/**
* Remove a single filter and reload page
*/
function removeFilter(filterType, filterValue) {
const params = new URLSearchParams(window.location.search);
if (filterType === 'status') {
const currentStatuses = (params.get('status') || '').split(',').filter(s => s.trim());
const newStatuses = currentStatuses.filter(s => s !== filterValue);
if (newStatuses.length > 0) {
params.set('status', newStatuses.join(','));
} else {
params.delete('status');
}
} else if (filterType === 'priority') {
const currentPriorities = (params.get('priority') || '').split(',').filter(p => p.trim());
const newPriorities = currentPriorities.filter(p => p !== filterValue);
if (newPriorities.length > 0) {
params.set('priority', newPriorities.join(','));
} else {
params.delete('priority');
}
} else if (filterType === 'search') {
params.delete('search');
} else {
params.delete(filterType);
}
// Reset to page 1 when changing filters
params.delete('page');
window.location.search = params.toString();
}
/**
* Clear all filters and reload page
*/
function clearAllFilters() {
const params = new URLSearchParams(window.location.search);
// Remove all filter parameters
params.delete('status');
params.delete('priority');
params.delete('category');
params.delete('type');
params.delete('assigned_to');
params.delete('search');
params.delete('date_from');
params.delete('date_to');
params.delete('page');
// Keep sort parameters
const sortParams = new URLSearchParams();
if (params.has('sort')) sortParams.set('sort', params.get('sort'));
if (params.has('dir')) sortParams.set('dir', params.get('dir'));
window.location.search = sortParams.toString();
}
function initTableSorting() {
const tableHeaders = document.querySelectorAll('th');
tableHeaders.forEach((header, index) => {
header.style.cursor = 'pointer';
header.addEventListener('click', () => {
const table = header.closest('table');
sortTable(table, index);
});
});
}
function initSidebarFilters() {
const applyFiltersBtn = document.getElementById('apply-filters-btn');
const clearFiltersBtn = document.getElementById('clear-filters-btn');
if (applyFiltersBtn) {
applyFiltersBtn.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
// Collect selected statuses
const selectedStatuses = Array.from(
document.querySelectorAll('.filter-group input[name="status"]:checked')
).map(cb => cb.value);
// Collect selected categories
const selectedCategories = Array.from(
document.querySelectorAll('.filter-group input[name="category"]:checked')
).map(cb => cb.value);
// Collect selected types
const selectedTypes = Array.from(
document.querySelectorAll('.filter-group input[name="type"]:checked')
).map(cb => cb.value);
// Update URL parameters
if (selectedStatuses.length > 0) {
params.set('status', selectedStatuses.join(','));
} else {
params.delete('status');
}
if (selectedCategories.length > 0) {
params.set('category', selectedCategories.join(','));
} else {
params.delete('category');
}
if (selectedTypes.length > 0) {
params.set('type', selectedTypes.join(','));
} else {
params.delete('type');
}
// Reset to page 1 when filters change
params.set('page', '1');
// Reload with new parameters
window.location.search = params.toString();
});
}
if (clearFiltersBtn) {
clearFiltersBtn.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
// Remove filter parameters
params.delete('status');
params.delete('category');
params.delete('type');
params.set('page', '1');
// Reload with cleared filters
window.location.search = params.toString();
});
}
}
function initSettingsModal() {
const settingsIcon = document.querySelector('.settings-icon');
if (settingsIcon) {
settingsIcon.addEventListener('click', function(e) {
e.preventDefault();
// openSettingsModal is defined in settings.js
if (typeof openSettingsModal === 'function') {
openSettingsModal();
}
});
}
}
function sortTable(table, column) {
const headers = table.querySelectorAll('th');
headers.forEach(header => {
header.classList.remove('sort-asc', 'sort-desc');
});
const rows = Array.from(table.querySelectorAll('tbody tr'));
const currentDirection = table.dataset.sortColumn == column
? (table.dataset.sortDirection === 'asc' ? 'desc' : 'asc')
: 'asc';
table.dataset.sortColumn = column;
table.dataset.sortDirection = currentDirection;
rows.sort((a, b) => {
const aValue = a.children[column].textContent.trim();
const bValue = b.children[column].textContent.trim();
// Check if this is a date column
const headerText = headers[column].textContent.toLowerCase();
if (headerText === 'created' || headerText === 'updated') {
const dateA = new Date(aValue);
const dateB = new Date(bValue);
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
}
// Special handling for "Assigned To" column
if (headerText === 'assigned to') {
const aUnassigned = aValue === 'Unassigned';
const bUnassigned = bValue === 'Unassigned';
// Both unassigned - equal
if (aUnassigned && bUnassigned) return 0;
// Put unassigned at the end regardless of sort direction
if (aUnassigned) return 1;
if (bUnassigned) return -1;
// Otherwise sort names normally
return currentDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
// Numeric comparison
const numA = parseFloat(aValue);
const numB = parseFloat(bValue);
if (!isNaN(numA) && !isNaN(numB)) {
return currentDirection === 'asc' ? numA - numB : numB - numA;
}
// String comparison
return currentDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
});
const currentHeader = headers[column];
currentHeader.classList.add(currentDirection === 'asc' ? 'sort-asc' : 'sort-desc');
const tbody = table.querySelector('tbody');
rows.forEach(row => tbody.appendChild(row));
}
// Old settings modal functions removed - now using settings.js with new settings modal
function initStatusFilter() {
const filterContainer = document.createElement('div');
filterContainer.className = 'status-filter-container';
const dropdown = document.createElement('div');
dropdown.className = 'status-dropdown';
const dropdownHeader = document.createElement('div');
dropdownHeader.className = 'dropdown-header';
dropdownHeader.textContent = 'Status Filter';
const dropdownContent = document.createElement('div');
dropdownContent.className = 'dropdown-content';
const statuses = ['Open', 'In Progress', 'Closed'];
statuses.forEach(status => {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = status;
checkbox.id = `status-${status.toLowerCase().replace(/\s+/g, '-')}`;
const urlParams = new URLSearchParams(window.location.search);
const currentStatuses = urlParams.get('status') ? urlParams.get('status').split(',') : [];
const showAll = urlParams.get('show_all');
// FIXED LOGIC: Determine checkbox state
if (showAll === '1') {
// If show_all=1 parameter exists, all should be checked
checkbox.checked = true;
} else if (currentStatuses.length === 0) {
// No status parameter - default: Open and In Progress checked, Closed unchecked
checkbox.checked = status !== 'Closed';
} else {
// Status parameter exists - check if this status is in the list
checkbox.checked = currentStatuses.includes(status);
}
label.appendChild(checkbox);
label.appendChild(document.createTextNode(' ' + status));
dropdownContent.appendChild(label);
});
const saveButton = document.createElement('button');
saveButton.className = 'btn save-filter';
saveButton.textContent = 'Apply Filter';
saveButton.onclick = () => {
const checkedBoxes = dropdownContent.querySelectorAll('input:checked');
const selectedStatuses = Array.from(checkedBoxes).map(cb => cb.value);
const params = new URLSearchParams(window.location.search);
if (selectedStatuses.length === 0) {
// No statuses selected - show default (Open + In Progress)
params.delete('status');
params.delete('show_all');
} else if (selectedStatuses.length === 3) {
// All statuses selected - show all tickets
params.delete('status');
params.set('show_all', '1');
} else {
// Some statuses selected - set the parameter
params.set('status', selectedStatuses.join(','));
params.delete('show_all');
}
params.set('page', '1');
window.location.search = params.toString();
dropdown.classList.remove('active');
};
dropdownHeader.onclick = () => {
dropdown.classList.toggle('active');
};
dropdown.appendChild(dropdownHeader);
dropdown.appendChild(dropdownContent);
dropdownContent.appendChild(saveButton);
filterContainer.appendChild(dropdown);
const tableActions = document.querySelector('.table-controls .table-actions');
if (tableActions) {
tableActions.prepend(filterContainer);
}
}
function quickSave() {
if (!window.ticketData) {
console.error('No ticket data available');
return;
}
const statusSelect = document.getElementById('status-select');
const prioritySelect = document.getElementById('priority-select');
if (!statusSelect || !prioritySelect) {
console.error('Status or priority select not found');
return;
}
const data = {
ticket_id: parseInt(window.ticketData.id),
status: statusSelect.value,
priority: parseInt(prioritySelect.value)
};
fetch('/api/update_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(response => {
return response.text().then(text => {
try {
return JSON.parse(text);
} catch (e) {
throw new Error('Invalid JSON response: ' + text);
}
});
})
.then(result => {
if (result.success) {
// Update the hamburger menu display
const hamburgerStatus = document.getElementById('hamburger-status');
const hamburgerPriority = document.getElementById('hamburger-priority');
if (hamburgerStatus) hamburgerStatus.textContent = statusSelect.value;
if (hamburgerPriority) hamburgerPriority.textContent = 'P' + prioritySelect.value;
// Update window.ticketData
window.ticketData.status = statusSelect.value;
window.ticketData.priority = parseInt(prioritySelect.value);
// Update main page elements if they exist
const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) {
statusDisplay.className = `status-${statusSelect.value}`;
statusDisplay.textContent = statusSelect.value;
}
// Close hamburger menu after successful save
const hamburgerContent = document.querySelector('.hamburger-content');
if (hamburgerContent) {
hamburgerContent.classList.remove('open');
document.body.classList.remove('menu-open');
}
} else {
console.error('Error updating ticket:', result.error || 'Unknown error');
toast.error('Error updating ticket: ' + (result.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error updating ticket:', error);
toast.error('Error updating ticket: ' + error.message, 5000);
});
}
// Ticket page functions (if needed)
function saveTicket() {
const editables = document.querySelectorAll('.editable');
const data = {};
const ticketId = getTicketIdFromUrl();
editables.forEach(field => {
if (field.dataset.field) {
data[field.dataset.field] = field.value;
}
});
fetch('/api/update_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
...data
})
})
.then(response => response.json())
.then(data => {
if(data.success) {
const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) {
statusDisplay.className = `status-${data.status}`;
statusDisplay.textContent = data.status;
}
}
});
}
/**
* Load template data into the create ticket form
*/
function loadTemplate() {
const templateSelect = document.getElementById('templateSelect');
const templateId = templateSelect.value;
if (!templateId) {
// Clear form when "No Template" is selected
document.getElementById('title').value = '';
document.getElementById('description').value = '';
document.getElementById('priority').value = '4';
document.getElementById('category').value = 'General';
document.getElementById('type').value = 'Issue';
return;
}
// Fetch template data
fetch(`/api/get_template.php?template_id=${templateId}`, {
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch template');
}
return response.json();
})
.then(data => {
if (data.success && data.template) {
const template = data.template;
// Populate form fields with template data
if (template.title_template) {
document.getElementById('title').value = template.title_template;
}
if (template.description_template) {
document.getElementById('description').value = template.description_template;
}
if (template.category) {
document.getElementById('category').value = template.category;
}
if (template.type) {
document.getElementById('type').value = template.type;
}
if (template.default_priority) {
document.getElementById('priority').value = template.default_priority;
}
} else {
console.error('Failed to load template:', data.error);
toast.error('Failed to load template: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
console.error('Error loading template:', error);
toast.error('Error loading template: ' + error.message, 4000);
});
}
/**
* 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();
}
/**
* Toggle checkbox when clicking anywhere in the checkbox cell
*/
function toggleRowCheckbox(event, cell) {
// Prevent double-toggle if clicking directly on the checkbox
if (event.target.type === 'checkbox') return;
event.stopPropagation();
const cb = cell.querySelector('.ticket-checkbox');
if (cb) {
cb.checked = !cb.checked;
updateSelectionCount();
}
}
function updateSelectionCount() {
const checkboxes = document.querySelectorAll('.ticket-checkbox:checked');
const count = checkboxes.length;
const toolbar = document.querySelector('.bulk-actions-inline');
const countDisplay = document.getElementById('selected-count');
const exportDropdown = document.getElementById('exportDropdown');
const exportCount = document.getElementById('exportCount');
if (toolbar && countDisplay) {
if (count > 0) {
toolbar.style.display = 'flex';
countDisplay.textContent = count;
} else {
toolbar.style.display = 'none';
}
}
// Show/hide export dropdown based on selection
if (exportDropdown) {
if (count > 0) {
exportDropdown.style.display = '';
if (exportCount) exportCount.textContent = count;
} else {
exportDropdown.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) {
toast.warning('No tickets selected', 2000);
return;
}
showConfirmModal(
`Close ${ticketIds.length} Ticket(s)?`,
'Are you sure you want to close these tickets?',
'warning',
() => performBulkCloseAction(ticketIds)
);
}
function performBulkCloseAction(ticketIds) {
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_close',
ticket_ids: ticketIds
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.failed > 0) {
toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk close:', error);
toast.error('Bulk close failed: ' + error.message, 5000);
});
}
function showBulkAssignModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
return;
}
// Create modal HTML
const modalHtml = `
<div class="modal-overlay" id="bulkAssignModal">
<div class="modal-content ascii-frame-outer">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Assign ${ticketIds.length} Ticket(s)</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body">
<label for="bulkAssignUser">Assign to:</label>
<select id="bulkAssignUser" class="editable">
<option value="">Select User...</option>
<!-- Users will be loaded dynamically -->
</select>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button data-action="perform-bulk-assign" class="btn btn-bulk">Assign</button>
<button data-action="close-bulk-assign-modal" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Fetch users for the dropdown
fetch('/api/get_users.php', {
credentials: 'same-origin'
})
.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) {
toast.warning('Please select a user', 2000);
return;
}
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_assign',
ticket_ids: ticketIds,
parameters: { assigned_to: parseInt(userId) }
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeBulkAssignModal();
if (data.failed > 0) {
toast.warning(`Bulk assign: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk assign:', error);
toast.error('Bulk assign failed: ' + error.message, 5000);
});
}
function showBulkPriorityModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
return;
}
const modalHtml = `
<div class="modal-overlay" id="bulkPriorityModal">
<div class="modal-content ascii-frame-outer">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Change Priority for ${ticketIds.length} Ticket(s)</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body">
<label for="bulkPriority">Priority:</label>
<select id="bulkPriority" class="editable">
<option value="">Select Priority...</option>
<option value="1">P1 - Critical Impact</option>
<option value="2">P2 - High Impact</option>
<option value="3">P3 - Medium Impact</option>
<option value="4">P4 - Low Impact</option>
<option value="5">P5 - Minimal Impact</option>
</select>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button data-action="perform-bulk-priority" class="btn btn-bulk">Update</button>
<button data-action="close-bulk-priority-modal" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
`;
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) {
toast.warning('Please select a priority', 2000);
return;
}
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_priority',
ticket_ids: ticketIds,
parameters: { priority: parseInt(priority) }
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeBulkPriorityModal();
if (data.failed > 0) {
toast.warning(`Priority update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
toast.success(`Successfully updated priority for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk priority update:', error);
toast.error('Bulk priority update failed: ' + error.message, 5000);
});
}
// Make table rows clickable
document.addEventListener('DOMContentLoaded', function() {
const tableRows = document.querySelectorAll('tbody tr');
tableRows.forEach(row => {
// Skip if row already has click handler
if (row.dataset.clickable) return;
row.dataset.clickable = 'true';
row.style.cursor = 'pointer';
row.addEventListener('click', function(e) {
// Don't navigate if clicking on a link, button, checkbox, or select
if (e.target.tagName === 'A' ||
e.target.tagName === 'BUTTON' ||
e.target.tagName === 'INPUT' ||
e.target.tagName === 'SELECT' ||
e.target.closest('a') ||
e.target.closest('button')) {
return;
}
// Find the ticket link in the row
const ticketLink = row.querySelector('.ticket-link');
if (ticketLink) {
window.location.href = ticketLink.href;
}
});
// Add hover effect
row.addEventListener('mouseenter', function() {
this.style.backgroundColor = 'rgba(0, 255, 65, 0.08)';
});
row.addEventListener('mouseleave', function() {
this.style.backgroundColor = '';
});
});
});
// Bulk Status Change
function showBulkStatusModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
return;
}
const modalHtml = `
<div class="modal-overlay" id="bulkStatusModal">
<div class="modal-content ascii-frame-outer">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Change Status for ${ticketIds.length} Ticket(s)</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body">
<label for="bulkStatus">New Status:</label>
<select id="bulkStatus" class="editable">
<option value="">Select Status...</option>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button data-action="perform-bulk-status" class="btn btn-bulk">Update</button>
<button data-action="close-bulk-status-modal" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
}
function closeBulkStatusModal() {
const modal = document.getElementById('bulkStatusModal');
if (modal) {
modal.remove();
}
}
function performBulkStatusChange() {
const status = document.getElementById('bulkStatus').value;
const ticketIds = getSelectedTicketIds();
if (!status) {
toast.warning('Please select a status', 2000);
return;
}
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_status',
ticket_ids: ticketIds,
parameters: { status: status }
})
})
.then(response => response.json())
.then(data => {
closeBulkStatusModal();
if (data.success) {
if (data.failed > 0) {
toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk status change:', error);
toast.error('Bulk status change failed: ' + error.message, 5000);
});
}
// Bulk Delete
function showBulkDeleteModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
return;
}
const modalHtml = `
<div class="modal-overlay" id="bulkDeleteModal">
<div class="modal-content ascii-frame-outer">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header" style="color: var(--status-closed);">⚠ Delete ${ticketIds.length} Ticket(s)</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body" style="text-align: center; padding: 2rem;">
<p style="color: var(--terminal-amber); font-size: 1.1rem; margin-bottom: 1rem;">
This action cannot be undone!
</p>
<p style="color: var(--terminal-green);">
You are about to permanently delete ${ticketIds.length} ticket(s).<br>
All associated comments and history will be lost.
</p>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button data-action="perform-bulk-delete" class="btn btn-bulk" style="background: var(--status-closed); border-color: var(--status-closed);">Delete Permanently</button>
<button data-action="close-bulk-delete-modal" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
}
function closeBulkDeleteModal() {
const modal = document.getElementById('bulkDeleteModal');
if (modal) {
modal.remove();
}
}
function performBulkDelete() {
const ticketIds = getSelectedTicketIds();
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_delete',
ticket_ids: ticketIds
})
})
.then(response => response.json())
.then(data => {
closeBulkDeleteModal();
if (data.success) {
toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk delete:', error);
toast.error('Bulk delete failed: ' + error.message, 5000);
});
}
// ============================================
// TERMINAL-STYLE MODAL UTILITIES
// ============================================
/**
* Show a terminal-style confirmation modal
* @param {string} title - Modal title
* @param {string} message - Message body
* @param {string} type - 'warning', 'error', 'info' (affects color)
* @param {function} onConfirm - Callback when user confirms
* @param {function} onCancel - Optional callback when user cancels
*/
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now();
// Color scheme based on type
const colors = {
warning: 'var(--terminal-amber)',
error: 'var(--status-closed)',
info: 'var(--terminal-cyan)'
};
const color = colors[type] || colors.warning;
// Icon based on type
const icons = {
warning: '⚠',
error: '✗',
info: ''
};
const icon = icons[type] || icons.warning;
// Escape user-provided content to prevent XSS
const safeTitle = escapeHtml(title);
const safeMessage = escapeHtml(message);
const modalHtml = `
<div class="modal-overlay" id="${modalId}">
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header" style="color: ${color};">
${icon} ${safeTitle}
</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body" style="padding: 1.5rem; text-align: center;">
<p style="color: var(--terminal-green); white-space: pre-line;">
${safeMessage}
</p>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button class="btn btn-primary" id="${modalId}_confirm">Confirm</button>
<button class="btn btn-secondary" id="${modalId}_cancel">Cancel</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById(modalId);
const confirmBtn = document.getElementById(`${modalId}_confirm`);
const cancelBtn = document.getElementById(`${modalId}_cancel`);
confirmBtn.addEventListener('click', () => {
modal.remove();
if (onConfirm) onConfirm();
});
cancelBtn.addEventListener('click', () => {
modal.remove();
if (onCancel) onCancel();
});
// ESC key to cancel
const escHandler = (e) => {
if (e.key === 'Escape') {
modal.remove();
if (onCancel) onCancel();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}
/**
* Show a terminal-style input modal
* @param {string} title - Modal title
* @param {string} label - Input field label
* @param {string} placeholder - Input placeholder text
* @param {function} onSubmit - Callback with input value when submitted
* @param {function} onCancel - Optional callback when cancelled
*/
function showInputModal(title, label, placeholder = '', onSubmit, onCancel = null) {
const modalId = 'inputModal' + Date.now();
const inputId = modalId + '_input';
// Escape user-provided content to prevent XSS
const safeTitle = escapeHtml(title);
const safeLabel = escapeHtml(label);
const safePlaceholder = escapeHtml(placeholder);
const modalHtml = `
<div class="modal-overlay" id="${modalId}">
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">
${safeTitle}
</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body" style="padding: 1.5rem;">
<label for="${inputId}" style="display: block; margin-bottom: 0.5rem; color: var(--terminal-green);">
${safeLabel}
</label>
<input
type="text"
id="${inputId}"
class="terminal-input"
placeholder="${safePlaceholder}"
style="width: 100%; padding: 0.5rem; background: var(--bg-primary); border: 1px solid var(--terminal-green); color: var(--terminal-green); font-family: var(--font-mono);"
/>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button class="btn btn-primary" id="${modalId}_submit">Save</button>
<button class="btn btn-secondary" id="${modalId}_cancel">Cancel</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById(modalId);
const input = document.getElementById(inputId);
const submitBtn = document.getElementById(`${modalId}_submit`);
const cancelBtn = document.getElementById(`${modalId}_cancel`);
// Focus input
setTimeout(() => input.focus(), 100);
const handleSubmit = () => {
const value = input.value.trim();
modal.remove();
if (onSubmit) onSubmit(value);
};
submitBtn.addEventListener('click', handleSubmit);
// Enter key to submit
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleSubmit();
}
});
cancelBtn.addEventListener('click', () => {
modal.remove();
if (onCancel) onCancel();
});
// ESC key to cancel
const escHandler = (e) => {
if (e.key === 'Escape') {
modal.remove();
if (onCancel) onCancel();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}
// ========================================
// QUICK ACTIONS
// ========================================
/**
* Quick status change from dashboard
*/
function quickStatusChange(ticketId, currentStatus) {
const statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
const otherStatuses = statuses.filter(s => s !== currentStatus);
const modalHtml = `
<div class="modal-overlay" id="quickStatusModal">
<div class="modal-content ascii-frame-outer" style="max-width: 400px;">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Quick Status Change</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body" style="padding: 1rem;">
<p style="margin-bottom: 1rem;">Ticket #${escapeHtml(ticketId)}</p>
<p style="margin-bottom: 0.5rem; color: var(--terminal-amber);">Current: ${escapeHtml(currentStatus)}</p>
<label for="quickStatusSelect">New Status:</label>
<select id="quickStatusSelect" class="editable" style="width: 100%; margin-top: 0.5rem;">
${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')}
</select>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button data-action="perform-quick-status" data-ticket-id="${ticketId}" class="btn btn-primary">Update</button>
<button data-action="close-quick-status-modal" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
}
function closeQuickStatusModal() {
const modal = document.getElementById('quickStatusModal');
if (modal) modal.remove();
}
function performQuickStatusChange(ticketId) {
const newStatus = document.getElementById('quickStatusSelect').value;
fetch('/api/update_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
status: newStatus
})
})
.then(response => response.json())
.then(data => {
closeQuickStatusModal();
if (data.success) {
toast.success(`Status updated to ${newStatus}`, 3000);
setTimeout(() => window.location.reload(), 1000);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
closeQuickStatusModal();
console.error('Error:', error);
toast.error('Error updating status', 4000);
});
}
/**
* Quick assign from dashboard
*/
function quickAssign(ticketId) {
const modalHtml = `
<div class="modal-overlay" id="quickAssignModal">
<div class="modal-content ascii-frame-outer" style="max-width: 400px;">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Quick Assign</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body" style="padding: 1rem;">
<p style="margin-bottom: 1rem;">Ticket #${escapeHtml(ticketId)}</p>
<label for="quickAssignSelect">Assign to:</label>
<select id="quickAssignSelect" class="editable" style="width: 100%; margin-top: 0.5rem;">
<option value="">Unassigned</option>
</select>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button data-action="perform-quick-assign" data-ticket-id="${ticketId}" class="btn btn-primary">Assign</button>
<button data-action="close-quick-assign-modal" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Load users
fetch('/api/get_users.php', {
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success && data.users) {
const select = document.getElementById('quickAssignSelect');
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 closeQuickAssignModal() {
const modal = document.getElementById('quickAssignModal');
if (modal) modal.remove();
}
function performQuickAssign(ticketId) {
const assignedTo = document.getElementById('quickAssignSelect').value || null;
fetch('/api/assign_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
assigned_to: assignedTo
})
})
.then(response => response.json())
.then(data => {
closeQuickAssignModal();
if (data.success) {
toast.success('Assignment updated', 3000);
setTimeout(() => window.location.reload(), 1000);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
closeQuickAssignModal();
console.error('Error:', error);
toast.error('Error updating assignment', 4000);
});
}
// ========================================
// KANBAN / CARD VIEW
// ========================================
/**
* Set the view mode (table or card)
*/
function setViewMode(mode) {
const tableView = document.querySelector('.ascii-frame-outer');
const cardView = document.getElementById('cardView');
const tableBtn = document.getElementById('tableViewBtn');
const cardBtn = document.getElementById('cardViewBtn');
if (!tableView || !cardView) return;
if (mode === 'card') {
tableView.style.display = 'none';
cardView.style.display = 'block';
tableBtn.classList.remove('active');
cardBtn.classList.add('active');
populateKanbanCards();
} else {
tableView.style.display = 'block';
cardView.style.display = 'none';
tableBtn.classList.add('active');
cardBtn.classList.remove('active');
}
// Store preference
localStorage.setItem('ticketViewMode', mode);
}
/**
* Populate Kanban cards from table data
*/
function populateKanbanCards() {
const rows = document.querySelectorAll('tbody tr');
const columns = {
'Open': document.querySelector('.kanban-column[data-status="Open"] .kanban-cards'),
'Pending': document.querySelector('.kanban-column[data-status="Pending"] .kanban-cards'),
'In Progress': document.querySelector('.kanban-column[data-status="In Progress"] .kanban-cards'),
'Closed': document.querySelector('.kanban-column[data-status="Closed"] .kanban-cards')
};
// Clear existing cards
Object.values(columns).forEach(col => {
if (col) col.innerHTML = '';
});
const counts = { 'Open': 0, 'Pending': 0, 'In Progress': 0, 'Closed': 0 };
const isAdmin = document.getElementById('selectAllCheckbox') !== null;
const offset = isAdmin ? 1 : 0;
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length < 6) return; // Skip empty rows
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
const priority = cells[1 + offset]?.textContent.trim() || '';
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const status = cells[5 + offset]?.textContent.trim() || '';
const assignedTo = cells[7 + offset]?.textContent.trim() || 'Unassigned';
// Get initials for assignee
const initials = assignedTo === 'Unassigned' ? '?' :
assignedTo.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const column = columns[status];
if (column) {
counts[status]++;
const card = document.createElement('div');
card.className = `kanban-card priority-${priority}`;
card.onclick = () => window.location.href = `/ticket/${ticketId}`;
card.innerHTML = `
<div class="card-header">
<span class="card-id">#${escapeHtml(ticketId)}</span>
<span class="card-priority p${priority}">P${priority}</span>
</div>
<div class="card-title">${escapeHtml(title)}</div>
<div class="card-footer">
<span class="card-category">${escapeHtml(category)}</span>
<span class="card-assignee" title="${escapeHtml(assignedTo)}">${escapeHtml(initials)}</span>
</div>
`;
column.appendChild(card);
}
});
// Update column counts
Object.keys(counts).forEach(status => {
const header = document.querySelector(`.kanban-column[data-status="${status}"] .column-count`);
if (header) header.textContent = counts[status];
});
}
// Restore view mode on page load
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem('ticketViewMode');
if (savedMode === 'card') {
// Delay to ensure DOM is ready
setTimeout(() => setViewMode('card'), 100);
}
});
// ========================================
// INLINE TICKET PREVIEW
// ========================================
let previewTimeout = null;
let currentPreview = null;
function initTicketPreview() {
// Create preview element
const preview = document.createElement('div');
preview.id = 'ticketPreview';
preview.className = 'ticket-preview-popup';
preview.style.display = 'none';
document.body.appendChild(preview);
currentPreview = preview;
// Add event listeners to ticket links
document.querySelectorAll('.ticket-link').forEach(link => {
link.addEventListener('mouseenter', showTicketPreview);
link.addEventListener('mouseleave', hideTicketPreview);
});
// Keep preview visible when hovering over it
preview.addEventListener('mouseenter', () => {
if (previewTimeout) {
clearTimeout(previewTimeout);
previewTimeout = null;
}
});
preview.addEventListener('mouseleave', hideTicketPreview);
}
function showTicketPreview(event) {
const link = event.target.closest('.ticket-link');
if (!link) return;
// Clear any pending hide
if (previewTimeout) {
clearTimeout(previewTimeout);
}
// Delay before showing
previewTimeout = setTimeout(() => {
const row = link.closest('tr');
if (!row) return;
// Extract data from the table row
const cells = row.querySelectorAll('td');
const isAdmin = document.getElementById('selectAllCheckbox') !== null;
const offset = isAdmin ? 1 : 0;
const ticketId = link.textContent.trim();
const priority = cells[1 + offset]?.textContent.trim() || '';
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const type = cells[4 + offset]?.textContent.trim() || '';
const status = cells[5 + offset]?.textContent.trim() || '';
const createdBy = cells[6 + offset]?.textContent.trim() || '';
const assignedTo = cells[7 + offset]?.textContent.trim() || '';
// Build preview content
currentPreview.innerHTML = `
<div class="preview-header">
<span class="preview-id">#${escapeHtml(ticketId)}</span>
<span class="preview-status status-${status.replace(/\s+/g, '-')}">${escapeHtml(status)}</span>
</div>
<div class="preview-title">${escapeHtml(title)}</div>
<div class="preview-meta">
<div><strong>Priority:</strong> P${escapeHtml(priority)}</div>
<div><strong>Category:</strong> ${escapeHtml(category)}</div>
<div><strong>Type:</strong> ${escapeHtml(type)}</div>
<div><strong>Assigned:</strong> ${escapeHtml(assignedTo)}</div>
</div>
<div class="preview-footer">Created by ${escapeHtml(createdBy)}</div>
`;
// Position the preview
const rect = link.getBoundingClientRect();
const previewWidth = 320;
const previewHeight = 200;
let left = rect.left + window.scrollX;
let top = rect.bottom + window.scrollY + 5;
// Adjust if going off-screen
if (left + previewWidth > window.innerWidth) {
left = window.innerWidth - previewWidth - 20;
}
if (top + previewHeight > window.innerHeight + window.scrollY) {
top = rect.top + window.scrollY - previewHeight - 5;
}
currentPreview.style.left = left + 'px';
currentPreview.style.top = top + 'px';
currentPreview.style.display = 'block';
}, 300);
}
function hideTicketPreview() {
if (previewTimeout) {
clearTimeout(previewTimeout);
}
previewTimeout = setTimeout(() => {
if (currentPreview) {
currentPreview.style.display = 'none';
}
}, 100);
}
// Initialize preview on page load
document.addEventListener('DOMContentLoaded', function() {
const hasTable = document.querySelector('table');
const isTicketPage = window.location.pathname.includes('/ticket/');
if (hasTable && !isTicketPage) {
initTicketPreview();
}
});
/**
* Toggle export dropdown menu
*/
function toggleExportMenu(event) {
event.stopPropagation();
const dropdown = document.getElementById('exportDropdown');
const content = document.getElementById('exportDropdownContent');
if (dropdown && content) {
dropdown.classList.toggle('open');
}
}
// Close export dropdown when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('exportDropdown');
if (dropdown && !dropdown.contains(event.target)) {
dropdown.classList.remove('open');
}
});
/**
* Export selected tickets to CSV or JSON
*/
function exportSelectedTickets(format) {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
return;
}
// Build URL with selected ticket IDs
const params = new URLSearchParams();
params.set('format', format);
params.set('ticket_ids', ticketIds.join(','));
// Trigger download
window.location.href = '/api/export_tickets.php?' + params.toString();
// Close dropdown
const dropdown = document.getElementById('exportDropdown');
if (dropdown) dropdown.classList.remove('open');
}
// ========================================
// Skeleton Loading Helpers
// ========================================
/**
* Generate skeleton table rows
*/
function generateSkeletonRows(count = 5) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<tr class="skeleton-row-tr">
<td><div class="skeleton skeleton-text" style="width: 80px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 40px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: ${70 + Math.random() * 30}%;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 60px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 50px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 70px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 80px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 80px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 70px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 70px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 60px;"></div></td>
</tr>
`;
}
return html;
}
/**
* Generate skeleton comments
*/
function generateSkeletonComments(count = 3) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<div class="skeleton-comment">
<div class="skeleton-comment-header">
<div class="skeleton skeleton-avatar"></div>
<div class="skeleton-comment-meta">
<div class="skeleton skeleton-text short"></div>
<div class="skeleton skeleton-text" style="width: 100px;"></div>
</div>
</div>
<div class="skeleton skeleton-text long"></div>
<div class="skeleton skeleton-text medium"></div>
<div class="skeleton skeleton-text short"></div>
</div>
`;
}
return html;
}
/**
* Generate skeleton stat cards
*/
function generateSkeletonStats(count = 4) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<div class="skeleton-stat skeleton">
<div class="skeleton skeleton-value"></div>
<div class="skeleton skeleton-label"></div>
</div>
`;
}
return html;
}
/**
* Show loading overlay on element
*/
function showLoadingOverlay(element, message = 'Loading...') {
// Remove existing overlay
const existing = element.querySelector('.loading-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'loading-overlay';
overlay.innerHTML = `
<div class="loading-spinner"></div>
<div class="loading-text">${message}</div>
`;
element.style.position = 'relative';
element.appendChild(overlay);
}
/**
* Hide loading overlay
*/
function hideLoadingOverlay(element) {
const overlay = element.querySelector('.loading-overlay');
if (overlay) {
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s';
setTimeout(() => overlay.remove(), 300);
}
}
// Export for use in other scripts
window.generateSkeletonRows = generateSkeletonRows;
window.generateSkeletonComments = generateSkeletonComments;
window.showLoadingOverlay = showLoadingOverlay;
window.hideLoadingOverlay = hideLoadingOverlay;