diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css index 2a8299e..522c625 100644 --- a/assets/css/dashboard.css +++ b/assets/css/dashboard.css @@ -204,6 +204,108 @@ kbd { /* ── lt-msg variants ─────────────────────────────────────────── */ .lt-mb-md { margin-bottom: 1rem; } +/* ── Kanban cards ────────────────────────────────────────────── */ +.lt-kanban-card { + padding: 0.6rem 0.75rem; + border: 1px solid rgba(0, 255, 65, 0.2); + margin-bottom: 0.4rem; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.lt-kanban-card:hover, +.lt-kanban-card:focus-visible { + border-color: var(--lt-text-primary, #00ff41); + background: rgba(0, 255, 65, 0.04); + outline: none; +} +.lt-kanban-card--p1 { border-left: 2px solid var(--lt-danger, #ff4d4d); } +.lt-kanban-card--p2 { border-left: 2px solid var(--lt-amber, #ffb000); } +.lt-kanban-card--p3 { border-left: 2px solid var(--lt-cyan, #00ffff); } +.lt-kanban-card--p4 { border-left: 2px solid rgba(0, 255, 65, 0.4); } +.lt-kanban-card--p5 { border-left: 2px solid rgba(0, 255, 65, 0.2); } + +.lt-kanban-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} +.lt-kanban-card-title { + font-size: 0.78rem; + line-height: 1.35; + font-weight: 600; + word-break: break-word; +} +.lt-kanban-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.1rem; +} +.lt-kanban-assignee { + width: 1.5rem; + height: 1.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(0, 255, 65, 0.35); + font-size: 0.6rem; + font-weight: 700; + flex-shrink: 0; +} + +/* ── Mobile sidebar overlay ──────────────────────────────────── */ +.mobile-sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 199; +} +.mobile-sidebar-overlay.active { display: block; } + +/* ── Mobile filter toggle button ─────────────────────────────── */ +.mobile-filter-toggle { + display: none; + width: 100%; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + background: transparent; + border: 1px solid rgba(0, 255, 65, 0.3); + color: var(--lt-text-primary, #00ff41); + font-family: inherit; + font-size: 0.75rem; + letter-spacing: 0.05em; + cursor: pointer; + text-align: left; +} + +/* ── Ticket preview popup ────────────────────────────────────── */ +.ticket-preview-popup { + position: fixed; + z-index: 9999; + background: var(--lt-surface, #0a0e14); + border: 1px solid rgba(0, 255, 65, 0.4); + padding: 0.75rem; + min-width: 280px; + max-width: 360px; + font-size: 0.75rem; + pointer-events: auto; + box-shadow: 0 4px 20px rgba(0,0,0,0.5); +} +.ticket-preview-popup .preview-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.4rem; +} +.ticket-preview-popup .preview-id { color: var(--lt-cyan, #00ffff); font-weight: 700; } +.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; } +.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; } +.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; } + /* ── Responsive ──────────────────────────────────────────────── */ @media (max-width: 768px) { .lt-page-header { @@ -214,10 +316,10 @@ kbd { .lt-stats-grid { grid-template-columns: repeat(2, 1fr); } + .mobile-filter-toggle { display: block; } + .lt-sidebar.mobile-open { transform: translateX(0); } } @media (max-width: 480px) { - .lt-stats-grid { - grid-template-columns: 1fr; - } + .lt-stats-grid { grid-template-columns: 1fr; } } diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index c301979..2018137 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -72,31 +72,6 @@ function initMobileSidebar() { sidebar.insertBefore(closeBtn, sidebar.firstChild); } - // Create mobile bottom navigation - if (!document.getElementById('mobileBottomNav')) { - const nav = document.createElement('nav'); - nav.id = 'mobileBottomNav'; - nav.className = 'mobile-bottom-nav'; - nav.innerHTML = ` - - [ ~ ] - HOME - - - - [ + ] - NEW - - - `; - document.body.appendChild(nav); - } } // Restore sidebar state on page load @@ -143,7 +118,30 @@ document.addEventListener('DOMContentLoaded', function() { const action = target.dataset.action; switch (action) { - // Bulk operations + // Navigation + case 'navigate': + if (target.dataset.url) window.location.href = target.dataset.url; + break; + case 'view-ticket': + if (target.dataset.ticketId) window.location.href = '/ticket/' + target.dataset.ticketId; + break; + // Bulk action triggers (show modals) + case 'bulk-status': + if (typeof showBulkStatusModal === 'function') showBulkStatusModal(); + break; + case 'bulk-assign': + if (typeof showBulkAssignModal === 'function') showBulkAssignModal(); + break; + case 'bulk-priority': + if (typeof showBulkPriorityModal === 'function') showBulkPriorityModal(); + break; + case 'bulk-delete': + if (typeof bulkDelete === 'function') bulkDelete(); + break; + case 'clear-selection': + clearSelection(); + break; + // Bulk operation perform actions case 'perform-bulk-assign': performBulkAssign(); break; @@ -168,26 +166,63 @@ document.addEventListener('DOMContentLoaded', function() { case 'close-bulk-delete-modal': closeBulkDeleteModal(); break; + // Checkbox selection + case 'toggle-select-all': + toggleSelectAll(); + break; + case 'update-selection': + updateSelectionCount(); + break; + case 'toggle-row-checkbox': + toggleRowCheckbox(e, target); + break; // Quick actions + case 'quick-status': + quickStatusChange(target.dataset.ticketId, target.dataset.status); + break; case 'perform-quick-status': performQuickStatusChange(target.dataset.ticketId); break; case 'close-quick-status-modal': closeQuickStatusModal(); break; + case 'quick-assign': + quickAssign(target.dataset.ticketId); + break; case 'perform-quick-assign': performQuickAssign(target.dataset.ticketId); break; case 'close-quick-assign-modal': closeQuickAssignModal(); break; + // View mode toggle + case 'set-view-mode': + if (target.dataset.mode === 'card') populateKanbanCards(); + break; + // Settings + case 'open-settings': + case 'open-settings-modal': + if (typeof openSettingsModal === 'function') openSettingsModal(); + break; + // Refresh + case 'manual-refresh': + window.location.reload(); + break; + // Export + case 'toggle-export-menu': + toggleExportMenu(e); + break; + case 'export-tickets': + exportSelectedTickets(target.dataset.format); + break; + // Advanced search + case 'open-advanced-search': + if (typeof openAdvancedSearch === 'function') openAdvancedSearch(); + break; // Mobile navigation case 'open-mobile-sidebar': if (typeof openMobileSidebar === 'function') openMobileSidebar(); break; - case 'open-settings-modal': - if (typeof openSettingsModal === 'function') openSettingsModal(); - break; // Filter badge actions case 'remove-filter': removeFilter(target.dataset.filterType, target.dataset.filterValue); @@ -682,14 +717,14 @@ function updateSelectionCount() { const exportDropdown = document.getElementById('exportDropdown'); const exportCount = document.getElementById('exportCount'); - if (toolbar && countDisplay) { - toolbar.classList.toggle('is-visible', count > 0); - if (count > 0) countDisplay.textContent = count; + if (toolbar) { + toolbar.style.display = count > 0 ? 'flex' : 'none'; + if (count > 0 && countDisplay) countDisplay.textContent = count; } // Show/hide export dropdown based on selection if (exportDropdown) { - exportDropdown.classList.toggle('is-visible', count > 0); + exportDropdown.style.display = count > 0 ? 'inline-flex' : 'none'; if (count > 0 && exportCount) exportCount.textContent = count; } } @@ -1278,27 +1313,10 @@ function performQuickAssign(ticketId) { * 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; - + // TDS v1.2 uses lt.tabs for show/hide; we just need to populate kanban cards if (mode === 'card') { - tableView.classList.add('is-hidden'); - cardView.classList.remove('is-hidden'); - tableBtn.classList.remove('active'); - cardBtn.classList.add('active'); populateKanbanCards(); - } else { - tableView.classList.remove('is-hidden'); - cardView.classList.add('is-hidden'); - tableBtn.classList.add('active'); - cardBtn.classList.remove('active'); } - - // Store preference localStorage.setItem('ticketViewMode', mode); } @@ -1306,18 +1324,17 @@ function setViewMode(mode) { * Populate Kanban cards from table data */ function populateKanbanCards() { - const rows = document.querySelectorAll('tbody tr'); + const rows = document.querySelectorAll('#tickets-table tbody tr'); + // TDS v1.2 kanban columns use id="kanban-col-{slug}" with .kanban-cards child const columns = { - 'Open': document.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') + 'Open': document.getElementById('kanban-col-open'), + 'Pending': document.getElementById('kanban-col-pending'), + 'In Progress': document.getElementById('kanban-col-inprogress'), + 'Closed': document.getElementById('kanban-col-closed'), }; // Clear existing cards - Object.values(columns).forEach(col => { - if (col) col.innerHTML = ''; - }); + 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; @@ -1325,53 +1342,60 @@ function populateKanbanCards() { rows.forEach(row => { const cells = row.querySelectorAll('td'); - if (cells.length < 6) return; // Skip empty rows + if (cells.length < 6) return; - 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 ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || ''; + const priorityEl = cells[1 + offset]?.querySelector('[class*="lt-p"]'); + const priority = priorityEl ? priorityEl.textContent.trim().replace('P','') : cells[1 + offset]?.textContent.trim() || '4'; + const title = cells[2 + offset]?.textContent.trim() || ''; + const category = cells[3 + offset]?.textContent.trim() || ''; + const statusEl = cells[5 + offset]?.querySelector('.lt-status'); + const status = statusEl ? statusEl.textContent.trim() : cells[5 + offset]?.textContent.trim() || ''; const assignedTo = cells[7 + offset]?.textContent.trim() || 'Unassigned'; - // Get initials for assignee - const initials = assignedTo === 'Unassigned' ? '?' : - assignedTo.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); + const initials = assignedTo === 'Unassigned' ? '?' + : assignedTo.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); const column = columns[status]; - if (column) { - counts[status]++; + if (!column || !ticketId) return; - const card = document.createElement('div'); - card.className = `kanban-card priority-${priority}`; - card.onclick = () => window.location.href = `/ticket/${ticketId}`; - card.innerHTML = ` -
- #${lt.escHtml(ticketId)} - -
-
${lt.escHtml(title)}
- - `; - column.appendChild(card); - } + counts[status] = (counts[status] || 0) + 1; + + const pNum = parseInt(priority, 10) || 4; + const card = document.createElement('div'); + card.className = 'lt-kanban-card lt-kanban-card--p' + pNum; + card.setAttribute('role', 'button'); + card.setAttribute('tabindex', '0'); + card.onclick = () => window.location.href = '/ticket/' + encodeURIComponent(ticketId); + card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); }; + card.innerHTML = + '
' + + '#' + lt.escHtml(ticketId) + '' + + 'P' + pNum + '' + + '
' + + '
' + lt.escHtml(title) + '
' + + ''; + + 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]; + document.querySelectorAll('.column-count[data-status]').forEach(el => { + const s = el.dataset.status; + el.textContent = '(' + (counts[s] || 0) + ')'; }); } -// Restore view mode on page load +// Restore view mode on page load — click the kanban tab button to trigger lt.tabs document.addEventListener('DOMContentLoaded', function() { const savedMode = localStorage.getItem('ticketViewMode'); if (savedMode === 'card') { - setViewMode('card'); + const cardBtn = document.getElementById('cardViewBtn'); + if (cardBtn) cardBtn.click(); + else populateKanbanCards(); } }); diff --git a/assets/js/ticket.js b/assets/js/ticket.js index 0b25941..41f61ec 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -410,9 +410,9 @@ function performStatusChange(statusSelect, selectedOption, newStatus) { lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus }) .then(data => { if (data.success) { - // Update the dropdown to show new status as current - const newClass = 'status-' + newStatus.toLowerCase().replace(/ /g, '-'); - statusSelect.className = 'editable status-select ' + newClass; + // Update the dropdown to show new status as current (preserve TDS v1.2 classes) + const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-'); + statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass; // Update the selected option text to show as current selectedOption.text = newStatus + ' (current)'; @@ -440,43 +440,11 @@ function performStatusChange(statusSelect, selectedOption, newStatus) { } function showTab(tabName) { - // Hide all tab contents - const descriptionTab = document.getElementById('description-tab'); - const commentsTab = document.getElementById('comments-tab'); - const attachmentsTab = document.getElementById('attachments-tab'); - const dependenciesTab = document.getElementById('dependencies-tab'); - const activityTab = document.getElementById('activity-tab'); - - if (!descriptionTab || !commentsTab) { - return; - } - - // Hide all tabs - document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active')); - - // Remove active class and aria-selected from all buttons - document.querySelectorAll('.tab-btn').forEach(btn => { - btn.classList.remove('active'); - btn.setAttribute('aria-selected', 'false'); - }); - - // Show selected tab and activate its button - const tabEl = document.getElementById(`${tabName}-tab`); - if (tabEl) tabEl.classList.add('active'); - const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`); - if (activeBtn) { - activeBtn.classList.add('active'); - activeBtn.setAttribute('aria-selected', 'true'); - } - - // Load attachments when tab is shown + // Load content for tabs that require it (TDS v1.2 handles the actual show/hide via lt.tabs) if (tabName === 'attachments') { loadAttachments(); initializeUploadZone(); - } - - // Load dependencies when tab is shown - if (tabName === 'dependencies') { + } else if (tabName === 'dependencies') { loadDependencies(); } } diff --git a/views/DashboardView.php b/views/DashboardView.php index 96c7281..b447941 100644 --- a/views/DashboardView.php +++ b/views/DashboardView.php @@ -423,7 +423,8 @@ include __DIR__ . '/layout_header.php'; class="ticket-link"> - P + 'lt-chip-critical', 2 => 'lt-chip-warn', 3 => 'lt-chip-info', default => 'lt-chip-ok' }; ?> + P diff --git a/views/TicketView.php b/views/TicketView.php index e8dbbbc..841094d 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -370,6 +370,11 @@ include __DIR__ . '/layout_header.php'; $threadClass = $parentId ? 'comment-reply' : 'comment-root'; $dateStr = date('M d, Y H:i', strtotime($comment['created_at'])); $editedIndicator = !empty($comment['updated_at']) ? ' (edited)' : ''; + // Avatar initials + color + $words = array_filter(explode(' ', $displayName)); + $initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2)))); + $avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', '']; + $avatarColor = $avatarColors[abs(crc32($displayName)) % count($avatarColors)]; ?>
+ API Usage

Include the API key in your requests using the Authorization header:

-
Authorization: Bearer YOUR_API_KEY
-

API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.

+
+
+ HTTP HEADER + +
+
Authorization: Bearer YOUR_API_KEY
+
+

+ Example — create a ticket via cURL:
+

+
+
CURL
+
curl -X POST https://your-instance/api/create_ticket.php \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'
+
+

API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.

@@ -125,8 +143,13 @@ document.addEventListener('click', function (e) { var target = e.target.closest('[data-action]'); if (!target) return; switch (target.getAttribute('data-action')) { - case 'copy-api-key': copyApiKey(); break; - case 'revoke-key': revokeKey(target.getAttribute('data-id')); break; + case 'copy-api-key': copyApiKey(); break; + case 'revoke-key': revokeKey(target.getAttribute('data-id')); break; + case 'copy-header-example': + navigator.clipboard.writeText('Authorization: Bearer YOUR_API_KEY') + .then(function() { lt.toast.success('Copied!'); }) + .catch(function() { lt.toast.error('Copy failed'); }); + break; } });