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