Files
tinker_tickets/assets/js/dashboard.js
T
jared 2e450dc01d Apply web_template gap analysis improvements (P1-P3)
P1-A: Fix CSP - add fonts.googleapis.com to style-src, fonts.gstatic.com to font-src
P1-B: CSRF token rotation - add rotateToken() to CsrfMiddleware; bootstrap.php rotates
      after successful validation and stores in $GLOBALS['_new_csrf_token']; add
      apiRespond() helper to append token to responses; lt.api interceptor in
      layout_footer.php auto-updates window.CSRF_TOKEN from responses
P1-C: Styled 403/404 error views with TDS layout instead of raw text; index.php now
      uses requireAdmin() helper eliminating 7 duplicated guard blocks (P3-D)
P2-A: Remove duplicate JS-generated keyboard help modal from keyboard-shortcuts.js;
      '?' key now routes to static #lt-keys-help modal in footer
P2-B: Asset versioning driven by config ASSET_VERSION key; base.css and base.js get
      ?v= cache-busting in layout_header.php
P2-C: Add data-theme="dark" to <html> tag to prevent FOUC on light-mode users
P2-E: Escape status value in dashboard.js hover preview class attribute via lt.escHtml()
P2-F: Replace bespoke showLoadingOverlay() with lt-spinner / lt-loading-text from
      base.css; add .lt-loading-overlay wrapper CSS to dashboard.css
P2-G: Add keyboard-shortcuts.js to all 7 admin views so J/K nav and ? help work
P3-A: APP_NAME, APP_SUBTITLE, APP_VERSION driven from config.php; layout header/footer
      use config values instead of hardcoded strings
P3-G: Replace custom initTableSorting() with lt.sortTable.init() which manages aria-sort

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:02:40 -04:00

