c6037a9ccc
- TicketView: ticket age was measuring from last update not creation; fixed to always use created_at - dashboard.js: bulk assign used non-existent onSelect callback (no selection was ever stored); fixed to onChange with selected[0], added max:1 to enforce single-select - base.js: lt.combobox Enter key only fired when focusedIdx >= 0; now falls back to first filtered result when no arrow key used - DashboardView + dashboard.js + dashboard.css: add COLS ▾ button on table header that opens a checkbox panel to show/hide optional columns (Ticket ID, Category, Type, Created By, Assigned To, Created, Updated); state persisted in localStorage, Reset button restores all; core columns (Priority, Title, Status, Actions) always visible; data-col attributes added to all th/td for CSS targeting Notifications bell: was functional all along — was broken by the notifications.php 500 error (now fixed). Avg resolution: correct, tickets genuinely take ~158 days average on this dataset. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1622 lines
60 KiB
JavaScript
1622 lines
60 KiB
JavaScript
/**
|
||
* 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':
|
||
showBulkDeleteModal();
|
||
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':
|
||
setViewMode(target.dataset.mode);
|
||
break;
|
||
// Settings
|
||
case 'open-settings':
|
||
case 'open-settings-modal':
|
||
if (typeof openSettingsModal === 'function') openSettingsModal();
|
||
break;
|
||
case 'close-settings':
|
||
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||
break;
|
||
case 'save-settings':
|
||
if (typeof saveSettings === 'function') saveSettings();
|
||
break;
|
||
// Refresh — use lt.autoRefresh.now() so modal/focus guards are respected
|
||
case 'manual-refresh':
|
||
if (window.lt && lt.autoRefresh) lt.autoRefresh.now();
|
||
else 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);
|
||
}
|
||
showTableSkeleton(5); 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);
|
||
});
|
||
}
|
||
|
||
var _bulkAssignUserId = null; // set by combobox onSelect
|
||
|
||
function showBulkAssignModal() {
|
||
const ticketIds = getSelectedTicketIds();
|
||
|
||
if (ticketIds.length === 0) {
|
||
lt.toast.warning('No tickets selected', 2000);
|
||
return;
|
||
}
|
||
|
||
_bulkAssignUserId = null;
|
||
|
||
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 class="lt-label">Assign to:</label>
|
||
<div class="lt-combobox" id="bulkAssignCombobox">
|
||
<div class="lt-combobox-input-wrap">
|
||
<input type="text" class="lt-combobox-input" id="bulkAssignUserInput"
|
||
placeholder="Search users…" autocomplete="off" aria-label="Search users">
|
||
</div>
|
||
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
|
||
</div>
|
||
<span class="lt-field-hint lt-text-muted">Type to search — supports large user lists</span>
|
||
</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');
|
||
|
||
lt.api.get('/api/get_users.php')
|
||
.then(data => {
|
||
if (data.success && data.users) {
|
||
const input = document.getElementById('bulkAssignUserInput');
|
||
if (!input) return;
|
||
const items = data.users.map(u => ({
|
||
value: String(u.user_id),
|
||
label: u.display_name || u.username
|
||
}));
|
||
lt.combobox.init(input, items, {
|
||
max: 1,
|
||
onChange: function(selected) { _bulkAssignUserId = selected[0] || null; }
|
||
});
|
||
}
|
||
})
|
||
.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 = _bulkAssignUserId;
|
||
const ticketIds = getSelectedTicketIds();
|
||
|
||
if (!userId) {
|
||
lt.toast.warning('Please select a user from the list', 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);
|
||
}
|
||
showTableSkeleton(5); 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 priorityEl = document.getElementById('bulkPriority');
|
||
if (!priorityEl) return;
|
||
const priority = priorityEl.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);
|
||
}
|
||
showTableSkeleton(5); 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>
|
||
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
|
||
</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 bulkStatusEl = document.getElementById('bulkStatus');
|
||
if (!bulkStatusEl) return;
|
||
const status = bulkStatusEl.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);
|
||
}
|
||
showTableSkeleton(5); 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 lt-text-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);
|
||
showTableSkeleton(5); 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 quickStatusEl = document.getElementById('quickStatusSelect');
|
||
if (!quickStatusEl) return;
|
||
const newStatus = quickStatusEl.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);
|
||
showTableSkeleton(5); 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);
|
||
});
|
||
}
|
||
|
||
var _quickAssignUserId = undefined; // undefined = no change; null = unassign; string = user_id
|
||
|
||
/**
|
||
* Quick assign from dashboard
|
||
*/
|
||
function quickAssign(ticketId) {
|
||
_quickAssignUserId = undefined;
|
||
|
||
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 lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
|
||
<label class="lt-label">Assign to:</label>
|
||
<div class="lt-combobox" id="quickAssignCombobox">
|
||
<div class="lt-combobox-input-wrap">
|
||
<input type="text" class="lt-combobox-input" id="quickAssignInput"
|
||
placeholder="Search users…" autocomplete="off" aria-label="Search users">
|
||
</div>
|
||
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
|
||
</div>
|
||
</div>
|
||
<div class="lt-modal-footer">
|
||
<button data-action="perform-quick-assign" data-ticket-id="${lt.escHtml(String(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');
|
||
|
||
lt.api.get('/api/get_users.php')
|
||
.then(data => {
|
||
if (data.success && data.users) {
|
||
const input = document.getElementById('quickAssignInput');
|
||
if (!input) return;
|
||
const items = [
|
||
{ value: '', label: 'Unassigned' },
|
||
...data.users.map(u => ({
|
||
value: String(u.user_id),
|
||
label: u.display_name || u.username
|
||
}))
|
||
];
|
||
lt.combobox.init(input, items, {
|
||
onSelect: function(item) { _quickAssignUserId = item.value || null; }
|
||
});
|
||
}
|
||
})
|
||
.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) {
|
||
if (_quickAssignUserId === undefined) {
|
||
lt.toast.warning('Please select a user from the list', 2000);
|
||
return;
|
||
}
|
||
const assignedTo = _quickAssignUserId;
|
||
|
||
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);
|
||
showTableSkeleton(5); 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.dataset.ticketId = ticketId;
|
||
card.dataset.status = status;
|
||
card.addEventListener('click', (e) => {
|
||
// Don't navigate if drag just ended (drag adds/removes is-dragging briefly)
|
||
if (card.dataset.dragged) { delete card.dataset.dragged; return; }
|
||
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) + ')';
|
||
});
|
||
|
||
// ── Kanban drag-and-drop via lt.sortable ──────────────────────
|
||
if (window.lt && lt.sortable) {
|
||
const colStatusMap = {
|
||
'kanban-col-open': 'Open',
|
||
'kanban-col-pending': 'Pending',
|
||
'kanban-col-inprogress': 'In Progress',
|
||
'kanban-col-closed': 'Closed',
|
||
};
|
||
|
||
function handleKanbanSort(newItems, movedCard) {
|
||
if (!movedCard) return;
|
||
const newColEl = movedCard.parentNode;
|
||
const newColId = newColEl ? newColEl.id : null;
|
||
const newStatus = colStatusMap[newColId];
|
||
const oldStatus = movedCard.dataset.status;
|
||
const ticketId = movedCard.dataset.ticketId;
|
||
|
||
if (!newStatus || !ticketId || newStatus === oldStatus) return;
|
||
|
||
movedCard.dataset.status = newStatus;
|
||
movedCard.dataset.dragged = '1';
|
||
|
||
// Optimistically update column counts
|
||
const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`);
|
||
const inc = document.querySelector(`.column-count[data-status="${newStatus}"]`);
|
||
if (dec) dec.textContent = '(' + Math.max(0, (parseInt(dec.textContent.replace(/\D/g,''),10)||1) - 1) + ')';
|
||
if (inc) inc.textContent = '(' + ((parseInt(inc.textContent.replace(/\D/g,''),10)||0) + 1) + ')';
|
||
|
||
// POST status update
|
||
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: parseInt(ticketId, 10), status: newStatus })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
|
||
movedCard.dataset.status = newStatus;
|
||
} else {
|
||
lt.toast.error('Status update failed: ' + (data.error || 'Unknown error'));
|
||
// Revert: put card back in original column
|
||
const origCol = document.getElementById(Object.keys(colStatusMap).find(k => colStatusMap[k] === oldStatus));
|
||
if (origCol) origCol.appendChild(movedCard);
|
||
movedCard.dataset.status = oldStatus;
|
||
}
|
||
})
|
||
.catch(() => {
|
||
lt.toast.error('Network error — status not saved');
|
||
});
|
||
}
|
||
|
||
Object.keys(columns).forEach(status => {
|
||
const col = columns[status];
|
||
if (col) lt.sortable.init(col, { group: 'kanban', onSort: handleKanbanSort });
|
||
});
|
||
}
|
||
}
|
||
|
||
// Restore view mode on page load — lt.tabs already restores the active panel visually
|
||
// via lt_activeTab_<path>; we just need to populate kanban cards if that panel is active
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
try {
|
||
const savedTab = localStorage.getItem('lt_activeTab_' + location.pathname);
|
||
if (savedTab === 'tab-kanban') {
|
||
populateKanbanCards();
|
||
}
|
||
} catch (_) {}
|
||
});
|
||
|
||
// ========================================
|
||
// 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 — element is position:fixed so coords are
|
||
// viewport-relative; getBoundingClientRect() already returns viewport coords,
|
||
// do NOT add scrollX/scrollY
|
||
const rect = link.getBoundingClientRect();
|
||
const previewWidth = 320;
|
||
const previewHeight = 200;
|
||
|
||
let left = rect.left;
|
||
let top = rect.bottom + 5;
|
||
|
||
// Adjust if going off-screen
|
||
if (left + previewWidth > window.innerWidth) {
|
||
left = window.innerWidth - previewWidth - 20;
|
||
}
|
||
if (top + previewHeight > window.innerHeight) {
|
||
top = rect.top - previewHeight - 5;
|
||
}
|
||
if (left < 0) left = 4;
|
||
if (top < 0) top = 4;
|
||
|
||
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();
|
||
}
|
||
});
|
||
|
||
// Hide preview when a modal opens, user scrolls, or page is about to navigate
|
||
document.addEventListener('click', function(e) {
|
||
if (e.target.closest('[data-modal-open], [data-action="open-advanced-search"], .lt-pagination a, .lt-pagination button')) {
|
||
hideTicketPreview();
|
||
if (currentPreview) currentPreview.classList.add('is-hidden');
|
||
}
|
||
}, true);
|
||
document.addEventListener('scroll', function() {
|
||
if (currentPreview && !currentPreview.classList.contains('is-hidden')) {
|
||
currentPreview.classList.add('is-hidden');
|
||
if (previewTimeout) { clearTimeout(previewTimeout); previewTimeout = null; }
|
||
}
|
||
}, { passive: true });
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
/**
|
||
* Replace table body rows with skeleton placeholders before a page reload.
|
||
* Gives visual feedback that a reload is in progress.
|
||
*/
|
||
function showTableSkeleton(rowCount) {
|
||
rowCount = rowCount || 5;
|
||
const tbody = document.querySelector('#tickets-table tbody');
|
||
if (!tbody) return;
|
||
let html = '';
|
||
for (let i = 0; i < rowCount; i++) {
|
||
html += '<tr class="lt-skeleton-row" aria-hidden="true">' +
|
||
'<td><div class="lt-skeleton" style="height:0.8rem;width:100%"></div></td>'.repeat(6) +
|
||
'</tr>';
|
||
}
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
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;
|
||
showTableSkeleton(6);
|
||
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;
|
||
|
||
// ── Column visibility toggle ──────────────────────────────────────
|
||
(function initColToggle() {
|
||
const LS_KEY = 'lt_col_visibility';
|
||
|
||
function getHidden() {
|
||
try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch(_) { return []; }
|
||
}
|
||
function saveHidden(cols) {
|
||
try { localStorage.setItem(LS_KEY, JSON.stringify(cols)); } catch(_) {}
|
||
}
|
||
|
||
function applyVisibility(hidden) {
|
||
const table = document.getElementById('tickets-table');
|
||
if (!table) return;
|
||
// All toggleable columns
|
||
const all = ['ticket_id','category','type','created_by','assigned_to','created_at','updated_at'];
|
||
all.forEach(col => {
|
||
const vis = !hidden.includes(col);
|
||
table.querySelectorAll('[data-col="' + col + '"]').forEach(el => {
|
||
el.style.display = vis ? '' : 'none';
|
||
});
|
||
});
|
||
// Update checkboxes
|
||
document.querySelectorAll('.col-toggle-cb').forEach(cb => {
|
||
cb.checked = !hidden.includes(cb.dataset.col);
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const btn = document.getElementById('colToggleBtn');
|
||
const panel = document.getElementById('colTogglePanel');
|
||
const reset = document.getElementById('colToggleReset');
|
||
if (!btn || !panel) return;
|
||
|
||
// Apply saved state on load
|
||
applyVisibility(getHidden());
|
||
|
||
// Toggle panel open/close
|
||
btn.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
const open = panel.getAttribute('aria-hidden') === 'false';
|
||
panel.setAttribute('aria-hidden', open ? 'true' : 'false');
|
||
btn.setAttribute('aria-expanded', open ? 'false' : 'true');
|
||
btn.textContent = (open ? 'COLS \u25BE' : 'COLS \u25B4');
|
||
});
|
||
|
||
// Close on outside click
|
||
document.addEventListener('click', function(e) {
|
||
if (!btn.contains(e.target) && !panel.contains(e.target)) {
|
||
panel.setAttribute('aria-hidden', 'true');
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
btn.textContent = 'COLS \u25BE';
|
||
}
|
||
});
|
||
|
||
// Checkbox change
|
||
panel.addEventListener('change', function(e) {
|
||
if (!e.target.classList.contains('col-toggle-cb')) return;
|
||
const hidden = Array.from(document.querySelectorAll('.col-toggle-cb'))
|
||
.filter(cb => !cb.checked)
|
||
.map(cb => cb.dataset.col);
|
||
saveHidden(hidden);
|
||
applyVisibility(hidden);
|
||
});
|
||
|
||
// Reset
|
||
if (reset) {
|
||
reset.addEventListener('click', function() {
|
||
saveHidden([]);
|
||
applyVisibility([]);
|
||
});
|
||
}
|
||
});
|
||
})();
|