8cb7cc0356
toggle-sidebar action was only in the DashboardView inline script, not in dashboard.js where toggleSidebar() is defined. Move it into the dashboard.js event delegation switch so it's guaranteed to fire. Also fix beta webhook: was using a different secret than production so Gitea pushes to development never triggered the beta deploy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1627 lines
61 KiB
JavaScript
1627 lines
61 KiB
JavaScript
/**
|
||
* Toggle sidebar visibility on desktop
|
||
*/
|
||
function toggleSidebar() {
|
||
const sidebar = document.getElementById('lt-sidebar');
|
||
if (!sidebar) return;
|
||
const isCollapsed = sidebar.classList.toggle('collapsed');
|
||
const btn = sidebar.querySelector('.lt-sidebar-toggle');
|
||
if (btn) {
|
||
btn.textContent = isCollapsed ? '\u25B6' : '\u25C0';
|
||
btn.setAttribute('aria-label', isCollapsed ? 'Expand filter sidebar' : 'Collapse filter sidebar');
|
||
btn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
|
||
}
|
||
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('lt-sidebar');
|
||
if (savedState === 'true' && sidebar) {
|
||
sidebar.classList.add('collapsed');
|
||
const btn = sidebar.querySelector('.lt-sidebar-toggle');
|
||
if (btn) {
|
||
btn.textContent = '\u25B6';
|
||
btn.setAttribute('aria-label', 'Expand filter sidebar');
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
}
|
||
});
|
||
|
||
// 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;
|
||
// Sidebar toggle
|
||
case 'toggle-sidebar':
|
||
toggleSidebar();
|
||
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 if (filterType === 'created_from') {
|
||
params.delete('created_from'); params.delete('created_to');
|
||
} else if (filterType === 'updated_from') {
|
||
params.delete('updated_from'); params.delete('updated_to');
|
||
} else if (filterType === 'closed_from') {
|
||
params.delete('closed_from'); params.delete('closed_to');
|
||
} 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);
|
||
|
||
// Checkboxes
|
||
const selectedStatuses = Array.from(
|
||
document.querySelectorAll('.lt-filter-group input[name="status"]:checked')
|
||
).map(cb => cb.value);
|
||
const selectedCategories = Array.from(
|
||
document.querySelectorAll('.lt-filter-group input[name="category"]:checked')
|
||
).map(cb => cb.value);
|
||
const selectedTypes = Array.from(
|
||
document.querySelectorAll('.lt-filter-group input[name="type"]:checked')
|
||
).map(cb => cb.value);
|
||
|
||
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');
|
||
|
||
// Date inputs
|
||
const dateFields = ['created_from','created_to','updated_from','updated_to','closed_from','closed_to'];
|
||
dateFields.forEach(name => {
|
||
const el = document.getElementById('filter-' + name.replace('_', '-'));
|
||
if (el && el.value) params.set(name, el.value);
|
||
else params.delete(name);
|
||
});
|
||
|
||
params.set('page', '1');
|
||
window.location.search = params.toString();
|
||
});
|
||
}
|
||
|
||
if (clearFiltersBtn) {
|
||
clearFiltersBtn.addEventListener('click', () => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
['status','category','type',
|
||
'created_from','created_to','updated_from','updated_to','closed_from','closed_to'
|
||
].forEach(k => params.delete(k));
|
||
params.set('page', '1');
|
||
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;
|
||
|
||
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 lt-modal-sm">
|
||
<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" for="bulkAssignUserInput">Assign to</label>
|
||
<div class="lt-typeahead" id="bulkAssignTypeahead" style="position:relative">
|
||
<input type="text" class="lt-input lt-w-full" id="bulkAssignUserInput"
|
||
placeholder="Type a name…" autocomplete="off" spellcheck="false"
|
||
aria-label="Search users" aria-autocomplete="list">
|
||
<div class="lt-typeahead-dropdown" id="bulkAssignDropdown"></div>
|
||
</div>
|
||
<div id="bulkAssignSelected" style="margin-top:0.4rem;font-size:0.75rem;color:var(--terminal-cyan);min-height:1.2em"></div>
|
||
</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');
|
||
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
|
||
|
||
lt.api.get('/api/get_users.php')
|
||
.then(data => {
|
||
if (!data.success || !data.users) return;
|
||
const input = document.getElementById('bulkAssignUserInput');
|
||
if (!input) return;
|
||
const items = data.users.map(u => ({
|
||
value: String(u.user_id),
|
||
label: u.display_name ? u.display_name + ' (' + u.username + ')' : u.username
|
||
}));
|
||
lt.typeahead.init(input, items, {
|
||
minChars: 1,
|
||
maxResults: 8,
|
||
onSelect: function(item) {
|
||
_bulkAssignUserId = item.value;
|
||
const sel = document.getElementById('bulkAssignSelected');
|
||
if (sel) sel.textContent = '✓ ' + item.label;
|
||
}
|
||
});
|
||
})
|
||
.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([]);
|
||
});
|
||
}
|
||
});
|
||
})();
|