1411 lines
51 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.
/**
* 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);
}
}
// 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
initSidebarFilters();
}
// Initialize for all pages
initSettingsModal();
// 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) {
// Navigation
case 'navigate':
if (target.dataset.url) window.location.href = target.dataset.url;
break;
case 'view-ticket':
if (target.dataset.ticketId) window.location.href = '/ticket/' + target.dataset.ticketId;
break;
// Bulk action triggers (show modals)
case 'bulk-status':
if (typeof showBulkStatusModal === 'function') showBulkStatusModal();
break;
case 'bulk-assign':
if (typeof showBulkAssignModal === 'function') showBulkAssignModal();
break;
case 'bulk-priority':
if (typeof showBulkPriorityModal === 'function') showBulkPriorityModal();
break;
case 'bulk-delete':
if (typeof bulkDelete === 'function') bulkDelete();
break;
case 'clear-selection':
clearSelection();
break;
// Bulk operation perform actions
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;
// Checkbox selection
case 'toggle-select-all':
toggleSelectAll();
break;
case 'update-selection':
updateSelectionCount();
break;
case 'toggle-row-checkbox':
toggleRowCheckbox(e, target);
break;
// Quick actions
case 'quick-status':
quickStatusChange(target.dataset.ticketId, target.dataset.status);
break;
case 'perform-quick-status':
performQuickStatusChange(target.dataset.ticketId);
break;
case 'close-quick-status-modal':
closeQuickStatusModal();
break;
case 'quick-assign':
quickAssign(target.dataset.ticketId);
break;
case 'perform-quick-assign':
performQuickAssign(target.dataset.ticketId);
break;
case 'close-quick-assign-modal':
closeQuickAssignModal();
break;
// View mode toggle
case 'set-view-mode':
if (target.dataset.mode === 'card') populateKanbanCards();
break;
// Settings
case 'open-settings':
case 'open-settings-modal':
if (typeof openSettingsModal === 'function') openSettingsModal();
break;
// Refresh
case 'manual-refresh':
window.location.reload();
break;
// Export
case 'toggle-export-menu':
toggleExportMenu(e);
break;
case 'export-tickets':
exportSelectedTickets(target.dataset.format);
break;
// Advanced search
case 'open-advanced-search':
if (typeof openAdvancedSearch === 'function') openAdvancedSearch();
break;
// Mobile navigation
case 'open-mobile-sidebar':
if (typeof openMobileSidebar === 'function') openMobileSidebar();
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() {
// Use the TDS lt.sortTable helper which manages aria-sort attributes correctly.
// Falls back to no-op if the table isn't present on this page.
if (window.lt && lt.sortTable && document.getElementById('tickets-table')) {
lt.sortTable.init('tickets-table');
}
}
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 — prefer data-ts attribute over text (which may be relative)
const headerText = headers[column].textContent.toLowerCase();
if (headerText === 'created' || headerText === 'updated') {
const cellA = a.children[column];
const cellB = b.children[column];
const dateA = new Date(cellA.dataset.ts || aValue);
const dateB = new Date(cellB.dataset.ts || 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
/**
* Bulk Actions Functions (Admin only)
*/
function toggleSelectAll() {
const selectAll = document.getElementById('selectAllCheckbox');
if (!selectAll) return;
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) {
toolbar.style.display = count > 0 ? 'flex' : 'none';
if (count > 0 && countDisplay) countDisplay.textContent = count;
}
// Show/hide export dropdown based on selection
if (exportDropdown) {
exportDropdown.style.display = count > 0 ? 'inline-flex' : 'none';
if (count > 0 && exportCount) exportCount.textContent = count;
}
}
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) {
lt.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) {
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_close',
ticket_ids: ticketIds
})
.then(data => {
if (data.success) {
if (data.failed > 0) {
lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
lt.toast.error('Bulk close failed: ' + error.message, 5000);
});
}
function showBulkAssignModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
lt.toast.warning('No tickets selected', 2000);
return;
}
// Create modal HTML
const modalHtml = `
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="bulkAssignModalTitle">Assign ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkAssignUser">Assign to:</label>
<select id="bulkAssignUser" class="lt-select">
<option value="">Select User...</option>
</select>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">ASSIGN</button>
<button data-action="close-bulk-assign-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkAssignModal');
// Fetch users for the dropdown
lt.api.get('/api/get_users.php')
.then(data => {
if (data.success && data.users) {
const select = document.getElementById('bulkAssignUser');
if (select) {
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(() => lt.toast.error('Error loading users'));
}
function closeBulkAssignModal() {
lt.modal.close('bulkAssignModal');
const modal = document.getElementById('bulkAssignModal');
if (modal) setTimeout(() => modal.remove(), 300);
}
function performBulkAssign() {
const userId = document.getElementById('bulkAssignUser').value;
const ticketIds = getSelectedTicketIds();
if (!userId) {
lt.toast.warning('Please select a user', 2000);
return;
}
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_assign',
ticket_ids: ticketIds,
parameters: { assigned_to: parseInt(userId) }
})
.then(data => {
if (data.success) {
closeBulkAssignModal();
if (data.failed > 0) {
lt.toast.warning(`Bulk assign: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
lt.toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
lt.toast.error('Bulk assign failed: ' + error.message, 5000);
});
}
function showBulkPriorityModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
lt.toast.warning('No tickets selected', 2000);
return;
}
const modalHtml = `
<div class="lt-modal-overlay" id="bulkPriorityModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkPriorityModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="bulkPriorityModalTitle">Change Priority for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkPriority">Priority:</label>
<select id="bulkPriority" class="lt-select">
<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 class="lt-modal-footer">
<button data-action="perform-bulk-priority" class="lt-btn lt-btn-primary">UPDATE</button>
<button data-action="close-bulk-priority-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkPriorityModal');
}
function closeBulkPriorityModal() {
lt.modal.close('bulkPriorityModal');
const modal = document.getElementById('bulkPriorityModal');
if (modal) setTimeout(() => modal.remove(), 300);
}
function performBulkPriority() {
const priority = document.getElementById('bulkPriority').value;
const ticketIds = getSelectedTicketIds();
if (!priority) {
lt.toast.warning('Please select a priority', 2000);
return;
}
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_priority',
ticket_ids: ticketIds,
parameters: { priority: parseInt(priority) }
})
.then(data => {
if (data.success) {
closeBulkPriorityModal();
if (data.failed > 0) {
lt.toast.warning(`Priority update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
lt.toast.success(`Successfully updated priority for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
lt.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.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;
}
});
});
});
// Bulk Status Change
function showBulkStatusModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
lt.toast.warning('No tickets selected', 2000);
return;
}
const modalHtml = `
<div class="lt-modal-overlay" id="bulkStatusModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkStatusModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="bulkStatusModalTitle">Change Status for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkStatus">New Status:</label>
<select id="bulkStatus" class="lt-select">
<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 class="lt-modal-footer">
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
<button data-action="close-bulk-status-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkStatusModal');
}
function closeBulkStatusModal() {
lt.modal.close('bulkStatusModal');
const modal = document.getElementById('bulkStatusModal');
if (modal) setTimeout(() => modal.remove(), 300);
}
function performBulkStatusChange() {
const status = document.getElementById('bulkStatus').value;
const ticketIds = getSelectedTicketIds();
if (!status) {
lt.toast.warning('Please select a status', 2000);
return;
}
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_status',
ticket_ids: ticketIds,
parameters: { status: status }
})
.then(data => {
closeBulkStatusModal();
if (data.success) {
if (data.failed > 0) {
lt.toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
});
}
// Bulk Delete
function showBulkDeleteModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
lt.toast.warning('No tickets selected', 2000);
return;
}
const modalHtml = `
<div class="lt-modal-overlay" id="bulkDeleteModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkDeleteModalTitle">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header lt-modal-header--danger">
<span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body" style="text-align:center">
<p class="lt-text-danger lt-text-sm">This action cannot be undone!</p>
<p class="lt-text-cyan">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-delete" class="lt-btn lt-btn-danger">DELETE PERMANENTLY</button>
<button data-action="close-bulk-delete-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkDeleteModal');
}
function closeBulkDeleteModal() {
lt.modal.close('bulkDeleteModal');
const modal = document.getElementById('bulkDeleteModal');
if (modal) setTimeout(() => modal.remove(), 300);
}
function performBulkDelete() {
const ticketIds = getSelectedTicketIds();
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_delete',
ticket_ids: ticketIds
})
.then(data => {
closeBulkDeleteModal();
if (data.success) {
lt.toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
lt.toast.error('Bulk delete failed: ' + error.message, 5000);
});
}
// ============================================
// TERMINAL-STYLE MODAL UTILITIES
// ============================================
/**
* 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 = lt.escHtml(title);
const safeLabel = lt.escHtml(label);
const safePlaceholder = lt.escHtml(placeholder);
const modalHtml = `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title" id="${modalId}_title">${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="${inputId}">${safeLabel}</label>
<input type="text" id="${inputId}" class="lt-input" placeholder="${safePlaceholder}" />
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_submit">SAVE</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById(modalId);
const input = document.getElementById(inputId);
lt.modal.open(modalId);
setTimeout(() => input.focus(), 100);
const cleanup = (cb) => {
lt.modal.close(modalId);
setTimeout(() => modal.remove(), 300);
if (cb) cb();
};
const handleSubmit = () => cleanup(() => onSubmit && onSubmit(input.value.trim()));
document.getElementById(`${modalId}_submit`).addEventListener('click', handleSubmit);
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
}
// ========================================
// 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="lt-modal-overlay" id="quickStatusModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickStatusModalTitle">
<div class="lt-modal lt-modal-xs">
<div class="lt-modal-header">
<span class="lt-modal-title" id="quickStatusModalTitle">Quick Status Change</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<p class="lt-mb-xs">Ticket #${lt.escHtml(ticketId)}</p>
<p class="lt-text-amber lt-mb-xs">Current: ${lt.escHtml(currentStatus)}</p>
<label for="quickStatusSelect">New Status:</label>
<select id="quickStatusSelect" class="lt-select">
${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')}
</select>
</div>
<div class="lt-modal-footer">
<button data-action="perform-quick-status" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">UPDATE</button>
<button data-action="close-quick-status-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickStatusModal');
}
function closeQuickStatusModal() {
lt.modal.close('quickStatusModal');
const modal = document.getElementById('quickStatusModal');
if (modal) setTimeout(() => modal.remove(), 300);
}
function performQuickStatusChange(ticketId) {
const newStatus = document.getElementById('quickStatusSelect').value;
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.then(data => {
closeQuickStatusModal();
if (data.success) {
lt.toast.success(`Status updated to ${newStatus}`, 3000);
setTimeout(() => window.location.reload(), 1000);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
closeQuickStatusModal();
lt.toast.error('Error updating status', 4000);
});
}
/**
* Quick assign from dashboard
*/
function quickAssign(ticketId) {
const modalHtml = `
<div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickAssignModalTitle">
<div class="lt-modal lt-modal-xs">
<div class="lt-modal-header">
<span class="lt-modal-title" id="quickAssignModalTitle">Quick Assign</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<p class="lt-mb-xs">Ticket #${lt.escHtml(ticketId)}</p>
<label for="quickAssignSelect">Assign to:</label>
<select id="quickAssignSelect" class="lt-select">
<option value="">Unassigned</option>
</select>
</div>
<div class="lt-modal-footer">
<button data-action="perform-quick-assign" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">ASSIGN</button>
<button data-action="close-quick-assign-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickAssignModal');
// Load users
lt.api.get('/api/get_users.php')
.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(() => lt.toast.error('Error loading users'));
}
function closeQuickAssignModal() {
lt.modal.close('quickAssignModal');
const modal = document.getElementById('quickAssignModal');
if (modal) setTimeout(() => modal.remove(), 300);
}
function performQuickAssign(ticketId) {
const assignedTo = document.getElementById('quickAssignSelect').value || null;
lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo })
.then(data => {
closeQuickAssignModal();
if (data.success) {
lt.toast.success('Assignment updated', 3000);
setTimeout(() => window.location.reload(), 1000);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
closeQuickAssignModal();
lt.toast.error('Error updating assignment', 4000);
});
}
// ========================================
// KANBAN / CARD VIEW
// ========================================
/**
* Set the view mode (table or card)
*/
function setViewMode(mode) {
// TDS v1.2 uses lt.tabs for show/hide; we just need to populate kanban cards
if (mode === 'card') {
populateKanbanCards();
}
localStorage.setItem('ticketViewMode', mode);
}
/**
* Populate Kanban cards from table data
*/
function populateKanbanCards() {
const rows = document.querySelectorAll('#tickets-table tbody tr');
// TDS v1.2 kanban columns use id="kanban-col-{slug}" with .kanban-cards child
const columns = {
'Open': document.getElementById('kanban-col-open'),
'Pending': document.getElementById('kanban-col-pending'),
'In Progress': document.getElementById('kanban-col-inprogress'),
'Closed': document.getElementById('kanban-col-closed'),
};
// 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;
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
const priorityEl = cells[1 + offset]?.querySelector('[class*="lt-p"]');
const priority = priorityEl ? priorityEl.textContent.trim().replace('P','') : cells[1 + offset]?.textContent.trim() || '4';
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const statusEl = cells[5 + offset]?.querySelector('.lt-status');
const status = statusEl ? statusEl.textContent.trim() : cells[5 + offset]?.textContent.trim() || '';
const assignedTo = cells[7 + offset]?.textContent.trim() || 'Unassigned';
const initials = assignedTo === 'Unassigned' ? '?'
: assignedTo.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const column = columns[status];
if (!column || !ticketId) return;
counts[status] = (counts[status] || 0) + 1;
const pNum = parseInt(priority, 10) || 4;
const card = document.createElement('div');
card.className = 'lt-kanban-card lt-kanban-card--p' + pNum;
card.setAttribute('role', 'button');
card.setAttribute('tabindex', '0');
card.onclick = () => window.location.href = '/ticket/' + encodeURIComponent(ticketId);
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
card.innerHTML =
'<div class="lt-kanban-card-header">' +
'<span class="lt-text-xs lt-text-cyan">#' + lt.escHtml(ticketId) + '</span>' +
'<span class="lt-p' + pNum + ' lt-text-xs">P' + pNum + '</span>' +
'</div>' +
'<div class="lt-kanban-card-title">' + lt.escHtml(title) + '</div>' +
'<div class="lt-kanban-card-footer">' +
'<span class="lt-text-xs lt-text-muted">' + lt.escHtml(category) + '</span>' +
'<span class="lt-kanban-assignee lt-text-xs" title="' + lt.escHtml(assignedTo) + '">' + lt.escHtml(initials) + '</span>' +
'</div>';
column.appendChild(card);
});
// Update column counts
document.querySelectorAll('.column-count[data-status]').forEach(el => {
const s = el.dataset.status;
el.textContent = '(' + (counts[s] || 0) + ')';
});
}
// Restore view mode on page load — click the kanban tab button to trigger lt.tabs
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem('ticketViewMode');
if (savedMode === 'card') {
const cardBtn = document.getElementById('cardViewBtn');
if (cardBtn) cardBtn.click();
else populateKanbanCards();
}
});
// ========================================
// 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 is-hidden';
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">#${lt.escHtml(ticketId)}</span>
<span class="preview-status status-${lt.escHtml(status.replace(/\s+/g, '-'))}">${lt.escHtml(status)}</span>
</div>
<div class="preview-title">${lt.escHtml(title)}</div>
<div class="preview-meta">
<div><strong>Priority:</strong> P${lt.escHtml(priority)}</div>
<div><strong>Category:</strong> ${lt.escHtml(category)}</div>
<div><strong>Type:</strong> ${lt.escHtml(type)}</div>
<div><strong>Assigned:</strong> ${lt.escHtml(assignedTo)}</div>
</div>
<div class="preview-footer">Created by ${lt.escHtml(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.classList.remove('is-hidden');
}, 300);
}
function hideTicketPreview() {
if (previewTimeout) {
clearTimeout(previewTimeout);
}
previewTimeout = setTimeout(() => {
if (currentPreview) {
currentPreview.classList.add('is-hidden');
}
}, 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) {
lt.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');
}
/**
* Show TDS spinner overlay on an element.
* Uses lt-spinner + lt-loading-text from base.css.
*/
function showLoadingOverlay(element, message = 'Loading...') {
const existing = element.querySelector('.lt-loading-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'lt-loading-overlay';
const spinner = document.createElement('div');
spinner.className = 'lt-spinner';
const text = document.createElement('div');
text.className = 'lt-loading-text';
text.textContent = message;
overlay.appendChild(spinner);
overlay.appendChild(text);
element.classList.add('has-lt-overlay');
element.appendChild(overlay);
}
/**
* Hide TDS spinner overlay
*/
function hideLoadingOverlay(element) {
const overlay = element.querySelector('.lt-loading-overlay');
if (overlay) {
overlay.remove();
element.classList.remove('has-lt-overlay');
}
}
// ========================================
// AUTO-REFRESH (lt.autoRefresh integration)
// ========================================
/**
* Reload the dashboard, but skip if a modal is open or user is typing.
* Registered with lt.autoRefresh so it runs every 5 minutes automatically.
*/
function dashboardAutoRefresh() {
// Don't interrupt the user if a modal is open
if (document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) return;
// Don't interrupt if focus is in a text input
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
window.location.reload();
}
document.addEventListener('DOMContentLoaded', function() {
// Only run auto-refresh on the dashboard, not on ticket pages
if (!window.location.pathname.includes('/ticket/')) {
lt.autoRefresh.start(dashboardAutoRefresh, 5 * 60 * 1000);
}
});
// ========================================
// RELATIVE TIMESTAMPS
// ========================================
/**
* Convert all .ts-cell[data-ts] elements to relative time using lt.time.ago().
* Runs once on DOMContentLoaded and refreshes every 60s so "2m ago" stays current.
* The original full timestamp is preserved in the title attribute for hover.
*/
function initRelativeTimes() {
document.querySelectorAll('.ts-cell[data-ts]').forEach(el => {
el.textContent = lt.time.ago(el.dataset.ts);
});
}
document.addEventListener('DOMContentLoaded', initRelativeTimes);
setInterval(initRelativeTimes, 60000);
// Export for use in other scripts
window.showLoadingOverlay = showLoadingOverlay;
window.hideLoadingOverlay = hideLoadingOverlay;